記事一覧

signintech/gopdf から gpdf へ — 座標計算が消える

signintech/gopdf は速いが、セル・線・ヘッダ全てが (x, y) の計算式。gpdf 移行で座標計算がほぼ消える流れを API 対応表とコードで示す。

TL;DR

gpdf は 12 カラムレイアウトエンジンを持つ純 Go の PDF ライブラリ。signintech/gopdf は PDF 座標系に対する低レベルバインディング。gopdf を長く運用してきたコードベースが今や SetXYCell、幅の四則演算で埋まっているなら、それらの呼び出しがレイアウトエンジン下で何に畳み込まれるかをこの記事で追う。

先週、ある人と signintech/gopdf 製の請求書ジェネレーターをリファクタリングしていた。5 年ぶんの積層。明細テーブルを描画する関数が 280 行。実際に仕事をしているのはそのうち 40 行ほど — 金額のフォーマット、日付のフォーマット、行ごとに繰り返し。残りの 240 行は x 座標の計算、y 座標の追跡、SetXYCellBrLine(x1, y1, x2, y2) での罫線描画、行がページに収まるかどうかの判定、収まらないときの手動 AddPage とヘッダ再描画だった。

これが本番の gopdf。悪いライブラリではない。CGO なしの薄くて速い PDF イメージングモデルへのバインディング — 名前通りそれだけのもの。カーソルがあり、座標があり、レイアウトエンジンの役回りをエンジニアが演じる。

この記事は gopdf の API を gpdf にマッピングしていく。タイトルに結論は書いてある: ほとんどの行は消える、なぜならランタイムが代わりにやってくれるレイアウト計算だったから。

signintech/gopdf の良いところと、そうでないところ

「移行しよう」のフレーミングに入る前に正直に言っておくと、gopdf には実際の美点がある。

メンテナンスは活発。純 Go (CGO なし) なのでクロスコンパイルも Alpine イメージも素直に動く。CJK を含む TrueType フォントをサポートする。出力速度は速い — gopdf は重いエンジンを挟まずに PDF wire format を直接書くため、イメージングプリミティブだけ見れば gpdf と同じレンジに入る。API は内部 PDF モデルにそのまま対応している: 現在点があり、それを動かし、そこに描く。すでに PDF 座標で考える人にとって gopdf は素直。

そうでないのは、レイアウトシステムを持っていないところ。行・列・flex・グリッドという概念がない。自動改ページがない: コンテンツが下マージンを越えても、AddPage を自分で呼ぶまで描画は続く (ページ外に出るかもしれない)。テーブルはプリミティブとしては存在しない — プロジェクトごとに Cell 呼び出しと手書きの罫線、自前の改ページロジックで再実装するパターン。

1 ページの修了証や非常に決まったフォームならカーソルモデルで十分。請求書、レポート、明細書、可変長コンテンツを含むものは、ドキュメント表面積に比例して座標計算が増える。それが gpdf の対象ワークロード。

メンタルモデルの転換

ここがコードの読み味を実際に変える部分。gpdf には gopdf にない 2 つの考え方がある。

宣言的なツリー。 描画位置をレンダラーに伝えるのではない。ページ → 行 → 列 → コンテンツのツリーを記述すると、レイアウトエンジンが 1 パスで位置を解決する。進めるカーソルがない。連続する 2 つの r.Col(...) は互いを知らない。

12 カラムグリッド。 各行は暗黙的に 12 ユニット幅。それを列で消費する: r.Col(8, ...) は 2/3、r.Col(4, ...) は 1/3。Bootstrap や Tailwind が HTML で使う考え方を PDF に持ち込んだもの。pageWidth - leftMargin - rightMargin を 4 で割る代わりに、r.Col(3, ...) を 4 回書く。

この 2 つだけで計算の大半が消える。後ろの Before/After ペアは全部同じ形に畳み込まれる: カーソルを進める命令ループが、小さな宣言的ツリーになる。

API 対応表

まずカンニングペーパー。具体的な対比は後ろのセクションで 5 ペア追う。

やりたいことsignintech/gopdfgpdf
構築pdf := gopdf.GoPdf{}; pdf.Start(gopdf.Config{...})doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4), ...)
ページサイズConfig{PageSize: gopdf.PageSizeA4}gpdf.WithPageSize(document.A4)
ページ追加pdf.AddPage()page := doc.AddPage()
カーソル移動pdf.SetX(40); pdf.SetY(80) (随所で)(カーソルなし)
1 行テキストpdf.SetXY(x, y); pdf.Cell(nil, "hi")c.Text("hi") (列の中)
折り返しテキストpdf.MultiCell(&gopdf.Rect{W: 200, H: 100}, body)c.Text(body) (自動折り返し)
改行pdf.Br(20)(行の間で暗黙、必要なら c.Spacer(document.Mm(4)))
フォント登録pdf.AddTTFFont("noto", "fonts/Noto.ttf")gpdf.WithFont("Noto", ttfBytes) (構築時)
アクティブフォントpdf.SetFont("noto", "", 14)テキスト単位で template.FontFamily("Noto"), template.FontSize(14)
pdf.SetTextColor(26, 35, 126)template.TextColor(pdf.RGBHex(0x1A237E))
横罫線pdf.Line(40, 100, 555, 100)c.Line(template.LineColor(pdf.Gray(0.7)))
矩形pdf.RectFromUpperLeftWithStyle(x, y, w, h, "FD")c.Box(template.BgColor(...), template.Border(...))
画像pdf.Image("logo.png", x, y, &gopdf.Rect{W: 100, H: 50})c.Image(imgBytes, template.FitWidth(document.Mm(35)))
手書きテーブル多数の Cell + Line + SetXYc.Table(headers, rows, template.ColumnWidths(...))
ヘッダ / フッタpdf.AddHeader(fn) / pdf.AddFooter(fn)doc.Header(fn) / doc.Footer(fn)
ページ番号自前カウンタから "Page %d of %d" を組むc.PageNumber() / c.TotalPages() (プレースホルダ)
暗号化Config{Protection: PDFProtectionConfig{...}}gpdf.WithEncryption(gpdf.AES256, "user", "owner", perms)
出力pdf.WritePdf("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
writer に出力pdf.Write(w) / pdf.ToBuffer()doc.Render(w)

構造的な変化は 2 つ。第一に、カーソルが消える。表で「随所で」とマークした行は誇張ではない — 実プロダクションの gopdf コードでは SetXY の出現数が Cell を上回る。これらは gpdf 側ではすべて消滅する。第二に、ピクセルがパーセンテージに変わるRect{W: 200, H: 100} は「この列はコンテナの 12 ユニット中 4 を取る」になる。同じ列を半幅の行に入れても比率が保たれて再計算は不要。

Before / After 1: hello world

最小限の差分。右側で何が消えているかに注目。

Before — signintech/gopdf:

package main

import (
    "log"

    "github.com/signintech/gopdf"
)

func main() {
    pdf := gopdf.GoPdf{}
    pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
    pdf.AddPage()

    if err := pdf.AddTTFFont("helvetica", "fonts/Helvetica.ttf"); err != nil {
        log.Fatal(err)
    }
    if err := pdf.SetFont("helvetica", "", 24); err != nil {
        log.Fatal(err)
    }

    pdf.SetX(40)
    pdf.SetY(80)
    if err := pdf.Cell(nil, "Hello, World!"); err != nil {
        log.Fatal(err)
    }

    if err := pdf.WritePdf("hello.pdf"); err != nil {
        log.Fatal(err)
    }
}

After — gpdf:

package main

import (
    "log"
    "os"

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

func main() {
    doc := gpdf.NewDocument(
        gpdf.WithPageSize(document.A4),
        gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("Hello, World!", template.FontSize(24), template.Bold())
        })
    })

    data, err := doc.Generate()
    if err != nil {
        log.Fatal(err)
    }
    if err := os.WriteFile("hello.pdf", data, 0o644); err != nil {
        log.Fatal(err)
    }
}

消えたものが 2 つ。Helvetica は標準 14 フォントの一員で gpdf にバンドル済みなので、ランタイムに TTF ファイルは不要。SetX(40); SetY(80) も消える — 行はページマージン内に自動で収まる。代わりに加わったのは 12 ユニット幅の単一列を持つ行。"Hello, World!" にはこの足場は重く見えるが、これがそのまま 100 ページレポートを支える足場でもある、というのが本題。

Before / After 2: 4 列のヘッダ行

ここが座標計算の見え方が一番強く出る場所。ページ幅をぶち抜く 4 つの等幅セルがほしい場合: ページ幅からマージンを引き、4 で割る。gopdf ではその割り算を書く。gpdf では 12 ユニットを 4 つに分ける。

Before — signintech/gopdf:

const (
    pageWidth   = 595.28 // A4 (pt)
    leftMargin  = 40.0
    rightMargin = 40.0
    rowY        = 100.0
    rowH        = 24.0
)

contentWidth := pageWidth - leftMargin - rightMargin // 515.28
colW := contentWidth / 4                              // 128.82

pdf.SetFont("helvetica-bold", "", 11)
pdf.SetFillColor(26, 35, 126)
pdf.SetTextColor(255, 255, 255)

headers := []string{"明細", "数量", "単価", "金額"}
for i, h := range headers {
    x := leftMargin + colW*float64(i)
    pdf.RectFromUpperLeftWithStyle(x, rowY, colW, rowH, "F")

    pdf.SetXY(x+6, rowY+7)
    if err := pdf.Cell(nil, h); err != nil {
        log.Fatal(err)
    }
}

pdf.SetTextColor(0, 0, 0)

定数が 4 つ、幅の引き算 1 回、割り算 1 回、colW*float64(i) のループ — その float キャストは Go の * が int を float64 に昇格しないからだけのコード。gpdf 版にはどれも存在しない。

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    headers := []string{"明細", "数量", "単価", "金額"}
    for _, h := range headers {
        r.Col(3, func(c *template.ColBuilder) {
            c.Box(
                template.BgColor(pdf.RGBHex(0x1A237E)),
                template.Padding(document.Mm(2), document.Mm(3)),
            )
            c.Text(h,
                template.Bold(), template.FontSize(11),
                template.TextColor(pdf.White),
            )
        })
    }
})

r.Col(3, ...) を 4 回で合計 12。幅はグリッドが面倒を見る。A4 を Letter に変えても、マージンを縮めても、このコードは pageWidth に一切依存していないので並びは正しいまま。1 列目だけ他より 2 倍広くしたければ、その列を r.Col(6, ...)、他のうち 1 つを r.Col(2, ...) にすればいい。算術なし。

Before / After 3: 改ページする請求書テーブル

本丸。gopdf で複数ページにまたがるテーブルを描くのはほぼ全部簿記: 現在の y を追い、各行を描き、次の行が収まるかチェックし、収まらなければ AddPage を呼んでヘッダを再描画。状態機械があなたのコードに住む。

Before — signintech/gopdf:

func drawInvoiceTable(pdf *gopdf.GoPdf, items [][4]string) error {
    const (
        pageH       = 841.89 // A4 height
        bottomLimit = pageH - 40
        rowH        = 22.0
        leftX       = 40.0
    )
    cols := []float64{260, 80, 80, 95} // 明細・数量・単価・金額

    // 1 ページ目と改ページ後に呼ぶヘッダ描画関数。
    drawHeader := func(y float64) float64 {
        pdf.SetFont("helvetica-bold", "", 11)
        pdf.SetFillColor(26, 35, 126)
        pdf.SetTextColor(255, 255, 255)
        x := leftX
        for i, h := range []string{"明細", "数量", "単価", "金額"} {
            pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "F")
            pdf.SetXY(x+6, y+7)
            if err := pdf.Cell(nil, h); err != nil {
                log.Println(err)
            }
            x += cols[i]
        }
        pdf.SetTextColor(0, 0, 0)
        pdf.SetFont("helvetica", "", 11)
        return y + rowH
    }

    y := drawHeader(100)
    for _, row := range items {
        if y+rowH > bottomLimit {
            pdf.AddPage()
            y = drawHeader(60)
        }

        x := leftX
        for i, cell := range row {
            pdf.RectFromUpperLeftWithStyle(x, y, cols[i], rowH, "D") // 罫線のみ
            pdf.SetXY(x+6, y+7)
            if err := pdf.Cell(nil, cell); err != nil {
                return err
            }
            x += cols[i]
        }
        y += rowH
    }
    return nil
}

このテーブル描画関数は 30 行、そのうちデータに関するのは 5 行。残りはレイアウト: ハードコードされた高さ、ハードコードされた下限、改ページ後のヘッダ再描画クロージャ、for ループ 2 つ、セルあたりカーソル 2 回更新。これが gopdf テーブルの中央値。

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"明細", "数量", "単価", "金額"},
            items, // [][]string
            template.ColumnWidths(55, 15, 15, 15),
            template.TableHeaderStyle(
                template.Bold(),
                template.TextColor(pdf.White),
                template.BgColor(pdf.RGBHex(0x1A237E)),
            ),
            template.TableStripe(pdf.RGBHex(0xF5F5F5)),
        )
    })
})

これだけ。改ページ自動。本体が継続するページではヘッダが自動で繰り返される。ストライプ行はオプション 1 つ。列幅はコンテナのパーセンテージなので、同じテーブルを r.Col(6, ...) の中に入れれば比率を保ったまま半分のサイズになり書き直しは不要。25 行の gopdf 簿記関数は消える。

具体的な数字を 1 つ。100 行の請求書レンダリングは gpdf で 108 µs、signintech/gopdf でだいたい 2.4 ms — gopdf 側はあなたが書いたセル単位パターンに依存するので変動する。倍率が見出しなのではなく、関数そのものが消えたことが見出し。

Before / After 4: 段落の隣に画像

よくある形: 左にロゴ、右に住所ブロック。

Before — signintech/gopdf:

const (
    leftX  = 40.0
    rightX = 380.0
    blockY = 50.0
)

if err := pdf.Image("logo.png", leftX, blockY, &gopdf.Rect{W: 100, H: 60}); err != nil {
    log.Fatal(err)
}

pdf.SetFont("helvetica-bold", "", 14)
pdf.SetXY(rightX, blockY)
if err := pdf.Cell(nil, "ACME 株式会社"); err != nil {
    log.Fatal(err)
}

pdf.SetFont("helvetica", "", 10)
pdf.SetXY(rightX, blockY+20)
pdf.Cell(nil, "東京都渋谷区神宮前 1-2-3")
pdf.SetXY(rightX, blockY+34)
pdf.Cell(nil, "150-0001")
pdf.SetXY(rightX, blockY+48)
pdf.Cell(nil, "[email protected]")

明示的な y 座標が 6 個、右ブロックは rightX = 380 から始まる — 誰かがロゴを 100 幅と決め、右ブロックは 240 ピクセル空けると決めた結果の数値。ロゴを右に動かすと数値全部が変わる。

After — gpdf:

//go:embed logo.png
var logoData []byte

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(4, func(c *template.ColBuilder) {
        c.Image(logoData, template.FitWidth(document.Mm(35)))
    })
    r.Col(8, func(c *template.ColBuilder) {
        c.Text("ACME 株式会社", template.Bold(), template.FontSize(14))
        c.Text("東京都渋谷区神宮前 1-2-3")
        c.Text("150-0001")
        c.Text("[email protected]")
    })
})

2 列、4 + 8 = 12。画像は固定幅にフィットさせ、高さはアスペクト比から gpdf が計算する。各 c.Text は前の行の下に流れる — Br も y 計算もない。ロゴを右に移したければ列の順番を入れ替えるだけ。

Before / After 5: フッタのページ番号

gopdf ではカウントを自前で持つ。レンダリングはシングルパスで、最初のフッタを描く時点では総ページ数が分からないため。多くのコードベースは 2 パス回避策をやる: 一度レンダリングしてページ数を数え、再度レンダリングして総数を埋める。

Before — signintech/gopdf:

totalPages := 0
pdf.AddFooter(func() {
    totalPages++
})

// 1 パス目: 全体を描いてページ数を数える。
buildContent(&pdf)
finalTotal := totalPages

// 2 パス目: 既知の総数で再レンダリング。
pdf2 := gopdf.GoPdf{}
pdf2.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
pageNum := 0
pdf2.AddFooter(func() {
    pageNum++
    pdf2.SetFont("helvetica", "", 8)
    pdf2.SetXY(40, 800)
    pdf2.Cell(nil, fmt.Sprintf("%d / %d ページ", pageNum, finalTotal))
})
buildContent(&pdf2)
pdf2.WritePdf("report.pdf")

gopdf を運用したことがあれば、これを書いたことがあるはず。FAQ には載らないが、出力をパースせずに正直な「X / Y ページ」フッタを得る唯一の方法。

After — gpdf:

doc.Footer(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(6, func(c *template.ColBuilder) {
            c.Text("ACME 株式会社",
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
        r.Col(6, func(c *template.ColBuilder) {
            c.Stack(template.AlignRight(), func(c *template.ColBuilder) {
                c.PageNumber(template.Inline())
                c.Text(" / ", template.Inline())
                c.TotalPages(template.Inline())
                c.Text(" ページ", template.Inline())
            }, template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
    })
})

PageNumberTotalPages はプレースホルダ。レイアウトエンジンが先にページネーションし、総数を解決してから書き込む。1 パス、自前カウントなし、二重描画なし。「5 / 12」のような句中表記もそのまま動く。

日本語: 手動サブセットなしで

signintech/gopdf でも CJK は使えるが、文字集合の簿記を自分でやる構成。TTF を追加し、文字マップを設定し、登録したサブセット外の文字が来ると豆腐になる。gpdf の TrueType サブセッタは cmap (フォーマット 4・6・12) を歩いて、実際に使ったグリフだけを埋め込む — 手動サブセットリストは存在しない。

//go:embed NotoSansJP-Regular.ttf
var notoJP []byte

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    gpdf.WithFont("NotoSansJP", notoJP),
    gpdf.WithDefaultFont("NotoSansJP", 14),
)

page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Text("こんにちは、世界。")
        c.Text("吾輩は猫である。名前はまだ無い。")
        c.Text("東京都渋谷区神宮前1-2-3")
    })
})

200 字程度の日本語請求書だと、フォントサブセットは ~30 KB。フル埋め込みの 4 MB ではなく。日本語フォント周りの詳細は日本語フォント埋め込み、IPAex Gothic や Source Han Sans のフォールバックはIPAex Gothic を参照。

ベンチマーク

同一ハードウェア・同一ワークロード、Apple M1 と Go 1.25。

ベンチgpdfsignintech/gopdfgofpdfMaroto v2
単一ページ13 µs423 µs132 µs237 µs
4×10 請求書テーブル108 µs835 µs241 µs8.6 ms
100 ページレポート683 µs8.6 ms11.7 ms19.8 ms
複雑な CJK 請求書133 µs997 µs254 µs10.4 ms

数値は gpdf/_benchmark/benchmark_test.go から。signintech/gopdf は 1 呼び出しあたりの仕事が少ないぶん単純パスでは gofpdf より速いが、テーブルになると差が広がる: gpdf のレイアウト処理はホットパスでアロケーションのない C 風の命令的内部コードで動き、手書きの gopdf テーブルはセルごとに文字列とフォントメトリクスをアロケートする。

シングルコアで 1 ページあたり 108 µs のテーブル付きスループット = 1 秒あたり約 9,000 請求書。たいていのワークロードでは PDF 生成をリクエストパス上に置いておける。gopdf が 10 倍遅かったから cron / queue / 事前生成を挟んでいた回避策はだいたい外せる。

gopdf にあって gpdf にないもの

正直セクション。以下に依存している場合、移行はこの記事だけでは完結しない。

  • ImportPage 既存 PDF から 1 ページを取り込み、その上にコンテンツをスタンプする「PDF テンプレート」ワークフロー。gpdf の overlay (gpdf.Overlay) で典型ケースは扱えるが、UseImportedTemplate と同じプリミティブは公開していない。ベンダ提供 PDF への描画が前提なら overlay API を先に評価する。
  • 多角形・楕円のプリミティブ。 gopdf には OvalPolygon がある。gpdf のプリミティブは矩形・線・画像・テキスト・テーブルで、任意パス描画は一級市民ではない。データ可視化はチャートライブラリで PNG/SVG にして埋め込む構成にする。
  • 直接的なカーソル位置。 ピクセル単位での配置 (例えば (420, 240) ちょうどにスタンプ) が本当にほしい場合 page.Absolute(x, y, fn) がある。ただしこれは脱出口で、コードの大半はここに手を伸ばすべきではない。
  • PlaceHolderText / FillInPlaceHoldText レイアウト後にテキストを埋める 2 パスのプレースホルダパターン。gpdf の PageNumber / TotalPages プレースホルダは典型ケース (ページ番号) を扱うが、汎用「あとで埋めるスロット」機構はまだない。

請求書、明細書、レポート、修了証、契約書、領収書、配送ラベル、納品書、CJK ドキュメント — つまり gopdf の請求の大半が消費している用途では、移行は完全に行ける。

FAQ

gpdf は signintech/gopdf のフォーク? 違う。gpdf は純 Go によるクリーン実装。PDF wire writer、レイアウトエンジン、TrueType サブセッタ、AES 暗号化、PKCS#7 署名、すべて一から書かれている。共有コードも血統もない。

両方とも純 Go・CGO なし。乗り換える実利は? レイアウトエンジン。前述の移行セクションは 8 割が座標計算の削除で、それが日々のコードの読み味を実際に変える差分。ベンチマークは副次。MIT ライセンスは gopdf の MIT と同じなのでライセンス面の理由はない。

段階的に移行できる? できる。両者は衝突しない。それぞれ独立した []byte 出力を生む。あるセクションを gpdf、別のを gopdf でレンダリングし、gpdf.Merge(a, b) で 1 つの PDF に綴じる。実際にはドキュメント単位で一気に置き換えるほうが楽だと感じるチームが多い — 同じファイルに 2 つのメンタルモデルが共存するのは混乱の元。

既存コードは pdf.Image(path, ...) でディスクからロゴを読んでいる。embed する必要は? 必須ではない。c.Image(imageBytes, ...) はバイトを取るので、ランタイムで読みたければ os.ReadFile で問題ない。だが //go:embed のほうが既定としては良い: バイナリが書き込み可能ファイルシステムを期待しなくなり、コンテナイメージは 1 バイナリで済み、本番でアセットが欠ける事故が消える。

gopdf.PageSizeA4 などのページサイズ定数は? gpdf 側の document.A4, document.Letter, document.Legal 等が同じ集合をカバーする。カスタムサイズは document.PageSize(document.Mm(210), document.Mm(297))

請求書ジェネレータで pdf.Rotate を使った縦書きスタンプを行っている。等価機能は?page.Absolute(x, y, fn) が回転オプションを受け取るので、典型的な「ページに対角の透かし」は page.Absolute 1 呼び出しで済む。テーブル内のセル単位の回転はプリミティブとしてはなく、必要なら該当セルだけ画像にして埋め込む。

自動で書き換えるツールはある? まだない。単純部分 (SetXY/Cellr.Col/c.Text) のマッピングは機械的だが、テーブルの書き換えは構造的 — 翻訳ではなく簿記コードの削除。典型的なジェネレータの手動移行はドキュメントタイプあたり数時間が目安。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT、ゼロ依存、CJK ネイティブ対応、12 カラムグリッドレイアウト。

go get github.com/gpdf-dev/gpdf

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

次に読む