epiphany/spec/ANALYSIS_TEXT_RUN_PRIMITIVE...

684 lines
36 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.

# Text-run primitives in the resolved layout IR — analysis and recommendation
**Status:** analysis + recommendation, awaiting ruling.
**Charter:** `PLAN_EDITOR_APP.md` §3.7 (the layout-IR readiness tranche) and
§Ruling A criterion 3, the hard criterion — "shaping, font fallback,
bidi/complex scripts, and metrics consistent between interactive canvas,
SVG/PDF export, hit testing, and the accessibility tree. A stack with no
credible text story is disqualified regardless of vector performance."
**Companion to:** `CONTRACT_EDITOR_T4PRE_IR.md` (W1, landed `dd33b34`) and
`CONTRACT_EDITOR_T4PRE_W2_GLYPHS.md` (W2, landed `24f8c80`). This is W3, the
third and last T4 prerequisite — a decision document, not an implementation.
**Prepared against `main` @ `3b09595`.** Every claim below was checked against
the source; file and line are given for each.
> **Revision 2 (review response).** Draft 1 recommended disposition B —
> a text run carrying only its source string, shaped independently by each
> painter. Review found the argument for it rested on a layout guarantee the
> spec does not make, and that B permits precisely the inconsistency Ruling A's
> criterion 3 makes disqualifying. Both findings are correct and were verified
> against source. §2 is rewritten, **disposition E is added and recommended**,
> B is demoted with its defect stated, and F6's NFC claim is **withdrawn as
> false**. Draft 1's dispositions are retained rather than deleted so the
> rejected options stay on the record with their reasons.
>
> **Revision 3 (second review response).** Disposition E approved
> directionally; four specification-tightening findings incorporated before the
> `.tex` amendment. E's identity and segment types are now **fully specified**
> rather than opaque, with the invariants the amendment must state (§3E) — the
> governing test being that E's bytes must *actually* determine the ink, which
> revision 2's placeholder types did not achieve. The host-font rule is
> **narrowed** from "no host fonts" to "no ambient or unresolved lookup": an
> updated OS font is a changed *input* under
> `req:solver:within-implementation-byte-stability`, not a determinism
> violation, so revision 2's prohibition was broader than the spec supports and
> would have foreclosed imported faces (§2.1, §4.3). The reservation is
> **re-ordered** to derive from measured bounds rather than precede shaping
> (§3E cost 5). The cluster map gains its canonical invariants and a required
> Unicode segmentation version (§3E, §5.4). Two citation errors fixed.
---
## 0. What this decides, and what it does not
W3 decides **what shape a text run takes in the layout IR, and what part of it
enters the canonical bytes**. Those two answers are what a toolkit spike needs
in order to be conducted honestly, and they are what a wrong early convenience
would foreclose.
W3 does **not** decide the toolkit, the shaper library, the text-engraving
algorithms (title-block layout, lyric spacing, melisma extenders,
chord-symbol alignment), or the operation vocabulary that authors text. Those
are downstream and each is its own tranche.
---
## 1. The facts
### F1 — the model carries almost no text
§3.7 names five categories: "titles, lyrics, chord symbols, rehearsal marks,
instrument names." Three of the five carry **no text in the model at all**:
| category | model type | carries text? |
|---|---|---|
| titles / composer / subtitle / lyricist / arranger / copyright | `ScoreMetadata` (`graph.rs:1502`) | **yes** — six `Option<String>` |
| additional metadata | `MetadataEntry { key, MetadataValue::Text }` (`graph.rs:1494`, `1483`) | **yes** |
| instrument names | `Instrument { name, abbreviation }` (`graph.rs:1544`) | **yes** |
| staff names | `Staff { name, abbreviation }` (`graph.rs:813`) | **yes** |
| text-line spanners (`rit.`, `cresc.`) | `TextLineDefinition { text }` (`graph.rs:1034`) | **yes** |
| part / group / layer / view names | `PartDefinition`, `StaffGroup`, `AnalysisLayer`, `ViewDefinition` (`graph.rs:1612``1638`) | **yes** (non-score-ink) |
| **lyrics** | `LyricLine { id, events }` (`graph.rs:1326`) | **no** — no syllables |
| **chord symbols** | `ChordSymbol { id, anchor }` (`graph.rs:1334`) | **no** |
| **rehearsal marks** | `Marker { id, anchor }` (`graph.rs:1173`) | **no** label |
| analytical annotations | `AnalyticalAnnotation { id, anchor, layer }` (`graph.rs:1283`) | **no** |
| comments | `Comment { id, anchor, resolved }` (`graph.rs:1292`) | **no** body |
Nineteen lines mention `String` in the entire graph module, and none of them is
in a cross-cutting structure. `LyricLine`'s own doc comment says it plainly:
"Baseline: the event references it carries."
**Consequence.** The text-run primitive is not the binding constraint for
lyrics, chord symbols, or rehearsal marks — *their model is empty*, and filling
it is a schema major on the core track, not an IR tranche. What the primitive
*is* the binding constraint for is the text the model already holds: a title
block and instrument names down the left of the first system. That is a
smaller, more honest v1 than §3.7's list implies, and it is a real one.
### F2 — nothing engraves text today, and the spec says so
`epiphany-engrave` contains **zero** references to title, metadata, instrument
name, or lyric (verified by grep over the whole crate). The gap is admitted
normatively in three places:
* `req:layoutir:repeat-render` (`core_spec.tex:10256`): the jump kinds
"draw no Minimal-tier marks — their segno / coda / instruction text **awaits
a text primitive**".
* `logical.rs:179-180`: markers "need a text primitive and belong to a later
tranche."
* `constrained.rs:1543`: volta ending numbers are set in time-signature digit
glyphs because "the Minimal tier has no text primitive."
So this decision is *owed*; three landed tranches have deferred to it by name.
### F3 — the primitive vocabulary is normatively closed at three
`core_spec.tex:10111` lists `ResolvedLayoutIR` with exactly `glyphs`,
`strokes`, `curves`, and `req:layoutir:resolved-primitives` ratifies that
vocabulary ("Beyond glyphs … line-stroke primitives … and cubic-Bézier curve
primitives"). A fourth array is a **spec change**, on the parallel track's
`.tex`, not a code-only change. Whatever this document recommends, the `.tex`
edit is not mine to make.
### F4 — the exclusion precedent, and its exact wording
Two things are already excluded from the resolved layout's canonical bytes, on
one stated ground:
* `vertical_band` on strokes and curves — `req:layoutir:resolved-band-ownership`:
"**it draws nothing**, so two layouts differing only in it are the same
rendered layout."
* `PrimitiveIndices` (W1) — the same argument, recorded on
`resolved.rs:91-94`.
Note the ground precisely. It is *not* "derived data may be excluded." It is
"non-drawing data may be excluded." That distinction is load-bearing in §3.
The positive form of the same rule is the one that decides this document.
`ResolvedLayoutIR::canonical_bytes` is defined as **the layout's rendering
fingerprint**: it "encodes what a conformant renderer draws and what a
conformance claim compares — every primitive's provenance, geometry, style, and
layer" (`resolved.rs:147-153`). Anything that determines ink belongs inside it.
### F5 — the identity precedent, which is the useful one
`GlyphCatalogIdentity` (`glyph.rs:93`) carries the SMuFL version, font id, font
version, and a BLAKE3 `metrics_hash` over every consulted glyph's metrics,
"required for any layout-conformance claim that depends on byte-equal output
across runs" (`glyph.rs:90`). The layout does not pretend its bytes are
font-independent; it **names the font inputs inside the bytes**, so the
byte-equality claim is conditional and honest.
The same discipline appears at the toolchain level in W2's neighbour:
`font_subset_generated.rs:13-15` records that "the binary subset's exact bytes
depend on the fontTools version recorded here, so regeneration is reproducible
per version."
This is the model a text-shaping identity follows — **and must exceed in one
respect**. `metrics_hash` covers "every consulted glyph's metrics (bounding box,
advance width, and named anchors)" (`glyph.rs:9-12`): it pins *spacing*, not
*shape*. That is sufficient for music glyphs, whose outlines are separately
locked by W2's byte-exact round trip and the golden PNGs. It is **not**
sufficient for a text face, whose outlines are what the run's ink is. A text
face identity therefore hashes the **font file's bytes**, not its metrics; and
the pinned source SHA recorded in a generated file's header
(`font_subset_generated.rs:12`) does not substitute, because it does not enter
the layout bytes. `req:solver:within-implementation-byte-stability` anticipates
this precisely — its identical-inputs list says font metrics are "referenced by
version **and content hash**" (`core_spec.tex:13115-13116`).
### F6 — NFC is **not** guaranteed for score text (draft 1's claim withdrawn)
Draft 1 asserted that all score text arriving through operations inherits an
NFC guarantee. **That is false**, and the correction matters because the
primitive's `text` field cannot be documented as "NFC, verbatim from the graph."
* `req:determinism:unicode-canonicalization` (`core_spec.tex:15164`): canonical
text fields MUST be UTF-8 in NFC; identity comparison MUST be byte comparison
of NFC UTF-8; locale MUST NOT affect parsing, serialization, or key sorting.
* The envelope's own string reader does enforce it — `encode.rs:21` normalizes
before length-prefixing and `envdecode.rs:185-190` rejects with
`EnvelopeDecodeError::NotNfc` — **but that path covers only strings the
envelope encodes directly**, and `encode.rs:21` names the one it means: "The
single text field (a transaction label)".
* Every score-bearing payload instead embeds the **core codec's** bytes
wholesale: `SetMetadataOp` pushes `self.metadata.canonical_bytes()`
(`payload.rs:1360`), `CreateStaffOp` the staff's (`payload.rs:1438`),
`CreateInstrumentOp` the instrument's (`payload.rs:1467`), and the
cross-cutting values the spanner's (`payload.rs:831`).
* The core codec **deliberately preserves non-NFC strings**: "Strings are
length-prefixed UTF-8 and are **not** NFC-folded here: the graph's `Score`
equality is byte-exact on its `String` fields, so the codec preserves them
exactly" (`codec.rs:24-27`).
So operation-authored score text can be non-NFC today, and the graph codec will
faithfully round-trip it. This is a **live compliance gap against
`req:determinism:unicode-canonicalization`, independent of this tranche** — it
exists whether or not a text primitive lands. The text primitive merely makes
it visible, because a run's string would flow from the graph into the rendering
fingerprint.
**W3's requirement:** the text primitive MUST NOT document its `text` field as
NFC unless something enforces it. Either the graph/payload boundary validates
(rejecting non-NFC score text at authoring, matching the envelope's own
discipline) or the projection into `ResolvedText` rejects. This is a finding
for the core track, and it is named here so its absence is a decision.
### F7 — blast radius, measured
| surface | sites | note |
|---|---|---|
| `.curves` references (proxy for "handles all three arrays") | layout-ir 26, engrave 21, render-svg 6, testkit 3 | a fourth array touches each |
| `PrimitiveRef::` matches | 19, across 3 files (`hittest.rs`, `engrave/lib.rs`, `testkit/editloop.rs`) | a fourth variant is exhaustive-match work |
| `epiphany-editor-core` | **0** `.curves`, 4 `.glyphs`, 1 `hit_test_map` | editor-core consumes the hit-test map, not the arrays |
The last row is the good news and should be stated carefully: editor-core is
nearly insulated, because W1's seam and the hit-test map already stand between
it and the primitive arrays. The genesis track's own lesson cuts the other way
and is worth quoting — adding an `OperationKind` variant proved *not*
containable to core+ops, with four downstream literal sites and editor-core
blocking the whole gate. A fourth primitive array should be assumed to behave
the same until a census says otherwise.
### F8 — SVG has a font-**embedding** precedent, not a text pipeline
Draft 1 overstated this. What exists: `render-svg` emits music glyphs either as
inline outline `<path>` or as `<text>` referencing an `@font-face`-embedded
Bravura **subset**, base64 in a data URI (`svg.rs:311-317`, `svg.rs:438`).
What that machinery actually does is narrower than "text export works":
* the subset is **static and build-time**, generated once by
`tools/extract_bravura_outlines.py` (`font_subset_generated.rs:1`), not
produced per document from the glyphs a score uses;
* each `<text>` element carries **one already-positioned SMuFL codepoint**
(`svg.rs:438`) — the position comes from the resolved layout, not from
shaping a string.
So the `@font-face` embedding mechanism is genuinely reusable, and the license
handling (Reserved Font Name, pinned source SHA, per-version reproducibility) is
a template worth copying exactly. **Shaping and document-specific subsetting do
not exist and are new work.**
### F9 — what the spec actually guarantees about layout determinism
This is the fact draft 1 got wrong, and it inverts the argument.
The determinism summary table (`core_spec.tex:613-631`) distinguishes tiers:
| tier | guarantee |
|---|---|
| Canonical score determinism | "Identical operation sets produce identical materialized score states **across all conforming implementations**" |
| **Layout determinism** | "**Within one solver implementation at a fixed version: byte-equal output** for identical input. **Across implementations: reference-suite thresholds, not byte equal.**" |
| Conformance determinism | "Reference-suite-based. **Cross-implementation byte equality is not required for layout.**" |
| Non-canonical caches | "May vary freely across runs, platforms, implementations." |
`req:solver:cross-implementation-conformance` (`core_spec.tex:13142`) says it
normatively: different conforming solvers MAY produce different layouts;
conformance is hard-constraint satisfaction, internal determinism,
well-formed `SolveReport`, and per-tier quality thresholds on the reference
suite.
**Therefore shaping before the canonical boundary is not inherently
non-conformant.** A fixed solver at a fixed version, with pinned font inputs
and a declared shaping identity, is exactly what "within one implementation at
a fixed version" contemplates. Draft 1 imported the *score* layer's
cross-implementation guarantee into the *layout* layer, where the spec
deliberately weakens it, and then designed around a constraint that does not
exist.
---
## 2. The actual problem
With F9 corrected, the problem is not "shaping poisons a cross-implementation
byte-equality claim." It is three narrower things, and they pull in the
opposite direction from draft 1:
1. **Within-implementation determinism is a real requirement, and *ambient*
font lookup defeats it.** `req:solver:within-implementation-byte-stability`
(`core_spec.tex:13106-13124`) obliges a solver at a fixed version to produce
byte-identical output for identical inputs — and it *defines* identical
inputs to include "identical font and glyph metrics (**referenced by version
and content hash**)" (`core_spec.tex:13115-13116`).
Note what that does and does not say, because revision 2 also overstated it.
An OS font update is a **changed input**, not a determinism violation — the
obligation is conditioned on identical font inputs, so a solver whose font
changed is not obliged to reproduce its old output. Host fonts are therefore
not inherently disqualifying. What *is* disqualifying is **ambient or
unresolved lookup**: a chain that names "whatever the system calls Helvetica"
has no content hash, so the input is unnamed, the layout is unportable, and
no consumer other than the one machine can reproduce the ink. The rule to
write is narrower than "no host fonts" (§4.3).
2. **Criterion 3 requires *consistency across the four consumers*, and only a
shared shaping result delivers it.** Canvas, SVG/PDF export, hit testing,
and the accessibility tree must agree on metrics. If each consumer shapes
independently they agree only by coincidence of library and version.
3. **The fingerprint must mean what it says.** `canonical_bytes` "encodes what
a conformant renderer draws" (`resolved.rs:147-153`, F4). Any design where
two layouts with identical bytes legitimately produce different ink breaks
that definition — and the W1/`vertical_band` exclusion rule, whose whole
justification is the contrapositive, loses its footing.
Accessibility adds a fourth, orthogonal requirement: the **source string** must
survive into the IR, because a screen reader needs text, not outlines.
The design question is therefore not "shaped or unshaped" but **"how do we
carry both the shaped result and the source, with the shaping inputs declared."**
---
## 3. Dispositions
### A — no text primitive; text is anonymous glyph runs
Reuse `ResolvedGlyph`, one per shaped glyph, with `GlyphReference` naming a
text font's glyph. The engraver shapes; the IR carries only the result.
* **Cost 0 (structural):** nothing new — no fourth array, no `PrimitiveRef`
variant, and `GlyphReference` is already an open `Cow<'static, str>`
(`glyph.rs:55`), so it can name non-SMuFL glyphs today.
* **Cost 1 (fails criterion 4, fatal):** the source string is gone, so the
accessibility tree has no text to expose. A title rendered as eleven
anonymous outlines is invisible to a screen reader, and criterion 4
explicitly rejects "toolkit carries a tree while the canvas is unusable."
* **Cost 2:** no caret, no character selection, no find-in-score — hit testing
yields glyph boxes with no relation to source offsets.
* **Cost 3:** one `Provenance` per glyph; a 40-character title costs 40
provenance records where one run would cost one.
**Refused.** Not because shaping is early — F9 says early shaping is legitimate
— but because discarding the source is unrecoverable.
### B — a text run carrying only the source string; each painter shapes
`ResolvedText { provenance, text, font, size, origin, align, reserved_box,
shaping_context, layer }`, with no shaped output. Canvas, exporter, and a11y
tree each shape independently.
**This was draft 1's recommendation. It is withdrawn.** Its defect is §2.2 and
§2.3 together: it *knowingly* permits canvas and `resvg` to place glyphs
differently, which is not what Ruling A's tolerance grants. That tolerance
covers **rasterization** differences downstream of equivalent scene geometry —
antialiasing, curve flattening — not different advances, different clusters,
different fallback faces, or different line geometry. Criterion 3 separately and
explicitly demands consistent *metrics*. And under B, two layouts with identical
canonical bytes can legitimately produce different ink, which contradicts the
fingerprint's own definition.
Two further defects surfaced in review:
* **A `TextShapingIdentity` is required under B too**, not only under C.
Reporting a host fallback substitution tells a user something changed; it does
not make the output reproducible or make two consumers agree.
* **One font and one `(direction, script, language)` context cannot represent
the spike's own cases** (§5). Mixed Arabic/Latin requires itemization into
multiple directional and script runs; fallback produces segments in multiple
faces; feature selection is named in §2 as a shaping input but appears in no
field; and automatic itemization needs a declared Unicode/bidi algorithm
version. "The painter must not re-guess" and "one mixed-direction run" cannot
both hold with that shape.
### C — B plus a shaped cache excluded from the canonical bytes
Retained for the record; superseded by E. Its distinctive move was to exclude
derived-but-drawing data from the fingerprint, which F4's precedent does not
license ("non-drawing", not "derived"). E resolves this by **including** the
shaped result instead, which needs no new exclusion doctrine at all.
### D — defer; the spike uses the toolkit's own text and the IR gets nothing
* Criterion 3 is a *hard* criterion. A spike that never renders score text
cannot disqualify a stack for having no text story — the one thing criterion
3 exists to do.
* It is the foreclosure Ruling A was written to prevent: pick a stack on vector
performance, discover its text pipeline cannot serve export and the a11y
tree, and the ruling has failed silently.
### E — the shaped run: source string **and** canonical positioned glyphs
The disposition draft 1 omitted. One primitive carrying both halves:
```rust
pub struct ResolvedText {
pub provenance: Provenance,
/// The source string, carried verbatim for accessibility, search,
/// and editing. NOT documented as NFC until F6's gap is closed.
pub text: String,
/// Every shaping input, declared — the `GlyphCatalogIdentity`
/// discipline (F5) applied to text.
pub shaping: TextShapingIdentity,
/// The shaped result: the definitive geometry every consumer draws.
pub segments: Vec<ShapedSegment>,
/// Source offsets to glyph clusters and caret positions, so hit
/// testing and a caret index into the string rather than the ink.
pub clusters: ClusterMap,
/// What shaping actually produced, and what the solver allocated.
/// `reserved_box` is a solver *policy* over `bounds` (padding, a
/// minimum allocation) — never an unshaped estimate. See below.
pub bounds: BoundingBox,
pub reserved_box: BoundingBox,
pub origin: Point,
pub align: TextAlign,
pub style: GlyphStyle,
pub layer: i32,
}
/// Every input that determines the ink, named so the fingerprint means
/// what it says. Modelled on `GlyphCatalogIdentity` (F5) but *stronger*:
/// that type hashes metrics, and metrics do not determine outlines.
pub struct TextShapingIdentity {
/// The ordered chain, tried in order. Resolution is closed over this
/// list: a codepoint no listed face covers is a reported failure,
/// never an ambient host lookup (§4.3).
pub faces: Vec<TextFaceIdentity>,
/// Shaper implementation and version — it moves glyphs, so it is an
/// input on exactly the footing of the font version.
pub shaper: ShaperId,
pub shaper_version: SemVer,
/// The OpenType feature set applied, in canonical order.
pub features: Vec<FeatureSetting>,
/// The Unicode version whose bidi algorithm itemized the run, and
/// whose grapheme segmentation defines the caret stops. Present
/// whether or not itemization was automatic: segmentation is
/// version-dependent even when direction is declared by hand.
pub unicode_version: UnicodeVersion,
}
pub struct TextFaceIdentity {
/// Human-facing name — diagnostic only, never an input.
pub family: FontId,
pub version: Option<SemVer>,
/// **The identity that matters**: a content hash over the exact font
/// file's bytes. A family/version pair does not pin outlines, and a
/// SHA recorded in a build script does not enter these bytes.
pub file_hash: [u8; 32],
/// Which face within a collection (`.ttc`/`.otc`).
pub face_index: u32,
/// Variable-font axis coordinates, canonical-ordered by axis tag;
/// empty for a static face.
pub variations: Vec<(AxisTag, CanonicalF64)>,
/// Synthetic weight/slant applied when the face lacks the style —
/// it changes the ink, so it is not a renderer's private choice.
pub synthesis: FaceSynthesis,
}
pub struct ShapedSegment {
/// Which face in `TextShapingIdentity::faces` this segment resolved
/// to — an index, so the identity is stated once.
pub face: u32,
/// The glyph ids are **font-internal ids in that face's namespace**,
/// meaningless without `TextFaceIdentity::file_hash`.
pub glyphs: Vec<PositionedGlyph>,
/// The half-open source range this segment covers, in UTF-8 byte
/// offsets into `ResolvedText::text`.
pub source: Range<u32>,
pub direction: TextDirection,
pub script: ScriptTag,
pub language: LanguageTag,
/// Em size in staff spaces. Positions below are in the same
/// staff-space, y-up frame as every other resolved primitive, and
/// quantize to the 1/1024 grid identically.
pub size: StaffSpace,
}
pub struct PositionedGlyph {
pub glyph_id: u32,
/// Offset from the run's `origin`, **with alignment already applied**
/// — a consumer places the run by `origin` alone and never re-derives
/// from `align`, which is retained as a record of the decision.
pub offset: Point,
pub transform: Option<Transform2D>,
}
```
**Invariants the `.tex` amendment must state, not merely imply:**
* Every offset in `ClusterMap` and every `ShapedSegment::source` bound is a
**valid UTF-8 boundary** in `text`.
* Segment source ranges **cover the whole string** and do not overlap
invalidly — visual order may differ from logical order under bidi, but the
logical partition is total.
* `ClusterMap` carries, per cluster: its source range, its glyph indices, and
its caret stops — each stop with a **geometric position and a bidi affinity**,
so a caret at a direction boundary is unambiguous.
* A cluster that shaping could not resolve is represented **diagnostically**
(an explicit unresolved marker), never dropped — a dropped cluster is a
silent divergence between the string and the ink.
* Positions are staff-space y-up, quantized on the same 1/1024 grid as glyph
positions (`resolved.rs:11-13`), so text quantization is not a second
convention.
**The positioned result enters the canonical bytes.** Canvas, SVG/PDF export,
and hit testing consume that one result; accessibility consumes the source
string. Tessellated meshes remain the genuinely discardable paint cache — the
thing that legitimately varies per platform under the determinism table's
"non-canonical caches … may vary freely across runs, platforms,
implementations" (`core_spec.tex:629-631`).
**No consumer reshapes — and SVG export cannot honour that with `<text>`.**
This is a concrete consequence worth stating before the `.tex` amendment,
because it constrains the exporter. `ShapedSegment` carries font-internal glyph
ids, and an ordinary SVG `<text>` element cannot request one: it carries
*characters*, and the viewer's own shaper picks the glyphs. For anything
contextual — a ligature, a positional Arabic form, a `locl` substitution —
`<text>` will silently draw a different glyph than the one the layout resolved.
So conformant text export must use **explicit-glyph representation**: outlines
looked up from the same face (the `epiphany-glyphs` seam W2 built, extended to
text faces) and emitted as `<path>`, exactly as `GlyphMode::PathOutline` already
does for music glyphs. The existing `@font-face` mode remains available only
where the run is provably non-contextual, and that is an optimization, not the
contract. PDF is easier — it addresses glyphs by id natively — but the rule is
the same: draw the resolved glyphs, never the string.
Why this is the right shape:
* **It matches the existing resolved-stage contract.** The resolved IR is where
"every glyph has a definitive position" (`resolved.rs:3`). Text with
undetermined positions would be the one primitive that isn't resolved at the
resolved stage.
* **It satisfies criterion 3 by construction**, rather than by hoping two
shapers agree.
* **It keeps the fingerprint honest** in the direction the fingerprint is
actually defined: bytes determine ink.
* **It preserves accessibility**, which is exactly what disposition A threw
away — so the apparent dilemma between deterministic geometry and
accessibility was false, and it was false because of §2's corrected framing.
* **It needs no new exclusion doctrine**, unlike C.
Costs, honestly:
* **Cost 1:** the engraver gains a shaper dependency. That is real weight in a
crate that currently has none, and it must be a pinned, vendored-or-locked
version because `req:solver:within-implementation-byte-stability` makes the
solver's determinism an obligation at fixed version.
* **Cost 2:** F7's blast radius in full — fourth array, fourth `PrimitiveRef`
variant, 19 exhaustive matches, plus the `.tex` amendment (F3).
* **Cost 3:** the canonical encoding grows a substantially richer primitive
than a glyph, and every field of it is fingerprint-visible. A shaper upgrade
moves the bytes — which is correct behaviour (it moves the ink) but means the
shaper version is now a conformance-relevant input, exactly as the font
version already is (F5).
* **Cost 4:** a text-font asset decision, with the full Bravura treatment
(pinned source, SHA, license, RFN handling, subsetting-tool version).
* **Cost 5 — and a pipeline-ordering consequence revision 2 got wrong.**
Draft 1 and revision 2 both had the solver *reserve space before shaping* and
report overflow afterwards. That was a workaround for B's missing shaper, and
it does not survive E: once a canonical shaper lives inside the engraving
pipeline, **shaping and itemization run before the spacing constraints that
depend on text extents are solved**. `reserved_box` therefore stops being an
unshaped estimate and becomes a **solver policy over `bounds`** — padding, a
minimum allocation, a column quantum — derived from measurement, not guessing
at it. Both are kept because the policy is a real decision worth recording and
worth changing independently. Paint-time re-spacing remains forbidden either
way: a painter draws what `segments` says.
The residual cost is ordering pressure on the engraver: text extents now
participate in horizontal spacing, so the pipeline gains a shaping pass ahead
of the constraint solve. That is the honest price of consistent metrics, and
it is smaller than it looks because v1's text (titles, instrument names) sits
outside the note-spacing problem.
---
## 4. Recommendation
**Take E. Refuse A explicitly. B, C, and D are recorded as considered and
rejected for the reasons above.**
Concretely:
1. **`ResolvedText` as in §3E** — source string, shaping identity, itemized
positioned segments, cluster/caret map, measured bounds and reservation — as
a fourth primitive array and a fourth `PrimitiveRef` variant, with the
`.tex` amendment to `req:layoutir:resolved-primitives` and the
§"ResolvedLayoutIR" listing (parallel-track work; this document is the
request).
2. **The canonical rule:** *a text run's shaped result is canonical layout
geometry and enters the rendering fingerprint, together with the complete
shaping identity that produced it. The source string is carried alongside it,
never in place of it.* Draft 1's proposed rule — that any pre-canonical
shaping is non-conformant — is **withdrawn**; F9 shows the spec does not
support it.
3. **No ambient or unresolved host-font lookup.** The rule, in the form the
`.tex` should carry it:
> A host font MAY participate in a conformant fallback chain **only after
> resolution to an exact, content-hashed asset that every consumer can
> obtain**, named in the run's `TextShapingIdentity`. Ambient lookup — any
> chain entry naming a face the layout does not pin by content hash — is
> non-conformant. A codepoint no resolved face covers is a **reported**
> failure, never a silent substitution.
This supersedes both draft 1's blanket prohibition on pre-canonical shaping
*and* revision 2's blanket prohibition on host fonts. It protects
portability, export consistency, and the goldens, without foreclosing
imported or user-supplied faces — which a notation product that opens other
people's documents will need. What it costs: a document that resolves a
user's local face is only portable if that asset travels with it, which
makes font embedding in the bundle a real downstream question. Named here,
not answered.
4. **The shaped result is drawn, never re-derived.** No consumer reshapes, and
SVG export therefore emits explicit glyphs (outlines from the same face),
not `<text>` — see §3E. This is the rule that makes criterion 3's
"consistent metrics" mean something operational.
5. **v1 scope: the text that exists.** Instrument/staff names at system left,
the title block from `ScoreMetadata`, and `TextLineDefinition.text` on
spanners — model-backed, Latin, one pinned face. Lyrics, chord symbols,
rehearsal marks, analytical annotations, and comments are **blocked on the
model, not on this decision** (F1). Bidi and fallback are exercised in the
spike through **synthetic `ResolvedText` fixtures**, which need no model work
and no schema major.
6. **Design for itemization from day one even though v1 populates one segment.**
`segments` is a `Vec` and `TextShapingIdentity` carries an ordered chain and
a feature set from the first commit. This is the foreclosure guard for this
tranche: retrofitting itemization means touching every producer and consumer
simultaneously, and it is nearly free to honour now.
7. **Name F6's NFC gap as core-track work.** The text primitive must not
document its `text` field as NFC until the graph/payload boundary validates
or projection rejects.
---
## 5. What the T4 spike must then demonstrate (criterion 3, made testable)
Under E the spike's job changes: the shaping happens in the engraver, so the
spike is testing whether a candidate stack can **faithfully consume a shaped
result** — a materially easier and more decisive test than asking each stack to
shape correctly.
1. **Faithful consumption:** the candidate canvas draws a `ResolvedText`'s
positioned segments at the given positions without re-shaping, and matches
the SVG exporter's rendering of the same run under Ruling A's bounded visual
differential — which is now a *rasterization* comparison of identical
geometry, which is what that tolerance was always for.
2. **Fallback, forced:** a run whose string needs a face beyond the first in the
declared chain resolves through the **declared chain only**, and a codepoint
no declared face covers is reported, not silently substituted from the host.
3. **Bidi:** a mixed Arabic/Latin run itemizes into multiple directional
segments, each drawn in its resolved face at its resolved position. Synthetic
fixture; no model work required.
4. **Hit testing at character granularity, against the stated index contract.**
Click inside a run and recover a source index. The contract, now pinned:
**UTF-8 byte offsets** are the base index (they address the stored `String`
directly), **caret stops are grapheme-cluster boundaries carrying bidi
affinity**, and the **Unicode version that defines that segmentation is part
of `TextShapingIdentity`** — always, not only when itemization is automatic,
because grapheme boundaries move between Unicode versions even for a run
whose direction was declared by hand. Without that, two implementations could
agree on every pixel of ink and still produce different canonical
`ClusterMap`s, which would put a divergence inside the fingerprint that no
visual test can see. The current map has one geometric region per primitive
and no cluster data (`hittest.rs:303-310`), so this is new surface either
way.
5. **Accessibility:** the run appears in the accessibility tree as its **source
string**, not as a graphic — the check disposition A fails outright.
A stack that cannot do 2 and 5 is disqualified regardless of tessellation
throughput.
---
## 6. The four open rulings — answered
Recorded from review; no longer open.
1. **The `.tex` amendment proceeds now**, as a non-document-canonical layout-IR
addition. It changes the layout fingerprint but requires no bundle/wire
schema-major move — it follows strokes and curves, which
`req:layoutir:resolved-primitives` already added on those terms.
2. **Every bundled text face gets the full treatment** — pinned source, hash,
license, Reserved Font Name handling, version, subsetting-tool version. **Do
not select a face until the ordered fallback-chain representation is
settled**, since the chain is what the face must slot into.
3. **Accept text-bearing golden exposure, and include at least one controlled
text golden.** Keeping all text out of the goldens would remove precisely the
regression tripwire the goldens exist to provide. The house rule stands: a
moved text golden is re-reviewed visually, never re-blessed to pass.
4. **Lyrics do not jump the queue.** Title and instrument text give real
model-backed coverage; bidi and fallback are covered by synthetic
`ResolvedText` fixtures during the spike.