107 lines
4.9 KiB
Markdown
107 lines
4.9 KiB
Markdown
<!--
|
||
SPDX-License-Identifier: GPL-3.0-or-later
|
||
SPDX-FileCopyrightText: 2025 ai-ad4 and the FreePDFEditor contributors
|
||
-->
|
||
# 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:
|
||
1. Build the "originally embedded" subset of DejaVu Sans containing only
|
||
the glyphs for "Hello" (codepoints H, e, l, o).
|
||
2. 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").
|
||
3. 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
|
||
|
||
1. **hb-subset growth works as designed.** Adding a codepoint to the input's
|
||
unicode set and re-running `hb_subset_or_fail` produces 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.
|
||
|
||
2. **HarfBuzz's subset API changed across versions.** The
|
||
`HB_SUBSET_SETS_DROP` set name used in older HarfBuzz does not exist in
|
||
10.2; the input's unicode set plus the default `.notdef` retention 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.
|
||
|
||
3. **FreeType's `FT_Get_Char_Index` + `FT_Load_Glyph` is 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 with `FT_LOAD_NO_SCALE` and checking `format ==
|
||
FT_GLYPH_FORMAT_OUTLINE` is 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).
|
||
|
||
4. **The §4.2 "never mutate in place" rule is honoured by construction.**
|
||
`hb_subset_or_fail` returns a new face; the original `face` is 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 `fsType` restricted) — needed when the full font
|
||
is *not* installed.
|
||
- Rewriting `ToUnicode` CMaps for re-subset fonts (searchability depends on
|
||
it).
|
||
- Type3 fonts, CID/CJK ordering, vertical writing, variable fonts.
|
||
- The `fsType` check at the embedding call site (§2.4, §11) — a license
|
||
violation risk, must be enforced with no bypass path.
|
||
- **The next font-related work** is wiring the subset-growth path into the
|
||
production L3 emitter (M4) and adding the `fsType` gate.
|
||
|
||
## Reproducing
|
||
|
||
```bash
|
||
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. |