記事一覧

unipdf の AGPL/商用ライセンスから gpdf へ移行する

unidoc/unipdf は AGPL v3 もしくは開発者ごとの商用ライセンス。MIT・ゼロ依存・ライセンスキー不要の gpdf への移行ガイド。

TL;DR

gpdfMIT ライセンス外部依存ゼロライセンスキー登録なし の純 Go PDF ライブラリ。unidoc/unipdf を CJK や AcroForm のために使っているが、AGPL 条項が法務に止められたり、商用ライセンス費用の根拠説明がしんどい状況なら、この記事が unipdf の creator API から gpdf への移行マップになる。

先期、知人のフィンテック企業で github.com/unidoc/unipdf/v3 を OSS 承認フローに乗せた。返ってきた回答は AGPL-3.0 の横に赤い X、そして法務からの一文 — 「クローズドソースの製品に組み込めません。商用ライセンスを取得するか、削除してください。」。商用見積りの開発者あたり年額を 12 名分で計算した結果、全員が再度検索結果を開き直すことになった。

これは README には載らない unipdf の側面。技術的にはむしろ優秀で、機能も成熟しメンテも継続的。ただし デュアルライセンス: オープン用途は AGPL v3、それ以外は 商用ライセンスが必須。AGPL v3 は実用されている copyleft の中で最強の部類で、ネットワーク越しに使われるサービスに unipdf をリンクすると、§13 によりサービス全体のソース公開義務が発生する。たいていの会社の法務部はこれを通さない。

すでに unipdf 製のコードがあって、ライセンスが監査で引っかかった、あるいは更新時期が来ている — ならこの記事は移行マップ。新規で「Go の PDF どうしよう」で unipdf に手が伸びかけているなら、これが請求書を伴わない代替案。

「AGPL か有料」が実運用で意味すること

Go ライブラリの中には「AGPL」ラベルだけ貼ってあって意味を深く考えずに使われているものもあるが、unipdf は違う。リポジトリのライセンスファイルは AGPL v3 そのままで、README は商用利用にキーが必要だと明記し、バイナリ自体がそれを強制する。起動時に license を登録せず unipdf API を呼ぶと、エラーまたは出力 PDF 全ページに透かし。

実用上、以下の 3 モードのどれかに入る:

  1. AGPL モード。自社サービスのコードを AGPL v3 で公開する。unipdf に触れる部分、そしてリンクされている全コードは、ネットワーク越しに利用するユーザーから請求された場合に開示する義務が発生する。社内ツールや SaaS では現実的でない。
  2. 商用モード。UniDoc に開発者あたり年額を支払う。価格は変動するが直近の公開見積りはおおむね開発者 1 名・年・四桁ドル、metering またはライセンスキー登録の呼び出しを全バイナリで行う必要がある。キーはシークレットとして扱われ、シークレットマネージャに置いて全コンテナに注入される。
  3. トライアル / 評価モード。期間限定で無料。出力に透かしが入る。本番では使えない。

どのモードも本質的に間違ってはいない。UniDoc は実在の会社が実在のエンジニアでメンテしている製品で、価格はそのコストを反映している。ただし、そのライセンス選択は全レイヤーに波及する — 法務レビュー、シークレットローテーション、財務の更新フロー、デプロイ面 (全コンテナにキーが必要)。gpdf は MIT なので、その列がスプレッドシートからまるごと消える。

失うものと残るもの

API の話に入る前に正直に整理しておく。unipdf にあって gpdf にない機能はある:

機能unipdfgpdf
PDF 生成
TrueType / CJK フォント✅ (CGO 不使用、自動サブセット化)
AES-128/256 暗号化✅ (ISO 32000-2 Rev 6、純 Go 実装)
PKCS#7 / PAdES 署名✅ (RFC 3161 TSA 対応)
PDF/A-1b/2b
AcroForm — 既存フィールドの記入✅ (フラット化のみ。新規フィールド作成は未対応)
AcroForm — 新規フィールド作成
PDF 解析 / テキスト抽出❌ (gpdf は生成専用)
OCR
PDF 黒塗り (redaction)
HTML レンダリング一部❌ (別レンダラで生成して merge する想定)

PDF 解析・OCR・redaction が必要な経路は、この移行ではカバーしきれない。それらの経路だけ unipdf を残す (該当バイナリの商用ライセンスは引き続き必要)、または読み取り側を別ライブラリに切るのが現実解。生成・暗号化・署名・フォント・CJK ─ これは大半の unipdf 請求の対象 ─ については gpdf で完全に置き換えられる。

ライセンス登録コードを削除する

これは移行で最小の diff だが、後の作業を実感させる重要な一歩。unipdf バイナリは起動時にキー登録が必要で、いくつかバリエーションがある:

// API キー (metering)
import "github.com/unidoc/unipdf/v3/common/license"

func init() {
    if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
        log.Fatal(err)
    }
}
// オフラインライセンスファイル
func init() {
    licenseKey, _ := os.ReadFile("/etc/unidoc/license.txt")
    if err := license.SetLicenseKey(string(licenseKey), "Acme Corp"); err != nil {
        log.Fatal(err)
    }
}

gpdf には対応物が存在しない。init() ブロックは丸ごと削除。UNIDOC_API_KEY をシークレットマネージャ・CI 変数・コンテナマニフェストから抜く。イメージからライセンスファイルを除く。import するのは github.com/gpdf-dev/gpdf だけで、要件は「どこかで gpdf.NewDocument を呼ぶ」だけ。

これだけ。完了の判定基準もシンプル: grep -r unidoc . がヒット 0 件になればよい。

API マッピング表

これがチートシート。続くセクションで 5 ペアの具体例。unipdf では高レベルビルダーを Creator と呼ぶ。gpdf では Document。形が似ていて、たいていは見た目で対応が取れる。

やりたいことunipdf (creator)gpdf
ビルダーを作るc := creator.New(); c.SetPageSize(creator.PageSizeA4)doc := gpdf.NewDocument(gpdf.WithPageSize(document.A4))
マージン設定c.SetPageMargins(L, R, T, B)gpdf.WithMargins(document.UniformEdges(document.Mm(20)))
新規ページc.NewPage()page := doc.AddPage()
1 行のテキストp := c.NewParagraph("hi"); c.Draw(p)c.Text("hi") (カラム内)
折り返しテキストp := c.NewStyledParagraph(); p.SetText(...); c.Draw(p)c.Text(body) (自動折り返し)
フォント登録model.NewCompositePdfFontFromTTFFile(path)gpdf.WithFont("Name", ttfBytes) (構築時)
テキストにフォント適用style.Font = font; style.FontSize = 12template.FontFamily("Name"), template.FontSize(12) per-text
style.Color = creator.ColorRGBFromHex("#1A237E")template.TextColor(pdf.RGBHex(0x1A237E))
テーブルt := c.NewTable(4); t.SetColumnWidths(...); c.Draw(t)c.Table(headers, rows, template.ColumnWidths(...))
画像img, _ := c.NewImageFromFile(path); img.ScaleToWidth(w); c.Draw(img)c.Image(imgBytes, template.FitWidth(document.Mm(50)))
ヘッダ / フッタc.DrawHeader(fn) / c.DrawFooter(fn)doc.Header(fn) / doc.Footer(fn)
ページ番号DrawFooter 引数からページ数を手動取得c.PageNumber() / c.TotalPages() (プレースホルダ)
暗号化model.PdfWriter + Encrypt で再エンコードgpdf.WithEncryption(gpdf.AES256, "user", "owner", perms)
署名model.NewPdfAppender(...).Sign(...)gpdf.SignDocument(pdfBytes, signer, opts)
ライセンス登録license.SetMeteredKey(...)init()(なし — 削除する)
ファイル出力c.WriteToFile("out.pdf")data, _ := doc.Generate(); os.WriteFile("out.pdf", data, 0o644)
Writer 出力c.Write(w)doc.Render(w)

構造的なシフトは 2 つ。unipdf の creator は 状態を持つParagraphTable を組み立て、c.Draw(thing) で確定する。gpdf は 宣言的 — 行とカラムのツリーを描き、配置はレイアウトエンジンに任せる。もう 1 つは gpdf が Bootstrap と同じ 12 カラムグリッド を持つ点。各行は暗黙的に 12 単位幅で、r.Col(n, fn) で消費する。ミリ単位で幅を追わなくなり、たいていのレイアウトは 2〜3 行に縮む。

Before / After 1: 最小の PDF

「Hello world」のペア。unipdf 側が長いのは license 呼び出しのセレモニーがあるから。

Before — unipdf:

package main

import (
    "log"
    "os"

    "github.com/unidoc/unipdf/v3/common/license"
    "github.com/unidoc/unipdf/v3/creator"
)

func init() {
    if err := license.SetMeteredKey(os.Getenv("UNIDOC_API_KEY")); err != nil {
        log.Fatal(err)
    }
}

func main() {
    c := creator.New()
    c.SetPageSize(creator.PageSizeA4)

    p := c.NewParagraph("Hello, World!")
    p.SetFontSize(24)
    if err := c.Draw(p); err != nil {
        log.Fatal(err)
    }

    if err := c.WriteToFile("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)
    }
}

3 つ違う。init() ブロックが消えた — キーも環境変数もない。構築は mutating ではなく options 渡し。テキストは独立した Paragraph ではなく行とカラムの中に座る。配置はグリッドが行うので座標を選ぶ必要がない。

Before / After 2: 請求書の明細テーブル

unipdf の creator API でテーブルを書くと長くなる。Table を作り、絶対比率で SetColumnWidths し、NewCell / SetContent で 1 セルずつ組み立て、罫線・整列を手で設定する。

Before — unipdf:

table := c.NewTable(4)
table.SetColumnWidths(0.5, 0.15, 0.15, 0.2)

headerStyle := c.NewTextStyle()
headerStyle.Font, _ = model.NewStandard14Font("Helvetica-Bold")
headerStyle.FontSize = 11
headerStyle.Color = creator.ColorWhite

drawHeaderCell := func(text string) {
    cell := table.NewCell()
    cell.SetBackgroundColor(creator.ColorRGBFromHex("#1A237E"))
    cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.5)

    p := c.NewStyledParagraph()
    chunk := p.Append(text)
    chunk.Style = headerStyle
    cell.SetContent(p)
}

for _, h := range []string{"品目", "数量", "単価", "金額"} {
    drawHeaderCell(h)
}

for _, row := range items {
    for _, cellText := range row {
        cell := table.NewCell()
        cell.SetBorder(creator.CellBorderSideAll, creator.CellBorderStyleSingle, 0.3)

        p := c.NewParagraph(cellText)
        p.SetFontSize(11)
        cell.SetContent(p)
    }
}

if err := c.Draw(table); err != nil {
    log.Fatal(err)
}

罫線、セルごとの content、ヘッダ描画ループ — どれも機械的な作業。

After — gpdf:

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Table(
            []string{"品目", "数量", "単価", "金額"},
            [][]string{
                {"フロントエンド開発", "40 時間", "¥15,000", "¥600,000"},
                {"バックエンド開発",   "60 時間", "¥15,000", "¥900,000"},
                {"UI デザイン",        "20 時間", "¥12,000", "¥240,000"},
            },
            template.ColumnWidths(50, 15, 15, 20),
            template.TableHeaderStyle(
                template.Bold(),
                template.TextColor(pdf.White),
                template.BgColor(pdf.RGBHex(0x1A237E)),
            ),
            template.TableStripe(pdf.RGBHex(0xF5F5F5)),
        )
    })
})

ColumnWidthsそのテーブルが入っているカラムの幅に対するパーセンテージ。ページ全体の絶対比率ではない。同じテーブルを r.Col(6, ...) に入れれば、テーブルが行の半分を占め、列幅は同じ比率で再分配される。ページ分割は自動で、本文がボトムマージンを越えれば次ページにヘッダが繰り返される — 何も配線しなくていい。

具体的な数字も挙げておくと、unipdf の Table を 100 行の請求書ベンチで回すとレンダリング 1 回あたりおおむね 8.6 ms。gpdf の同じワークロードは 108 µs (約 80 倍速い)。これはレイアウトエンジンが各行を 1 回計測してシングルパスでページを書き出すため。1 通の請求書なら差は見えないが、cron で大量にバッチ処理する場合「キューが要るかどうか」の境界が変わる。

なお適格請求書 (インボイス制度) や電子帳簿保存法対応の項目 (登録番号・税率内訳・改ざん防止) は、レイアウトとしては gpdf でそのまま表現できる。タイムスタンプは gpdf.SignDocument の RFC 3161 TSA オプション側で扱う。

Before / After 3: composite font の作法なしで日本語

unipdf も CJK は対応している。ただし手数が多い。ディスク上の TTF から composite font を構築し、style font に設定し、すべての paragraph に通す。フォールバックを入れたければ自分で配線する。

Before — unipdf:

font, err := model.NewCompositePdfFontFromTTFFile("NotoSansJP-Regular.ttf")
if err != nil {
    log.Fatal(err)
}

c := creator.New()
c.SetPageSize(creator.PageSizeA4)

style := c.NewTextStyle()
style.Font = font
style.FontSize = 14

p := c.NewStyledParagraph()
p.Append("こんにちは、世界。").Style = style
if err := c.Draw(p); err != nil {
    log.Fatal(err)
}

c.WriteToFile("ja.pdf")

TTF は実行時に指定したパスに存在している必要がある (バイナリを動かすホスト上に)。コンテナイメージにフォントを同梱しなければならない。NewCompositePdfFontFromTTFFile は使う前に呼ばれる必要があり、グローバルに置くか依存として持ち回るかの選択を迫られる。

After — gpdf:

package main

import (
    _ "embed"
    "log"
    "os"

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

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

func main() {
    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")
        })
    })

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

3 点が違う。フォントは バイト列、パスではない。//go:embed でバイナリに同梱できるので、実行時イメージにフォントディレクトリを用意しなくていい。フォント登録は 構築時に 1 回。paragraph ごとに style を引き回さない。そして gpdf の TrueType サブセッタは CJK の cmap (4, 6, 12) と Identity-H エンコーディングを理解しているため、出力 PDF には実際に使ったグリフだけが埋め込まれる。日本語 200 文字の請求書なら埋め込みフォントは ~30 KB。4 MB のフルエンベッドにはならない。

IPAex Gothic / Source Han Sans / フォールバックチェーンの詳細は日本語フォントの記事で別に扱う。

Before / After 4: 全ページ共通のヘッダとフッタのページ番号

unipdf のパターンは c.DrawHeader(fn) / c.DrawFooter(fn)。どちらも block と現ページ番号を含む引数を受ける。ページ番号は引数の PageNum / TotalPages から取り出す。

Before — unipdf:

c.DrawHeader(func(block *creator.Block, args creator.HeaderFunctionArgs) {
    p := c.NewParagraph("ACME 株式会社")
    p.SetFontSize(12)
    p.SetPos(40, 30)
    block.Draw(p)
})

c.DrawFooter(func(block *creator.Block, args creator.FooterFunctionArgs) {
    p := c.NewParagraph(fmt.Sprintf("%d / %d ページ", args.PageNum, args.TotalPages))
    p.SetFontSize(8)
    p.SetPos(0, 20)
    p.SetTextAlignment(creator.TextAlignmentCenter)
    block.Draw(p)
})

ヘッダもフッタも絶対座標で描く block。Y 座標やマージンを間違えれば、ページサイズを変えるたびに座標を直すことになる。

After — gpdf:

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

doc.Header(func(p *template.PageBuilder) {
    p.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("ACME 株式会社", template.Bold(), template.FontSize(12))
            c.Line(template.LineColor(pdf.Gray(0.7)))
            c.Spacer(document.Mm(4))
        })
    })
})

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.PageNumber(template.AlignRight(),
                template.FontSize(8), template.TextColor(pdf.Gray(0.5)))
        })
    })
})

for i := 0; i < 10; i++ {
    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text(fmt.Sprintf("%d ページ目の本文。", i+1))
        })
    })
}

PageNumber / TotalPages はプレースホルダ。ページ確定後にレイアウトエンジンが解決する。ヘッダもフッタも自前のツリー — 配置するブロックではない。エンジンが各ページに毎回スペースを確保する。A4 から Letter にページサイズを変えても、他は何も触らなくていい。

Before / After 5: AES-256 暗号化

ライセンスの差が一番はっきり出るペア。unipdf の暗号化は model.PdfWriter を経由する経路で、これは商用利用にあたり license 登録のチェックが走る。gpdf 側は単一の関数オプション。AES-256 (ISO 32000-2 Rev 6) の実装はオープンソースの MIT コアに同梱。

Before — unipdf:

// creator で生成 → model.PdfWriter で再エンコードして暗号化を付与。
// ここで license チェックが発火する。
c := creator.New()
// ... コンテンツを描画 ...

var buf bytes.Buffer
if err := c.Write(&buf); err != nil {
    log.Fatal(err)
}

reader, err := model.NewPdfReader(bytes.NewReader(buf.Bytes()))
if err != nil {
    log.Fatal(err)
}

writer := model.NewPdfWriter()
encryptOpts := &model.EncryptOptions{Algorithm: model.RC4_128bit, Permissions: model.PermPrinting}
if err := writer.Encrypt([]byte("user-pwd"), []byte("owner-pwd"), encryptOpts); err != nil {
    log.Fatal(err)
}

for i := 1; i <= reader.NumPage; i++ {
    page, _ := reader.GetPage(i)
    writer.AddPage(page)
}

f, _ := os.Create("encrypted.pdf")
defer f.Close()
writer.Write(f)

After — gpdf:

doc := gpdf.NewDocument(
    gpdf.WithPageSize(document.A4),
    gpdf.WithMargins(document.UniformEdges(document.Mm(20))),
    gpdf.WithEncryption(
        gpdf.AES256,
        "user-pwd",
        "owner-pwd",
        gpdf.PermPrinting|gpdf.PermCopyContent,
    ),
)

page := doc.AddPage()
page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Text("社外秘")
    })
})

data, _ := doc.Generate()
os.WriteFile("encrypted.pdf", data, 0o644)

オプション 1 つ、デフォルト AES-256、別 writer パスなし。暗号化経路は MIT コア内 — 同じモジュール、同じ go get。署名も同じ話で、gpdf.SignDocument(pdfBytes, signer, gpdf.WithTSA("http://timestamp.digicert.com")) で PKCS#7 + RFC 3161 タイムスタンプを後処理として付与する。追加パッケージもキー登録もない。

どれくらい速いのか

_benchmark/benchmark_test.go を Apple M1 / Go 1.25 で実行した結果。unipdf は別実行で同条件 (Apple M1 / Go 1.25 / unipdf v3) で計測した値。

ベンチマークgpdfunipdf*gofpdfMaroto v2
単一ページ13 µs~180 µs132 µs237 µs
4×10 請求書テーブル108 µs~8.6 ms241 µs8.6 ms
100 ページレポート683 µs~95 ms11.7 ms19.8 ms
複雑な CJK 請求書133 µs~12 ms254 µs10.4 ms

* unipdf の数値は別実行で取得した参考値。同等ハードでの計測だが、gpdf の committed suite には含まれていない。

形は gofpdf 比較と同じ。実運用で使われるワークロードに対しては 10〜80 倍速い。テーブル多めで 1 ページ 108 µs ということは、1 コアで 1 秒間に 9,000 通の請求書。「PDF 生成をキューに入れるか、リクエスト経路で生成するか」の議論が、ほぼリクエスト経路で完結する側に寄る。

gpdf にない部分の正直な扱い

unipdf の請求書を OCR・redaction・PDF 解析が支えている場合、この移行ではカバーしきれない。正直な選択肢:

  • OCR: gpdf は OCR をやらないし、当面やる予定もない。Tesseract を gosseract 経由で、または OCR API を使う。生成は gpdf、OCR は別経路。
  • PDF 解析 / テキスト抽出: gpdf は生成専用。読み取りは pdfcpu が広く使え (Apache 2.0)、unipdf を解析専用に残せばライセンス購入数を減らせる可能性がある。
  • AcroForm の新規フィールド作成: gpdf は既存 AcroForm のフラット化はできるが、新規フィールドの作成はまだ。ビューアでユーザーが入力するフォーム PDF を作っているなら、ここがギャップ。ロードマップに乗っている。
  • Redaction (黒塗り): gpdf のロードマップにはない。redaction は何を黒塗りするかを判定するためにレンダラ側の知識が必要で、生成とは別アーキテクチャ。

生成・暗号化・署名・フォント・CJK ─ これは大半の unipdf 請求書の対象 ─ は完全に置き換えられる。

FAQ

gpdf は unipdf のフォークですか? 違う。gpdf は純 Go によるクリーンな再実装。PDF のワイヤフォーマット、レイアウトエンジン、TrueType サブセッタ、AES、PKCS#7 — 全てゼロから書かれている。unipdf からコードをコピーしていないため、ライセンス上の論争にもならない。

本当に MIT なのか? 「条件次第で AGPL」みたいな付帯条項はない? ない。リポジトリの LICENSE は MIT そのまま。追記なし、分野別の使用制限なし、商用枠の切り出しなし。クローズドソースの製品に組み込んで配布、商用 SaaS に同梱、オンプレアプライアンスに同梱、すべて可能。義務は配布物にライセンス・著作権表記を含めることだけ。

推移的依存に copyleft が混入していたりしない? gpdf コアの go.modrequire ブロックは空。推移的 AGPL も GPL も何もない。go get 後に go mod graph | grep gpdf で確認できる。

ライセンスキーを消すことが、そんなに重要? チームによっては「全て」。キーはシークレットマネージャに置き、ローテーションし、監査され、全コンテナイメージに含め、ログに漏らさないように扱う必要がある。マルチテナント SaaS で Pod が数百あれば、これは実運用上の負担。要件自体を消すと、インシデントの 1 クラスがなくなる。

今の unipdf コードは creator.Block.SetPos で絶対座標を多用している。gpdf に対応物は? ある。page.Absolute(x, y, fn) でサブツリーを明示座標に置ける。ただしコードが絶対座標まみれの場合、レイアウトエンジンモデルへの移行は構文の話ではなく思考様式の話。見積もり前に 12 カラムグリッドの記事 を読むのが安全。書き直し後はだいたい元より短くなる。

もし将来 UniDoc が unipdf を MIT に再ライセンスしたら? 選択肢が 1 つ増える。gpdf の主張は「unipdf が永遠に AGPL である」ではなく、「起動時の登録呼び出しを要求するライセンスと、開発者ごとの財務更新は、大半のワークロードでは存在しなくていいコスト」というもの。仮に明日再ライセンスされても、ライセンスキーの運用面は削除されるまで残る。

gpdf を使ってみる

gpdf は Go の PDF 生成ライブラリ。MIT、外部依存ゼロ、ライセンスキー不要、ネイティブ CJK 対応。

go get github.com/gpdf-dev/gpdf

⭐ Star on GitHub · ドキュメント

次に読む