# 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: ```rust 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 |