Guide
A customer's name fills a PDF form field as ????, and the response is still 200
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.