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

128 lines
6.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 |