Embedding Fonts So a PDF Looks the Same on Every Machine

2026-11-05来源 dev.toEnglish

Originally published on my blog — read it there with the full context, code and updates.

The requirement was one sentence

A report generator has to produce a document that looks the same everywhere:
Chinese headings in a serif, Chinese body text in a sans, Latin headings and
cover numerals in a display serif, Latin body text and tables in Inter.
Weights matter — medium for emphasis, semibold for headings — across three
languages and two PDF renderers.

And the fonts must travel with the document. Not "the server has them
installed", not "the reader probably has them" — embedded, so a print
shop in another country sees the same pages.

The old pipeline failed on the last clause in a way that was invisible: it
depended on fonts installed on the server by a package manager, and it only
ever embedded a single CJK face. The second weight did not exist, so "bold"
was the renderer's synthetic approximation, and the only way to notice was
to open the PDF and look at what was actually inside it.

Rule one: read the fonts back out of the finished PDF

"Embedded" is a claim about the output, so I measured the output. The same
document, rendered before and after:

before after
zh page count 23 23
en page count 28 29
bm page count 30 30
fonts actually embedded one CJK face + Roboto Source Han Serif Medium + SemiBold · Source Han Sans Regular + Medium · Cormorant Garamond Medium + SemiBold · Inter Regular + SemiBold + Medium · Source Han Sans for the CJK runs inside Latin reports
render time / peak memory baseline same order (±10%, noise)
file size baseline roughly +3%

The glyph counts per face are the receipt: in the Chinese report, 15,988
glyphs land in the body sans, 2,247 in its medium weight, 634 in the heading
serif medium, 222 in its semibold. That is the difference between "the font
is on the server" and "the font is in the file" — a reader in another
country sees the same four faces, and a page count that moved by exactly one
page in one language (the Latin metrics are wider than what they replaced)
is the honest cost.

Rule two: static instances only, and the reason is in the parser

The obvious modern choice — ship one variable font per family — is wrong
here, for two measured reasons:

  1. The PDF engine's font parser reads glyf/loca only. Feed it an OpenType file with PostScript/CFF outlines and it refuses: Fonts with postscript outlines are not supported. A variable font is not the problem; CFF outlines are.
  2. It does not read the fvar table at all, so a variable font silently resolves to its default instance — and the default instances of the CJK families are the thinnest ones (Thin 100 and ExtraLight 200, visible in the name table). Every page would render in a hairline weight, with no error anywhere in the pipeline.

So the build step takes the upstream variable fonts and instantiates
static cuts
: it runs the instancer, then rewrites name,
OS/2.usWeightClass and head.macStyle so the file identifies itself as
the weight it now is (the source's own name still says "Thin"). A manifest
records family, weight class, sha256, glyph count and code-point count for
every file, and the licence files ship next to them.

Rule three: the engine speaks two weights, so map onto two slots

The renderer's CSS handling honours exactly normal and bold — its source
says so in as many words ("Does not support: bolder, lighter, 100..900").
Numeric weights are therefore meaningless to it, while the headless-Chromium
renderer in the same pipeline honours them properly.

The mapping that works: each family declares an R slot and a B slot
(regular-ish and heavier), font-weight: 500 lands on the R slot of the
intended family, and 600 is rewritten to bold on the PDF side only,
which lands on the B slot. Chromium keeps 600 as written. Two tests pin
it: the B slot must be heavier than the R slot (read from usWeightClass,
not assumed), and font-weight: 600 must never appear in the stylesheet
handed to the PDF engine.

One subtlety worth the paragraph: a "medium" weight is not an emphasis
mechanism in this pipeline, it is a family member. Chinese headings use
the serif's medium as their regular and its semibold for emphasis; Latin
headings do the same with the display serif. Getting that wrong is invisible
on a screen and obvious in print.

Rule four: do not subset against a character set you do not control

The first attempt subset the CJK fonts to the union of two national
character sets and produced a ~29 MB bundle instead of ~50 MB. Then I ran
the name probe and found these were cut:

犇 骉 頔 祎 廸 玧 祾 赟 堃 

Every one of them is a real given name character. The report prints a
customer's name on the cover, and the name is free input — so a subset-based
build will eventually put a tofu box on the most prominent page of the
product. Full coverage means keeping ~31k code points — roughly 50 MB of static font assets in the repository (adding only ~3% to each finished PDF after engine subsetting); that is a fine price for never showing a customer an empty rectangle.

Rule five: make missing glyphs a build failure, not a surprise

The gate runs three checks and exits non-zero on any of them:

  • every font file declared in the manifest is present, and its sha256 matches the file on disk;
  • every character in the content library (all three languages) plus the brand strings, plus probe classes — simplified, traditional, CJK punctuation, full-width forms, mixed-script, digits, Latin extended, symbols — plus the name characters, has a glyph in the face that will actually print it;
  • the lookup uses the PDF engine's own font parser rather than a third-party library, so the check cannot disagree with the renderer.

The known edges are written down instead of hidden. Dingbats and emoji (✅ ✔)
exist in none of these text fonts and the engine cannot draw colour emoji,
so they are kept out of the report body. The display serif has only 974
glyphs and does not draw symbols like ① ★ ℃, which is why the
content-library check covers all slots rather than just the body. And CJK
characters inside a Latin report must go through the dedicated CJK slot —
two tests keep the renderers from disagreeing about that.

What I would do differently

  1. Choose faces and weights before styling anything. Retrofitting a second weight after the layout exists means re-checking every page count by hand.
  2. Read the renderer's source before choosing a font format. The variable-font decision looked like a taste question and was actually a parser question — two files settled it in ten minutes.
  3. Treat everything you ship inside a document as a licence artifact. Font files enter the repo with their licences, or they enter someone else's legal review.
  4. Measure what got embedded. Page count, glyph-per-face counts, file size, render time: all four moved, and three of them moved in ways I would have guessed wrong.

The result

Before: one CJK face and a UI font, with synthetic bold, depending on what
the server had installed. After: four families and eight static faces
embedded in the file, weights that are real members of their families, page
counts within one page of the previous layout, and a lint gate that fails
the build when a character has no glyph — so the failure mode moved from "a
customer sees a box" to "CI refuses the release". Which is the same trade as
everywhere else in this pipeline: make the mistake impossible to ship rather
than easy to notice.

I build automated document generation and multi-lingual PDF publishing pipelines — from layout engine customisation and compliant font embedding to CI visual verification gates. If your business reports, invoices, or client deliverables need to look identical on every machine, get in touch: WhatsApp +60 12-797 2969 · me@hoelee.com · hoelee.com.

I write about self-hosted infrastructure, DevOps and the failures behind them at blog.hoelee.com.

原文:https://www.a8q9.cn/en/article/embedding-fonts-so-a-pdf-looks-the-same-on-every-machine-ea5158.html

相关文章