4.9 KiB
Spike C — hb-subset font growth: M0 result
- Spike: C — grow an existing embedded subset font with a new glyph (engineering plan §14 step 6)
- Date: 2025-07-25
- Status: Complete. Gate MET.
- Gate: the grown subset contains and can render the new glyph, and the original subset is untouched (growth produces a new font, per §4.2).
What was built
spike/C_subset_growth/ exercises §4.2 resolution ladder step 1: when the
user types a character not in the embedded subset, grow the subset from the
installed full font using hb-subset and embed the new subset.
SubsetGrowth.cpp— three-step scenario using the HarfBuzz subset C API and FreeType:- Build the "originally embedded" subset of DejaVu Sans containing only the glyphs for "Hello" (codepoints H, e, l, o).
- Grow it by adding U+03A9 (GREEK CAPITAL LETTER OMEGA) — the character the user typed that wasn't in the original subset. This produces a new font object; the original subset is untouched (§4.2: "Never mutate a font program in place").
- Verify: the grown subset must contain the new glyph, its cmap must map U+03A9 → a glyph id, and FreeType must be able to load that glyph's outline (it renders, not just that the cmap has an entry). And the original subset must NOT contain it (growth actually added something).
main.cpp— emits the contract JSON; exit 0 if the gate is met.
Result
| Metric | Value | Target |
|---|---|---|
| new glyph renders | 1.0 (pass) | 1.0 |
| Property | Original subset | Grown subset |
|---|---|---|
| glyph count | 5 (H,e,l,o,.notdef) | 6 (+ Ω) |
| font bytes | 4300 | 4488 (+188) |
| U+03A9 present | no | yes |
| U+03A9 renders (FreeType outline) | — | yes |
Tested against DejaVu Sans 2.37 (the canonical metric-compatible open face on most Linux systems), HarfBuzz 10.2, FreeType 2.13. Verified clean under ASan+UBSan.
Findings
-
hb-subset growth works as designed. Adding a codepoint to the input's unicode set and re-running
hb_subset_or_failproduces a new, valid subset with the new glyph and a correct cmap, at a modest byte cost (+188 bytes for one glyph on this font). This is the §4.2 step-1 resolution path and it is viable. -
HarfBuzz's subset API changed across versions. The
HB_SUBSET_SETS_DROPset name used in older HarfBuzz does not exist in 10.2; the input's unicode set plus the default.notdefretention is sufficient. The plan should pin a HarfBuzz baseline (the vcpkg manifest already does) and document the API shape, because this is the kind of leaf-API drift that bites during upgrades. -
FreeType's
FT_Get_Char_Index+FT_Load_Glyphis the right verification pair. A cmap entry alone does not prove the glyph renders — a subset could map a codepoint to a glyph id with no outline. Loading the glyph withFT_LOAD_NO_SCALEand checkingformat == FT_GLYPH_FORMAT_OUTLINEis the proof. This is the verification the §4.2 "renders in Acrobat, Preview, and Chrome" gate reduces to when those viewers aren't available in the test environment; the real viewer check remains a release gate (§8.3 consumer-compatibility matrix). -
The §4.2 "never mutate in place" rule is honoured by construction.
hb_subset_or_failreturns a new face; the originalfaceis only read. This keeps undo cheap and prevents one page's edit from breaking another's rendering — exactly the property §4.2 requires.
What this means for the project
- Font subset growth is feasible and cheap. The hardest practical problem in PDF editing (§4.2) has a working solution for the common case: the full font is installed and we grow the subset. The M0 exit criterion for Spike C is met.
- Remaining §4.2 work, not covered by this spike:
- The substitution ladder steps 2–4 (font-service fallback, panose-based
substitution, refuse on
fsTyperestricted) — needed when the full font is not installed. - Rewriting
ToUnicodeCMaps for re-subset fonts (searchability depends on it). - Type3 fonts, CID/CJK ordering, vertical writing, variable fonts.
- The
fsTypecheck at the embedding call site (§2.4, §11) — a license violation risk, must be enforced with no bypass path.
- The substitution ladder steps 2–4 (font-service fallback, panose-based
substitution, refuse on
- The next font-related work is wiring the subset-growth path into the
production L3 emitter (M4) and adding the
fsTypegate.
Reproducing
cmake -S . -B build/manual -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build/manual --target spike_c_subset_growth
build/manual/bin/spike_c_subset_growth # uses DejaVu Sans by default
build/manual/bin/spike_c_subset_growth /path/to/some.ttf
Exit 0 if the gate is met, 1 otherwise. Also verified clean under ASan+UBSan.