nigig-org/REVIEWS/adr/0031-pdf-glyph-outlines.md
andodeki f37197781e
Some checks failed
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
feat(pdf): glyph outlines from TrueType and CFF, and glyph-aware text runs
`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.
2026-08-18 19:44:18 +00:00

6.7 KiB
Raw Permalink Blame History

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 CFF endchar is 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