記事一覧

gpdf で Noto Sans JP を使うには?

static 版の NotoSansJP-Regular.ttf を gpdf.WithFont に登録するだけ。Variable フォントを避ける理由と、17,000 グリフが PDF 内で 40 KB 未満まで減るサブセット化の話。

著者: gpdf team

質問を言い換えると

gpdf で日本語 PDF を作りたい。フォントは Noto Sans JP にしたい (Google が配布している SIL OFL ライセンス、JIS 範囲をフルカバーする定番のゴシック体)。Google Fonts の zip を落としたところまでは分かった。知りたいのはここから先 — どのファイルを選ぶか、どの weight を登録するか、zip の中に仕込まれている 1 つの罠は何か

結論 (TL;DR)

Google Fonts の zip を展開したら static/NotoSansJP-Regular.ttf を使う。zip ルートにある variable font ではない。これを gpdf.WithFont("NotoSansJP", bytes) に渡してデフォルトフォントに指定するだけ。gpdf は約 17,000 グリフのうち、実際に描画した分だけをサブセット化して PDF に埋め込む。請求書 1 枚なら 20〜40 KB。

完全なサンプル

package main

import (
    "log"
    "os"

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

func main() {
    font, err := os.ReadFile("NotoSansJP-Regular.ttf")
    if err != nil {
        log.Fatal(err)
    }

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

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("請求書", template.FontSize(28), template.Bold())
            c.Text("Noto Sans JP、これで十分。")
        })
    })

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

zip は Google Fonts からダウンロード → 展開 → static/NotoSansJP-Regular.ttfmain.go の横に置いて go run main.go。これで 1 ページの PDF が出る。

Variable フォントではなく static TTF を選ぶ

Google Fonts のページで Get font → Download all、zip を展開すると、中に見た目が似ている 2 つの塊がある:

  • NotoSansJP-VariableFont_wght.ttf (zip 直下) — weight 100〜900 を 1 ファイルに収めた variable font、約 7 MB
  • static/ ディレクトリ — NotoSansJP-Thin.ttf から NotoSansJP-Black.ttf まで weight 別に分かれた 9 本の TTF、各 5 MB

static/ の方を使う

gpdf の TrueType パーサは意図して機能を絞ってある。グリフアウトライン、コンポジットグリフ、cmaphmtx — 固定 weight のテキストを描画するために必要なテーブルは扱う。一方で variable font を動かすための fvar / gvar / HVAR は読まない。VariableFont_wght.ttf を渡すとパーサがエラーで止まるか、運が悪ければデフォルトインスタンスのグリフだけを拾って weight 指定を黙って無視する。設定した weight が反映されない理由が 2 週間分からなかった、ということになる。

ファイルサイズの観点でも variable は不利。variable font は weight 軸上のすべてのインスタンス分のアウトラインを 1 ファイルに持つ — それが設計意図だが、Regular しか使わないなら 8 weight 分の余計なデータを抱えることになる。static Regular が 5 MB、variable が 7 MB。どちらもサブセット化で削られるが、入力は static の方がクリーン。

肝心なのはこの 4 行

意味のあるコードは NewDocument のオプションだけ:

doc := gpdf.NewDocument(
    gpdf.WithFont("NotoSansJP", font),
    gpdf.WithDefaultFont("NotoSansJP", 11),
)

ファミリ名 ("NotoSansJP") は任意。gpdf はこれをルックアップキーとして使うだけで、ファイルパスでも、フォントのメタデータから読んだ名前でもない。チームで読みやすいなら "body" でも "jp" でも "Noto" でも良い。template.FontFamily(...) 側で同じ名前を指定していれば揃う。

WithDefaultFont は、毎回の c.Texttemplate.FontFamily("NotoSansJP") を書かずに済ませるためのもの。これを省略すると gpdf は Helvetica にフォールバックする。Helvetica は CJK コードポイントを 1 つもカバーしていないので、見出しだけフォント指定しているようなコードだと、本文全部が豆腐 (□□□□) になって「なぜ見出しだけまともなんだ」と悩むことになる。

weight はどこまで登録すべきか

請求書・領収書・業務レポートなら Regular と Bold の 2 つで事足りる。

reg,  _ := os.ReadFile("NotoSansJP-Regular.ttf")
bold, _ := os.ReadFile("NotoSansJP-Bold.ttf")

doc := gpdf.NewDocument(
    gpdf.WithFont("NotoSansJP", reg),
    gpdf.WithFont("NotoSansJP-Bold", bold),
    gpdf.WithDefaultFont("NotoSansJP", 11),
)

-Bold というサフィックスで登録しておくと、template.Bold() が自動でこちらを拾う。-Italic-BoldItalic も同じ規約。ただし Noto Sans JP にイタリックは存在しない — CJK フォントには字形上の自然な斜体がないので、Noto 系列でも提供されていない。日本語で強調したい場合は色・サイズ・太字のどれかで差をつける。

パンフレットや見出しで Medium や SemiBold が欲しいときは、好きなサフィックスで登録して、ファミリ名で直接参照すればいい:

gpdf.WithFont("NotoSansJP-Medium", medium)
// ...
c.Text("見出し", template.FontFamily("NotoSansJP-Medium"))

サフィックスで自動対応するのは -Bold / -Italic / -BoldItalic の 3 つだけ。それ以外はファミリ名でアドレスする。

サブセット化後の実サイズ

Noto Sans JP Regular はディスク上で約 5 MB。この数字を見て、フォント配信用 CDN を別に立てたり、PDF 生成後にフォントを剥がすポストプロセスを組んだりするチームが時々いる。gpdf 相手ならどちらも不要。

実際に PDF に入るバイト数はこれくらい:

ドキュメント使用グリフ数PDF 内のフォントデータ
1 行のレシート (15 字)約 14約 11 KB
一般的な請求書 (200 字)約 80約 28 KB
10 ページのレポート (8,000 字)約 900約 180 KB
JIS 第一水準フル使用約 6,800約 2.1 MB

(gpdf v1.0、static サブセット化有効時。CFF と hmtx のどこにグリフ ID が落ちるかで数 KB 前後する)

最終的に 50 KB の請求書 PDF なら、その半分以上がフォントデータ、ということになる。それでもサブセット化なしで 5 MB 丸々埋めるのと比べれば誤差みたいなもので、ビューアは即座に開く。

Noto Sans JP と Noto Sans CJK JP を混同しない

日本語を扱える Noto 系ファミリは 2 つあって、名前が似ているので混同されやすい。中身は全く別物。

Noto Sans JP が今回使う方。TTF 配布、日本語専用、weight ごとに別ファイル。Google Fonts からダウンロードできるのはこれ。

Noto Sans CJK JP は CJK 横断の大家族。OpenType Collection (.ttc) 形式で、日本語・簡体中国語・繁体中国語・韓国語のグリフを漢字統合 (Han unification) しつつ 1 ファイルに格納している。初期の Noto リリースや notofonts.github.io/noto-cjk に置かれているのはこちら。

gpdf は TTF をそのまま扱える。TTC はコンテナ形式なので、WithFont に渡す前にフェイスインデックスを選ぶ必要があるし、各フェイスの cmap は特定の CJK ロケール向けにチューニングされているため、漢字統合まわりの選択を暗黙にすることになる。JP 専用の TTF を選ぶ方が選択が明示的で事故が少ない。

新規プロジェクトなら Noto Sans JP を使う。レガシーで NotoSansCJK-Regular.ttc が既にリポジトリにある場合は、pyftsubsetfonttools で JP フェイスだけを抽出して TTF としてチェックインし直すのが推奨。

バイナリに同梱する

PDF ジェネレーターはたいていコンテナで動く。フォントを一緒に配る一番きれいな方法はバイナリに焼き込むこと:

package main

import (
    _ "embed"

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

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

func main() {
    doc := gpdf.NewDocument(
        gpdf.WithFont("NotoSansJP", notoJP),
        gpdf.WithDefaultFont("NotoSansJP", 11),
    )
    // ...
}

バイナリサイズは 8 MB → 13 MB 程度に膨らむ。代わりに Docker イメージの成果物が 1 つだけになり、COPY --from=builder /app /app で済み、フォントファイル忘れで壊れたコンテナを誰かがリリースする事故がなくなる。1 日に数千 PDF 出すバッチジョブなら、これがデフォルトでいい。

関連記事

gpdf を使ってみる

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

go get github.com/gpdf-dev/gpdf

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