記事一覧

Bootstrap 流の発想を PDF へ: gpdf の 12 カラムグリッド

gpdf は PDF レイアウトに Bootstrap 由来の 12 カラムグリッドを採用した。breakpoint や gutter は捨て、予測可能性を優先した設計判断を解説する。

TL;DR

gpdf は Bootstrap と同じ 12 カラムグリッドを採用している。12 を選んだのは、1・2・3・4・6 で割り切れる — 実務で本当に欲しい分割が全部入る最小の数だからだ。整数 span のモデルだけ残して、ほかは全部捨てた: breakpoint なし、gutter なし、order なし、auto-fill なし。 ページは行のスタック、行は 1 本の水平 Box、列はその中で「12 のうち何個分」かで幅が決まる。

それだけ。実装は Go 約 30 行。面白いのは「何を移植しなかったか」のほうだ。

なぜこの記事を書いたか

gpdf は Go の PDF 生成ライブラリ。高レベルレイアウト API は page.AutoRow → r.Col(span, fn) → c.Text/Image/Table の Builder。r.Col(4, ...) を初めて見た人がする質問はだいたい 3 つに収束する:

  1. なぜ 12? 16 や 24 じゃダメなのか? 「好きなだけ」じゃダメなのか?
  2. これは CSS Grid? Bootstrap? それとも別物?
  3. span の合計が 12 にならなかったらどうなるの?

この記事はその設計判断を順に解きほぐしていく。根っこにある原則は 1 つだけ: PDF のレイアウトは「適応的」ではなく「予測可能」であるべき。Web ページはリサイズで再フローするが、PDF は再フローしない。たったこれだけの違いで、Web のグリッドシステムを難しくしている要因のほとんどが消える。残ったものを Go で書くと、約 30 行になった。

「使い方だけ知りたい」人向けには /blog/12-column-grid のレシピが直球の答えになる。この記事はその裏側、なぜそういう形をしているかの話。

PDF レイアウトの選択肢は 3 つある

高レベル API を設計し始めた時、現実的な選択肢は 3 つだった:

  1. 絶対座標。「(72, 540) pt にテキストを描画」。Go の低レベル PDF ライブラリの大半がこれ。最大の自由度、最悪の人間工学。座標は全部自分で計算する。
  2. フロー + flexbox。コンテンツを上から下にスタック、行は子要素を grow/shrink で水平に分配。強力だが、レイアウトパスは非自明 — 制約ソルバーが要るし、丸め誤差が積み重なる。
  3. 固定グリッド + 比率。ページは行のスタック。行は N 個の等分スロットに割られる。各列は整数個のスロットを取る。幅 = スロット数 / N × 行幅。制約ソルバー不要。grow/shrink なし。

選んだのは 3 番。Bootstrap が 10 年以上前に同じ理由でここに着地している: 実務で必要なレイアウトの大半は、整数比のレイアウトだ。等幅 2 列。1/3 + 2/3 の分割。4 枚カードの行。25-50-25 の行。どれも制約ソルバーは要らない。

残った問題は「N は何にするか」だった。

なぜ 12 なのか

12 は魔法ではないが、適当でもない。文書で実際に欲しくなる整数分割を考えてみると:

  • 2 列 — 左右半分
  • 3 列 — 1/3 ずつ (3 カードのギャラリー)
  • 4 列 — 1/4 ずつ (KPI ストリップ)
  • 6 列 — 1/6 ずつ (狭いサイドパネル、たまに使う)
  • 12 列 — 1/12 ずつ (ほぼ使わない、薄いセパレータ用)

12 の約数を見ると 1, 2, 3, 4, 6, 12。1/6 までの「実際に使う」整数分割が 全部 入っている。10 だと 1/3 が出せない。16 でも 1/3 は出せない。24 はすべて出せるが、認知負荷が倍になる — r.Col(8, ...) を見て「これは 1/3 (24/3 = 8) なのか、2/3 (8/12 = 2/3) なのか」毎回脳内変換することになる。12 は「人がよく使う分割」を全部カバーする最小の数だ。

Bootstrap が 2011 年に 12 に着地したのも同じ理由。CSS Grid はもう一段抽象を上げて 1fr 2fr 1fr のように比率を直接書かせるが、これは無料じゃない — 読み手に「全兄弟を見ないと意味が決まらない」コストを押し付ける。r.Col(4, ...) は具体的に「行の 1/3」と即座にわかる。r.Col(2fr, ...) は周りを見ないと何分の何かが決まらない。

PDF のように「レイアウトが固定で、目視でデバッグする」前提なら、整数モデルのほうが向く。

Bootstrap から残したもの

3 つだけ:

  1. 12。分母。ダイヤルに刻まれた唯一の数。
  2. 整数 1〜12 の span。分数でも CSS 単位でもない。r.Col(4, ...) は「12 のうちの 4」。
  3. メンタルモデル。ページは行のスタック、行は列に分割される。HTML を書いてきた人なら 10 年見続けた形。

ここまでは Bootstrap と同じ。問題はここから先だ。

捨てたもの

breakpoint

Bootstrap の col-md-6 col-lg-4 は「タブレットで半分、PC で 1/3」みたいな指定。Web では便利。PDF では意味がない。PDF のページは固定キャンバスだ。viewport も resize イベントも media query も存在しない。breakpoint は丸ごと削った。

省けた量は見た目以上に大きい。CSS フレームワークが col-xs-* col-sm-* col-md-* col-lg-* col-xl-* の 5 種類のクラスを抱えているのは breakpoint のせいだ。gpdf はそれが全部ない。API は r.Col(span int, fn func(*ColBuilder))。シグネチャは 1 つ、覚えるべき軸は 1 つ。

gutter

Bootstrap の行は列の間にデフォルトで水平 padding が入る。PDF にデフォルト gutter は不要だ — 列間のマージンは描画する内容で完全に決まる。詰まったテーブルなら 0、ヒーローセクションなら 24pt、請求書の行なら 0.5pt の区切り、と全部違う。だからスペーシングは明示的にした。

gutter が欲しければ自分で入れる: c.Spacer(...) を列の間に挟むか、内側を Box でくるんで padding を付ける。グリッド側は頼んでもいない pixel を絶対に挿入しない。1 ポイントが効く印刷媒体では「gutter なし」がデフォルトの正解

order

CSS には order: 2 で列の見た目順を入れ替える機能がある。レスポンシブで「同じ DOM を狭い画面では別の順に見せる」ための機能。PDF では使い道がない。ファイル中の列の出現順がそのままページ上の出現順だ。実装すら検討しなかった。

auto-fill / auto-fit

CSS Grid には repeat(auto-fit, minmax(200px, 1fr)) がある。「200px 以上の列を入るだけ並べる」。Web のギャラリーには美しい機能。PDF はビルド時にページ幅が分かっている。レイアウトエンジンに考えさせる必要がない。

4 カードの行が欲しければ r.Col(3, ...) を 4 回。6 カードなら r.Col(2, ...) を 6 回。「auto」の代わりは自分のコードの for ループ 1 つ:

for _, item := range items {
    r.Col(3, func(c *template.ColBuilder) {
        c.Text(item.Name)
    })
}

3 行。フレームワークに焼き付ける必要はなかった。

span 合計の強制

意外に思われるところ: gpdf は列 span の合計が 12 になることを要求しない。これは意図的だ。

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(4, func(c *template.ColBuilder) { c.Text("左 1/3") })
    r.Col(4, func(c *template.ColBuilder) { c.Text("中 1/3") })
    // 合計 = 8。右 1/3 は単に空のまま。
})

ライブラリは各列を span/12 × 行幅 として扱う、それだけ。1 行に 4 + 4 を置けば右の 1 スロット分は空く。7 + 8 を置けば 2 番目の列が行を超えてはみ出す — これも意図的で、ページ幅より広いレイアウトグリッドに合わせたい時にこの挙動が効く。span は 1〜12 に clamp される (Col(0, ...)Col(1, ...) に、Col(99, ...)Col(12, ...) に。gpdf/template/grid.go:120 参照) が、自動 wrap も自動バランスもしない。

Bootstrap の旧版にあった「合計が 12 を超えたら次の行に折り返す」は Web のレスポンシブ問題に対する解だった。PDF にはそんな問題はない。代わりに「書いた通りに出る」というシンプルな契約に置き換えた。

container, fluid, no-gutters, offset, push/pull

全部入れていない。container-fluidcol-md-offset-3col-md-push-2 も、Bootstrap のユーティリティクラス相当はゼロ。列を右に押したければラップする: 空の r.Col(3, ...) を前に置けば良い。8 文字増えるだけで、新しい概念は要らない。

gpdf vs Bootstrap vs CSS Grid

機能Bootstrap (CSS)CSS Grid (CSS)gpdf (Go)
グリッドサイズ12 列任意 (grid-template-columns)12 列
単位クラス名比率 (fr)、px、%整数 span 1〜12
breakpoint5 種 (xs/sm/md/lg/xl)media query 経由なし
デフォルト gutterあり (gx-* で制御)なしなし
視覚的な並び替えorder-*order プロパティなし
auto-fillなしありなし
合計 > 12 の wrapあり (旧) / なし (flex)該当なしなし (オーバーフロー許容)
実装サイズ約 3,000 行 SCSSブラウザ内約 30 行 Go

「30 行」は実数だ。gpdf/template/grid.go を開いて数えればわかる: 定数 1 つ (gridColumns = 12)、整数を clamp する Builder メソッド 1 つ、行ごとに 1 つの Box を出す build パスで、子は Pct(span/12*100) 幅。測定パスもなく、flex アルゴリズムもなく、再バランスもない。幅の算術がそのままアルゴリズム

内部の動き

r.Col(4, fn) を呼ぶと、行に colEntry{span: 4, fn: fn} が append される。ドキュメントを build する時、各 entry は Width: document.Pct(33.333…)document.Box になり、列のコンテンツがその中にネストされる。行自体は Direction: DirectionHorizontal の Box。PDF Writer (Layer 1) はドキュメント順で Box を歩き、コンテンツストリームを吐く。レイアウトエンジン (Layer 2) は幅と高さの解決をする。グリッド (Layer 3) は整数 → パーセントの変換をする。それだけ。

これが 30 行で済む理由は、percent と整数がレイアウト境界で丸め誤差なく合成できることにある。列の中の列の中の列、と何段ネストしても、最終的に Pct の連鎖を float64 で掛け算するだけ。深いネストでも誤差は 1 タイポグラフィックポイント以下に収まる。

パイプライン全体を見たければ なぜ gpdf は他より 10 倍速いのか でレンダリングパイプラインを解説している。グリッドはその中で最も軽い層 — M1 で 1 ページあたり約 13 µs のうち、グリッドが消費するのは数百ナノ秒のオーダーだ。

完全に動くサンプル

4/8 分割のヘッダ、12 全幅のテーブル行、3/3/3/3 の KPI ストリップ:

package main

import (
    "os"

    "github.com/gpdf-dev/gpdf/document"
    "github.com/gpdf-dev/gpdf/template"
)

func main() {
    doc := template.NewDocument(document.PageSize(document.A4))

    doc.Page(func(p *template.PageBuilder) {
        // 4/8 分割: 左にロゴ、右に住所。
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(4, func(c *template.ColBuilder) {
                c.Text("ACME, Inc.", template.FontSize(18), template.Bold())
            })
            r.Col(8, func(c *template.ColBuilder) {
                c.Text("東京都千代田区一丁目 1-1", template.AlignRight())
                c.Text("〒100-0001", template.AlignRight())
            })
        })

        p.Spacer(document.Mm(10))

        // 全幅 (12 span 1 列) のテーブル。
        p.AutoRow(func(r *template.RowBuilder) {
            r.Col(12, func(c *template.ColBuilder) {
                c.Table([]string{"品目", "数量", "金額"}, [][]string{
                    {"商品 A", "2", "¥1,000"},
                    {"商品 B", "1", "¥2,500"},
                })
            })
        })

        p.Spacer(document.Mm(10))

        // KPI ストリップ: 3 span × 4 列。
        kpis := []struct{ label, value string }{
            {"小計", "¥4,500"},
            {"消費税 (10%)", "¥450"},
            {"送料", "¥0"},
            {"合計", "¥4,950"},
        }
        p.AutoRow(func(r *template.RowBuilder) {
            for _, k := range kpis {
                k := k
                r.Col(3, func(c *template.ColBuilder) {
                    c.Text(k.label, template.FontSize(8))
                    c.Text(k.value, template.FontSize(14), template.Bold())
                })
            }
        })
    })

    f, _ := os.Create("invoice.pdf")
    defer f.Close()
    doc.Render(f)
}

これは実際に動くプログラム。go get github.com/gpdf-dev/gpdf して実行すれば、カレントに invoice.pdf が出る。M1 でレンダ時間は約 130 µs。

整数モデルが向かないケース

整数 12 分の n のモデルが本当に間違っているケースは 2 つある。正直に挙げておく:

  1. 完全にピクセル合わせの幅が必要なとき。「この列は厳密に 73.5pt」みたいな要求。Pct ではほぼ無理だ (73.5/総幅 × 12 が整数になることは滅多にない)。固定座標が必要な要素だけ page.Absolute(...) を使い、それ以外をグリッドに任せる。同じページ上で混在できる。
  2. 新聞のような段組みフローが必要なとき。1 段目が埋まったら 2 段目に続く、みたいなテキスト流し込み。グリッドではできない。段組みフローのテキストエンジンは現状未搭載。必要な人は issue を投げてほしい — 不足は把握している。

それ以外、つまり請求書・帳票・契約書・パンフレット・スライドなどでは、12 グリッドのほうが CSS よりむしろタイトに当てはまる。

よくある質問

Q: 12 を 24 とかに変更できますか? できない。gridColumns は定数。変えると既存テンプレが全部壊れる。1 度 12 に決めたらコミットしている。

Q: 列の中に行をネストしたい場合は? できる。c.AutoRow(...) で列の中にサブ行を作れる。サブ行の span 1〜12 は 親列の幅 に対する 1〜12 で、ページ幅ではない。各レベルが「親に対する Pct(span/12 × 100)」なので、ネストは綺麗に合成される。

Q: 横置きページでも動きますか? 動く。グリッドはページサイズ非依存。r.Col(6, ...) は行の半分で、行が A4 縦の 210mm でも A4 横の 297mm でも同じこと。

Q: 2 列行用に r.Col2(span, span, fn1, fn2) のショートカットがないのはなぜ? 1 行減らすために API 表面を増やすのは割に合わないからだ。同じパターンを繰り返すなら、*template.PageBuilder を受け取って行を追加する Go 関数を自分で書けばいい。グリッドが最小限であるおかげで、ユーザー側のパターンが衝突せずに育てられる。

Q: grid-area や named lines のような CSS Grid の機能は? gpdf にはなく、ロードマップにもない。PDF ではコスト対効果が合わない。

まとめ

12 カラムグリッドは「実務の文書に必要な分割」を最小コストで提供するレイアウトプリミティブだ。数を Bootstrap から借り、整数モデルだけ残して、breakpoint・gutter・order・auto-fill・span 合計強制、その他レスポンシブ Web の荷物を全部捨てた。残ったのは定数 1 つ、Builder メソッド 1 つ、幅の式 1 つ — Go 約 30 行。ネストでクリーンに合成し、グリッドで表現できないケースは Absolute と無理なく同居し、書いたものを勝手に再バランスしたりはしない。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、CJK 対応。

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · ドキュメントを読む

次に読む