`sfnt.rs` read the metric tables and nothing else. It could say how wide a glyph was and not what shape it had, so every renderer drew embedded text with a substitute font at the correct advance — the failure mode that looks most like success: the line breaks land right and the letterforms belong to somebody else. `outline.rs` returns one outline type for both formats. TrueType quadratics are degree-elevated to cubics, which is exact, so no format detail leaks to a consumer. Composite glyphs are placed by their offsets and scales, with a depth bound because a font can reference itself. CFF Type 2 charstrings run through an interpreter with biased local and global subroutines, hints, hintmask byte counting, the leading width operand, and the FontMatrix as declared rather than assumed to be 1/1000. Separately, `ShowTextWithMetrics` carried one advance for a whole run — enough to move the pen to the next run and nothing else. So `text.rs` guessed: `seg.advance / char_count`. For "Wi" that puts the boundary between the letters at 5 when it is at 9, and every caret, drag-selection and search highlight in the application was wrong by that much for every proportional font. `GlyphPlacement` now carries per-glyph pen offsets, computed with the same expression as the run total so the two cannot drift. The even-spacing fallback stays for fonts with no width table, which is what `advance_is_measured` has always been for. The fixture story is ADR 0029's, again. `cff_sample.otf` is a fontTools conversion of DejaVu: no subroutines, no hints, no width operands. It proved the interpreter draws the right shapes, and then four mutations of that interpreter survived because nothing in the corpus reached the code they broke — each of which produces a plausible wrong glyph from a font that parses. `cff_subrs.cff` is hand-assembled for exactly those four, and fontTools agrees with every expectation asserted against it. A fifth mutation survived a composite test that counted contours; it is killed now by one that measures where the components land. Coordinates are asserted against fontTools ground truth, not against our own output. Seven mutations, all killed. 1397 tests pass. Deferred and recorded, not claimed: CID-keyed CFF, `seac` accents, rendering outlines through the Makepad device. ADR 0031.
6.7 KiB
ADR 0031: glyph outlines and glyph-aware text runs
- Status: Accepted
- Date: 2026-08-18
- Review item:
NIGIG_PDF_FEATURE_PARITY_PLAN.md§1 Phase 7, "Embedded-font rendering: glyph outlines from embedded TrueType and CFF" and "Glyph-aware text runs through the device trait" - Related: ADR 0019 (document creation, subsetting), ADR 0016 (the JPEG decoder that was a stub), ADR 0029 (mesh shadings — same lesson)
Context
sfnt.rs read head, hhea, hmtx and OS/2. It could say how wide a
glyph was and not what shape it had. Every renderer in the stack therefore
drew embedded text with a substitute font at the correct advance — the
failure mode that looks most like success, because the line breaks land
right, the page measures right, and the letterforms belong to somebody else.
Separately, RenderCommand::ShowTextWithMetrics carried one advance for a
whole run. That is enough to move the pen to the next run and nothing
else: it cannot say where the fourth character of "Hello" is. So
text.rs guessed, twice:
let per_char = seg.advance / total;
Even spacing. For "Wi" at 10 units total, that puts the boundary between the letters at 5 when it is really at 9. Every caret placement, selection drag and search highlight in the application was wrong by that much, for every proportional font — which is nearly all of them.
Decision
One outline type for both formats
outline.rs returns GlyphOutline — contours of lines and cubics —
for TrueType glyf and for CFF Type 2 charstrings alike. TrueType's
quadratics are degree-elevated to cubics, which is exact arithmetic and not
an approximation, so a consumer sees one curve type and no format leaks
through.
Design units, not text space. Dividing by units_per_em is the caller's
job because the caller knows the font size; baking a scale in makes an
outline silently wrong at any other size.
Empty is not an error
A space has start == end in loca and an empty charstring in CFF.
is_blank() is Some(empty), never Err. Collapsing the two makes a
broken parser look like a font full of spaces.
Glyph placements, computed once
GlyphPlacement { code, offset_x, offset_y, advance } rides on
ShowTextWithMetrics, and TextSegment::glyph_offsets carries it into
text.rs. The advances use the same expression as
RecordingDevice::measured_advance, so the offsets always sum to the run's
total. Two independent calculations would drift, and the drift would show up
as a caret sliding out of the text.
The even-spacing fallback stays for fonts with no width table — there are no
offsets to use there — and advance_is_measured is what tells a caller
which of the two it got.
What the corpus had to gain
cff_sample.otf, the existing fixture, is a fontTools conversion of DejaVu:
no subroutines, no hints, no width operands. It proved the interpreter
draws the right shapes, and then four mutations of that interpreter
survived, because nothing in the corpus reached the code they broke:
| Mutation | What it does to a real font |
|---|---|
| Ignore the subroutine bias | Subr 0 is called as −107; the call is out of range and the glyph comes back empty — indistinguishable from a character the font does not cover |
Skip zero hintmask bytes |
The mask is read as operators; the rest of the charstring is garbage that still draws |
| Do not take the leading width operand | Every following operand shifts by one; the glyph draws in the wrong place |
Do not double a short loca offset |
The contour count is read from the middle of the previous glyph |
cff_subrs.cff is hand-assembled for exactly those four — a local subr, a
global subr, two stem hints with a hintmask, and a width difference —
and fontTools agrees with every expectation the tests assert. A fifth
mutation, dropping composite-glyph offsets, survived a test that counted
contours; it is now killed by one that measures where the components land.
This is ADR 0029's lesson a second time, and it is now a rule: a parser whose fixture never reaches a branch has not been tested on that branch, however green the suite is.
Consequences
- Embedded TrueType and CFF glyphs can be drawn as paths. Coordinates are asserted against fontTools ground truth, not against our own output.
- Hit-testing, selection and search highlighting use real glyph positions.
- Not done here: rendering those outlines through the Makepad device,
and CID-keyed CFF (
FDArray/FDSelect, used by the CJK fonts). Both are recorded as unfinished rows in the Phase 7 table rather than described as done. seac-style accent composition in CFFendcharis not implemented; the base glyph draws and the accent does not.
Merge criteria
Enumerated from the plan bullets first, per ADR 0021.
| Criterion | State |
|---|---|
TrueType outlines from glyf/loca |
✅ asserted against DejaVu, all 6253 glyphs read |
| Quadratic curves, including implied on-curve midpoints | ✅ two_consecutive_off_curve_points_imply_an_on_curve_midpoint |
Short and long loca |
✅ both, symbol_sample.ttf is short-loca |
| Composite glyphs with offsets and scales | ✅ placement asserted, not just contour count |
| Composite recursion bounded | ✅ MAX_COMPOSITE_DEPTH, refused by name |
| CFF outlines from Type 2 charstrings | ✅ every point of 'H' matches fontTools exactly |
| CFF local and global subroutines, biased | ✅ cff_subrs.cff |
CFF hints and hintmask byte counting |
✅ cff_subrs.cff |
| CFF leading width operand | ✅ cff_subrs.cff, width 50 asserted |
CFF FontMatrix honoured, not assumed 1/1000 |
✅ the sample is 1/2048 |
Bare CFF (a PDF /FontFile3) without an sfnt wrapper |
✅ a_bare_cff_font_program_is_read_without_an_sfnt_wrapper |
CID-keyed CFF (FDArray/FDSelect) |
❌ deferred — not implemented |
seac accent composition |
❌ deferred — base glyph draws, accent does not |
| Per-glyph pen offsets on the text run | ✅ GlyphPlacement on ShowTextWithMetrics |
| Offsets sum to the run advance | ✅ same expression as measured_advance |
| Hit-testing uses real offsets | ✅ a_caret_lands_on_the_real_glyph_boundary |
| Selection and search use real offsets | ✅ two tests; "Wi" is the case even spacing gets wrong |
| Even spacing retained where there is no width table | ✅ and flagged by advance_is_measured |
| Outlines carried on the text run | ❌ deferred — the plan asks for outlines on the run; they are reachable through outline.rs by glyph id, not attached to each placement |
| Rendering outlines through the Makepad device | ❌ deferred — Phase 7 table row |
| Mutation-checked | ✅ 7 mutations, all killed; 5 of them survived first and drove new fixtures |