post: four posts — rclone/Drive quota, DSM space reclamation, mail domain/PTR, embedded fonts (EN+ZH) + og/banner
Deploy / build (push) Successful in 32s
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:
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.
|
||||
Reference in new issue
Block a user