--- title: "Embedding Fonts So a PDF Looks the Same on Every Machine" description: "Four families, eight faces, two renderers and one CSS quirk: what it took to stop a generated report depending on whatever fonts the server happened to have." pubDate: 2026-10-15 updatedDate: 2026-10-16 category: engineering tags: ["pdf", "fonts", "mpdf", "typography", "php"] ogImage: /og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.jpg banner: /banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.jpg draft: false --- ## 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: ```text 犇 骉 頔 祎 廸 玧 祾 赟 堃 ``` 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](https://wa.me/60127972969) · [me@hoelee.com](mailto:me@hoelee.com?subject=PDF%20Typography%20Pipeline) · [hoelee.com](https://hoelee.com).