post: four posts — rclone/Drive quota, DSM space reclamation, mail domain/PTR, embedded fonts (EN+ZH) + og/banner
Deploy / build (push) Successful in 32s

- my-backup-was-using-someone-elses-api-quota        (notes,       2026-10-15, pinned by its own evidence)
- space-reclamation-wasnt-stuck-it-was-waiting       (devops,      2026-10-14, pinned by its own samples)
- adding-a-domain-to-someone-elses-mail-server       (notes,       2025-05-06, backdated: no date/version pins)
- embedding-fonts-so-a-pdf-looks-the-same-everywhere (engineering, 2025-01-22, backdated: no date/version pins)
All four updatedDate 2026-10-16 so every lastmod stays honest; the two backdated posts carry no prose links
(a backdated post may only link backwards). og measured rows=1/overflow=0, banners 8 rows/delta=2/overflow=0.
This commit is contained in:
hoelee committed 2026-10-16 13:47:10 +08:00
1 parent 636e6f9624
commit 08fa7a9412
18 files changed
+1036

No files matched your search

@@ -0,0 +1,158 @@
---
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: 2025-01-22
updatedDate: 2026-10-16
category: engineering
tags: ["pdf", "fonts", "mpdf", "typography", "php"]
ogImage: /og/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
banner: /banners/embedding-fonts-so-a-pdf-looks-the-same-everywhere.png
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 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:
- 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.