pdf-lib + fontkit で生成した帳票PDFの電話番号がコピペで化ける
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
NotoSansJP は欧文字と全角記号に挟まれた数字を GSUB の文脈置換で代替グリフに差し替えるが、pdf-lib の ToUnicode CMap には各コードポイントの既定グリフしか載らないため、代替グリフで描画された数字はコピーや pdftotext での抽出時に化けたり消えたりする。
結論
NotoSansJP を fontkit 経由で pdf-lib に埋め込むと、「欧文字+全角記号に挟まれた数字」だけが GSUB の文脈置換で代替グリフに差し替わり、pdf-lib の ToUnicode CMap には載らない。結果として、コピー・検索・pdftotext での抽出時にその数字だけ化けたり消えたりする。
fontkit の features オプションでこの置換を無効化することはできない。回避するには、フォントを読み込む段階で GSUB テーブルのタグそのものを潰し、置換自体を発火させないようにする。
症状
Node.js サーバー側で pdf-lib と @pdf-lib/fontkit を使い、NotoSansJP を埋め込んで帳票 PDF(見積書・請求書・領収証・申込書)を生成していた。画面上・PDF ビューア上の見た目は正しいが、生成した PDF から pdftotext でテキストを抽出したり、PDF ビューアでコピーしたりすると、TEL:080-6748-2231 のような行の数字部分だけが化けたり抜け落ちたりしていた。
同じ PDF 内でも、郵便番号や請求書番号の数字は正しく抽出できており、症状は特定の並びの数字だけに限定されていた。
原因
NotoSansJP は OpenType の GSUB(Glyph Substitution)テーブルに、欧文字と全角記号(コロン等)に挟まれた数字を、見た目をわずかに調整した代替グリフへ差し替えるルールを持っている。fontkit はレイアウト計算時にこのルールを既定で適用し、features オプションを指定しても無効化できない。
一方、pdf-lib が PDF に埋め込む ToUnicode CMap は、各コードポイントの既定グリフにしかマッピングを持たない。GSUB によって代替グリフへ差し替えられた文字は、描画には使われるが ToUnicode には対応するエントリが無い状態になる。
その結果、PDF ビューアや pdftotext がグリフからテキストへ逆引きしようとしても代替グリフに対応する文字が見つからず、抽出結果が化ける、あるいは欠落する。郵便番号や請求書番号の数字が無事だったのは、「欧文字+全角記号に挟まれる」という置換条件に該当せず、既定グリフのまま描画されていたためだった。
直す
帳票は横書きの日本語と英数字のみで構成されており、リガチャや縦書き用のグリフ置換は一切必要としない。そこで、フォントを読み込む段階で GSUB テーブルディレクトリのタグ名を未知のタグに書き換え、置換ルール自体を発火させないようにした。
export function stripGsub(bytes: Uint8Array): Uint8Array {
const buf = Buffer.from(bytes); // Buffer.from(Uint8Array) はコピー(元は変更しない)
const numTables = buf.readUInt16BE(4);
for (let i = 0; i < numTables; i++) {
const off = 12 + i * 16;
if (buf.toString("latin1", off, off + 4) === "GSUB") {
buf.write("XSUB", off, "latin1"); // 未知タグ化=fontkit/HarfBuzz とも不使用になる
}
}
return new Uint8Array(buf);
}
書き換えるのはテーブルディレクトリの4バイトのタグ文字列だけで、テーブル本体のバイト列やオフセットは動かさない。フォントは GSUB のルールを参照できなくなるだけで、通常のグリフ描画(文字の形そのもの)には影響しない。この関数を通したフォントバイトを埋め込みに使うことで、置換自体が起きなくなり、描画に使われるグリフは常にコードポイントの既定グリフと一致する。
気づけなかった理由(再発防止)
見た目のレイアウトチェックだけでは、代替グリフも既定グリフとほぼ同じ形に描画されるため、目視では区別が付かない。症状が出るのは「PDF からテキストを抽出する」という、見た目とは別の経路を通したときだけだった。
修正後は、GSUB を無効化したフォントで実際にレイアウトした際、使用するグリフがすべて「そのコードポイントの既定グリフ」と一致することを確認するテストを追加した。あわせて、GSUB を無効化する前の素のフォントで同じ文字列をレイアウトすると代替グリフが選ばれることを確認するテストも残し、対処が前提としている挙動(NotoSansJP がこの文脈置換ルールを持っていること)自体が将来のフォント更新で変わっていないかを検出できるようにした。
よくある質問
Q1郵便番号や請求書番号の数字は無事だったのはなぜ?
GSUB の文脈置換は「欧文字+全角記号に挟まれた数字」という特定の並びでだけ発火する。郵便番号や請求書番号の数字はこの並びに当てはまらず既定グリフのまま描画されていたため、抽出時も無事だった。実際に化けたのは、コロンの直後に数字が続く電話番号の行だった。
Q2fontkit の features オプションで GSUB を無効化できないの?
できない。features はどの機能を有効にするかを選ぶオプションで、既定で有効な GSUB ルールそのものを止める手段としては機能しないことを実測で確認している。無効化するにはフォントバイナリ側のテーブルタグを書き換える必要がある。
Q3テーブルタグを書き換えるとフォントの見た目や埋め込みサイズは変わる?
変わらない。4バイトのタグ文字列を未知のタグに書き換えるだけで、テーブル自体のバイト列やオフセットは一切動かさない。フォント側は文脈置換のルールを持たなくなるだけで、通常のグリフ描画には影響しない。
Q4帳票以外の縦書きや装飾フォントでも同じ対処でいい?
帳票が横書きの日本語と英数字のみで、リガチャや縦書き用のグリフ置換を一切必要としないからこそ GSUB を丸ごと無効化できた。合字や縦書きレイアウトを使う文書では、この対処法は必要なグリフ置換まで潰してしまうため適用できない。
確認した環境
- pdf-lib ^1.17.1 / @pdf-lib/fontkit ^1.1.1
- 2026-07-24 に自社の帳票PDF生成(Next.js 16 / Node.js サーバー環境)で発生・修正
この記事の根拠
- TypeScriptファイル 47〜82行目コミット 72fa39e
- TypeScriptファイル 112〜157行目コミット 72fa39e
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。