Deploy / build (push) Successful in 17s
Every post now carries one unique local-ComfyUI/Ideogram 4 base frame, composited into EN + ZH share cards, a banner and list thumbs. - the shipped prompts_catalog held only 21 distinct metaphors for 72 posts (16 shared one visual), so all 72 were rewritten as unique object+state metaphors and 10 frames re-rolled after OCR found pseudo-text rendered at legible size - render_from_art.mjs emits JPEG by default (558 KB PNG -> 84 KB mozjpeg q85); the ZH 1200x630 card did not exist before this batch - 288 frontmatter fields migrated; the 144 v1 PNGs are gone - PostList's thumb helper keys off the base slug (a ZH card is zh-<slug>.jpg while the thumb is <slug>-800.webp, which 404'd the whole ZH listing) - [...slug].astro's <picture> regex accepts .jpg - the repo now carries the art inputs (metaphors.json, captions/<slug>.json, refreshed prompts_catalog.json) so a frame is reproducible from git alone
159 lines
7.9 KiB
Markdown
159 lines
7.9 KiB
Markdown
---
|
|
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.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 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.
|