Rebounder Tech Blog

Written by the people who actually run these systems in production.

Phone Numbers Garble When Copied From a pdf-lib PDF

公開 読了時間 約3分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

NotoSansJP substitutes digits between Latin letters and full-width punctuation via GSUB, but pdf-lib's ToUnicode CMap only maps default glyphs, so those digits garble on copy or pdftotext.

Conclusion

Embed NotoSansJP into pdf-lib through fontkit and digits “between a Latin letter and full-width punctuation” are swapped for alternate glyphs by GSUB contextual substitution — glyphs the pdf-lib ToUnicode CMap has no entry for. On copy, search or pdftotext, those digits garble or disappear.

fontkit’s features option cannot disable the substitution. Avoiding it means destroying the GSUB table tag when the font is loaded, so the substitution never fires.

Symptom

A Node.js server used pdf-lib and @pdf-lib/fontkit with NotoSansJP embedded to generate document PDFs — quotes, invoices, receipts, applications. On screen and in a PDF viewer they looked correct, but extracting text with pdftotext or copying from a viewer garbled or dropped the digits in lines like TEL:000-0000-0000.

Within the same PDF, postcodes and invoice numbers extracted correctly. The symptom was confined to digits in one particular sequence.

Cause

NotoSansJP’s OpenType GSUB (Glyph Substitution) table carries a rule swapping digits between a Latin letter and full-width punctuation (a colon, for example) for a slightly adjusted alternate glyph. fontkit applies that rule by default during layout, and the features option does not disable it.

Meanwhile the ToUnicode CMap pdf-lib embeds only maps the default glyph of each code point. A character swapped to an alternate glyph by GSUB is used for rendering but has no corresponding ToUnicode entry.

So when a PDF viewer or pdftotext tries to map glyphs back to text, the alternate glyph has no matching character and the extraction garbles or drops it. Postcodes and invoice numbers survived because they do not match the “between a Latin letter and full-width punctuation” condition and render as default glyphs.

The fix

These documents are horizontal Japanese and alphanumerics only, needing no ligatures and no vertical glyph substitution. So at font load time the GSUB table directory’s tag is rewritten to an unknown tag, and the substitution rules never fire.

export function stripGsub(bytes: Uint8Array): Uint8Array {
  const buf = Buffer.from(bytes); // Buffer.from(Uint8Array) copies; the original is untouched
  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"); // unknown tag: neither fontkit nor HarfBuzz uses it
    }
  }
  return new Uint8Array(buf);
}

Only the four-byte tag string in the table directory is rewritten; no table bytes and no offsets move. The font simply cannot reference GSUB rules any more, and ordinary glyph rendering — the shapes of the characters — is unaffected. Embedding the font bytes that went through this function means no substitution happens and the glyphs used for rendering always match the default glyph of the code point.

Why it went unnoticed, and preventing a repeat

A visual layout check cannot catch it: the alternate glyph renders in almost the same shape as the default, so the eye cannot tell them apart. The symptom only appears through a different path — extracting text from the PDF.

The fix added a test asserting that, when laying out with the GSUB-disabled font, every glyph used matches the default glyph for its code point. A second test lays out the same string with the untouched font and confirms an alternate glyph is selected, so if a future font update removes the contextual substitution rule this fix assumes, that change is detected too.

よくある質問

Q1Why were postcodes and invoice numbers unaffected?

GSUB contextual substitution only fires for a specific sequence — digits between a Latin letter and full-width punctuation. Postcodes and invoice numbers do not match it, so they render as default glyphs and extract cleanly. What garbled was a phone line where digits follow a colon.

Q2Can't fontkit's features option disable GSUB?

No. features selects which features to enable; it does not stop GSUB rules that are on by default, as measured here. Disabling them requires rewriting the table tag in the font binary.

Q3Does rewriting the table tag change appearance or embedded size?

No. It rewrites a four-byte tag string to an unknown tag without moving any table bytes or offsets. The font simply no longer has contextual substitution rules; ordinary glyph rendering is untouched.

Q4Does this work for vertical text or decorative fonts too?

Disabling GSUB wholesale is only safe because these documents are horizontal Japanese and alphanumerics needing no ligatures or vertical glyph substitution. For documents using ligatures or vertical layout it would destroy substitutions you need.

確認した環境

  • pdf-lib ^1.17.1 / @pdf-lib/fontkit ^1.1.1
  • Occurred and fixed 2026-07-24 in our own document PDF generation (Next.js 16 / Node.js server)

この記事の根拠

  • TypeScriptファイル 47〜82行目コミット 72fa39e
  • TypeScriptファイル 112〜157行目コミット 72fa39e

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。