記事一覧

gpdf で日本語が豆腐文字 (□□□) になる原因と直し方

PDF 出力で日本語が □ になるのはフォント未登録が最多。よくある 4 原因と直し方を最短で整理する。

質問を言い換えると

gpdf で日本語を書いたら、PDF にしたときに文字が □ で出てしまう。これは何で、どう直すのか。

速答

これは豆腐文字 (tofu)。PDF ビューアが、埋め込まれているフォントに該当コードポイントのグリフがないときに代替で描く矩形。原因は 4 つあり、そのうち 1 つが圧倒的に多い。

頻度順:

  1. CJK フォントを登録していない。 gpdf.NewDocumentWithFont がなく、PDF 標準 14 フォント (Helvetica / Times / Courier) にフォールバックしている。どれも U+3040〜U+9FFF をカバーしない。
  2. CJK フォントは登録したが c.Text のファミリ名が違う。 WithFont("NotoSansJP", ...) はあるのに template.FontFamily("Arial") を指定していて、Latin フォントで日本語を引きにいっている。
  3. TTF ファイル自体に CJK グリフが入っていない。 ディスク上の TTF が Latin サブセット (NotoSans-Regular.ttf) で、見た目のファイル名は正しそうだが中身に日本語グリフがない。
  4. gpdf に渡す前に文字列が化けている。 Shift-JIS や Latin-1 として読んでしまった文字列を UTF-8 として扱っており、そもそも日本語の Unicode コードポイントではなくなっている。□ ではなく 縺ゅ→縺 のような化け方 ならこれ。

原因 #1 の直し方 (これで 9 割片付く)

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", 12),
    )

    page := doc.AddPage()
    page.AutoRow(func(r *template.RowBuilder) {
        r.Col(12, func(c *template.ColBuilder) {
            c.Text("こんにちは、世界。")
        })
    })

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

2 行で登録と既定設定が済む。CGO なし、AddUTF8Font の段取りもなし。□□□□□、□□。 になっていたものが、このコードと NotoSansJP-Regular.ttf を同じディレクトリに置いて実行すれば正しい日本語で出る。

NotoSansJP-Regular.ttfGoogle Fonts から。

どの原因か見分ける

見るべきは 3 箇所 — ドキュメントを組み立てているところ、テキストを書いているところ、TTF ファイルそのもの。

出力が一様な □□□ (全部同じ矩形) なら原因 1・2・3 のいずれか。PDF にはフォントが埋め込まれているが、そのフォントに必要なグリフがない状態。PDF を Acrobat で開き ファイル → プロパティ → フォント でどれが埋め込まれているか見る。Helvetica / Times / Courier だけなら原因 1。NotoSansJP が載っているのに豆腐なら原因 2 か 3。

出力が 縺ゅ→縺ã"ã‚"ã«ã¡ã¯ のような Latin 交じりの化け方なら原因 4。日本語文字列が gpdf に渡る前に再エンコードされている。日本で一番多いのが Excel で保存した Shift-JIS CSVos.ReadFile でそのまま string にしているケース。次点は MySQL のカラム定義が utf8mb3latin1 のままになっているケース。gpdf 側では直せない — 読み込み側を修正する。

一部だけ化ける (ひらがな・カタカナは出るが特定の漢字だけ □) ならフォントが部分的にしかカバーしていない。「日本語対応」を名乗るフォントでも 鬱・龠・曻 のような JIS X 0213 第 3・第 4 水準の漢字を落としていることがある。Noto Sans JP は JIS X 0213 を網羅しているので切り替えれば直る。

原因 2 の詳細: フォントは入っているのに使われない

やっかいなのはこれ。フォント自体は PDF に埋め込まれているのに、テキスト側で引いていない。最小再現:

doc := gpdf.NewDocument(
    gpdf.WithFont("NotoSansJP", font),
    // WithDefaultFont を書き忘れている
)

page.AutoRow(func(r *template.RowBuilder) {
    r.Col(12, func(c *template.ColBuilder) {
        c.Text("こんにちは") // デフォルト = Helvetica で描画される
    })
})

直し方は 2 つ: gpdf.WithDefaultFont("NotoSansJP", 12)NewDocument に足すか、日本語を書く全ての c.Texttemplate.FontFamily("NotoSansJP") を渡す。WithFont に渡したファミリ名と c.Text 側のファミリ名は大文字小文字まで完全一致が必要。NotoSansJPnotosansjp は別フォント扱いになる。

原因 3 の詳細: TTF ファイルが違う

NotoSans-Regular.ttfNotoSansJP-Regular.ttf は別ファイル。前者は Latin 専用で CJK グリフはゼロ、後者が日本語版 (約 17,000 グリフ)。ls で並べると見分けにくく、補完で間違って掴むことがある。

gpdf は登録時にグリフの網羅を検証しない — バイト列を渡したら信用する設計。失敗はレンダリング時の豆腐で初めて現れる。

確認方法:

  • macOS: Font Book でファイルをダブルクリックするとグリフ一覧が見える
  • Linux: otfinfo -u NotoSans-Regular.ttf で Unicode カバレッジが出る
  • 全 OS: fontToolsttx -t cmap NotoSans-Regular.ttf で cmap テーブルが XML で出る

U+3042 (あ) が含まれていなければ Latin サブセットを掴んでいる。

原因 4 の詳細: 文字コードの化け

これは gpdf 以前の問題。c.Text に渡した時点で文字列が既に壊れている。描画前に print で確認する:

text := loadLabelFromSomewhere()
fmt.Printf("%q\n", text) // 実際の runes が出る
c.Text(text)

ここで "縺ゅ→縺" と出たら、もっと前の段階で UTF-8 が壊れている。gpdf では直せない — 文字列を作っている側で直す。

日本の業務系で頻出するパターン:

  • Excel から書き出した Shift-JIS CSVos.ReadFilestring() でそのまま読んでいる。Excel はデフォルトで CP932 (ほぼ Shift-JIS) で保存する。golang.org/x/text/encoding/japaneseShiftJIS.NewDecoder() を挟む
  • レガシー DB のカラムが utf8mb3latin1 のままで、保存時点で既に化けている。カラム定義を utf8mb4 に変更し、既存データはマイグレーションで変換
  • HTTP レスポンスContent-Type: application/json; charset=utf-8 が付いておらず、クライアントが Latin-1 推定した
  • EUC-JP の古いシステム からデータを引っ張ってきたケース。滅多に見ないが、官公庁系の CSV では今も現役

1 つ見落としやすい罠

gpdf のサブセット化は Generate() の瞬間に確定する。つまりドキュメント構築中に こんにちは を描画し、その後 鬱陶しい を描画したら、2 回目もちゃんとサブセットに追加される。ただし、生成済みの PDF を Acrobat で後から開いて漢字をタイプし直すとそのグリフはサブセットに入っていないので豆腐になる。PDF を後編集するのではなく、Go 側でもう一度 Generate() する。

関連記事

gpdf を使ってみる

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

go get github.com/gpdf-dev/gpdf

⭐ GitHub でスター · ドキュメントを読む