A W-9 request form has a text field called full_legal_name. A customer named Tanaka Taro signs up, the field gets filled with his name, and the PDF that comes back is a 200 with a valid application/pdf body. Open it, and the name field reads ????. Nothing in the response says that happened.
Why Helvetica cannot draw the name you just filled
Most PDF form fields reference a font through a /DA default-appearance string, usually /Helvetica, one of fourteen standard fonts every PDF reader ships without the PDF embedding it. Helvetica as a standard font encodes WinAnsi, which is Latin-1: the Latin alphabet plus the accented letters Western European languages need. It has no glyphs for Japanese, Cyrillic, Arabic, Hebrew, Thai, or Han characters, and none for most emoji either. A filler that draws the field's text with that font can only draw what WinAnsi covers, no matter what string the caller sent.
What a tool does when it meets a character it cannot draw
A PDF library filling thousands of forms a day cannot stop and ask a human what to do with a character outside the font, so most pick a fallback and move on rather than throwing on every unsupported codepoint. The common choice, and the one the fill path in PDFops' own stack takes by default, is to encode the text one codepoint at a time and substitute a literal ? for anything WinAnsi cannot represent. That substitution happens inside the font embedder, with no exception raised and no flag set on the result. Four Japanese characters go in; four question marks come out, baked straight into the field's appearance stream.
Why the field value looks fine and the PDF still doesn't
The field's underlying value, the actual string a PDF stores in its /V entry, is untouched by any of this. Reading it back with a library like pypdf or pdf-lib returns "Tanaka Taro" exactly as sent. Only the rendered appearance, the glyphs drawn for a human to look at, carry the question marks. That split is what makes the bug easy to ship: a test that fills a field and reads the value straight back passes every time, because the value was never touched. The only way to catch the problem is to open the file and look, or to inspect the raw content stream the appearance was drawn into, and a CI pipeline does neither by default.
Checking before you fill, not after
The fix that actually works is checking the string against the font's character set before it ever reaches the fill call, the same way a form validates an email address before it hits the database. WinAnsi is a fixed, small set, so the check is cheap: build it once from the embedded font, then test every character of every value against it.
// Build once per process; WinAnsi never changes at runtime.
const WINANSI = new Set(
Array.from({ length: 256 }, (_, i) => i)
.filter((code) => isWinAnsiRenderable(code)), // see your PDF library's font metrics
);
function findUnrenderable(value) {
const bad = [];
for (const ch of value) {
const cp = ch.codePointAt(0);
if (!WINANSI.has(cp)) bad.push(ch);
}
return bad;
}
function assertRenderable(fieldName, value) {
const bad = findUnrenderable(value);
if (bad.length > 0) {
throw new Error(
`field "${fieldName}": "${value}" contains characters a WinAnsi ` +
`font cannot draw (${bad.join(", ")}). Embed a Unicode font ` +
`(e.g. a Noto variant) instead of the standard Helvetica, or ` +
`transliterate the value before filling.`,
);
}
}
This is the same shape of check PDFops' own /api/invoice endpoint already runs before it draws a single character, precisely because that endpoint builds its own pages with the same standard Helvetica. Running the identical check in application code ahead of any form-fill call, whatever library sits underneath it, turns a silently wrong PDF into a loud error that names the exact field and the exact characters it couldn't draw.
What a WinAnsi font actually covers
Latin-1 covers more than it looks like at a glance: café, Müller, and García all render correctly, since é, ü, and í sit inside WinAnsi's extended range. What breaks is anything outside the Latin script entirely, plus a handful of typographic characters some editors insert automatically, like curly quotes or an em dash pasted from a word processor, if the specific font's character table happens to omit them. The only real fix for names and text in another script is embedding a font that actually has the glyphs, a Noto variant covers most scripts, and re-running the appearance generation with that font instead of Helvetica. Where that is not practical, transliterating to the closest Latin representation is the honest fallback, with the original value still stored in whatever system of record sits behind the PDF.
Try it
A free key from /docs/signup includes 250 calls a month, enough to fill a test AcroForm with a few non-Latin names and confirm for yourself which ones come back readable and which come back as question marks before any real customer data goes through the same path.
Top comments (0)