Embedding Fonts So a PDF Looks the Same on Every Machine

· 7 min read · engineering pdffontsmpdftypographyphp

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:

beforeafter
zh page count2323
en page count2829
bm page count3030
fonts actually embeddedone CJK face + RobotoSource 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 memorybaselinesame order (±10%, noise)
file sizebaselineroughly +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 is ~31k code points and roughly 50 MB of embedded fonts; 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:

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.

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts