36 KiB
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
.texamendment. 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 underreq: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_bandon 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 onresolved.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:21normalizes before length-prefixing andenvdecode.rs:185-190rejects withEnvelopeDecodeError::NotNfc— but that path covers only strings the envelope encodes directly, andencode.rs:21names the one it means: "The single text field (a transaction label)". - Every score-bearing payload instead embeds the core codec's bytes
wholesale:
SetMetadataOppushesself.metadata.canonical_bytes()(payload.rs:1360),CreateStaffOpthe staff's (payload.rs:1438),CreateInstrumentOpthe 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
Scoreequality is byte-exact on itsStringfields, 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:
-
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).
-
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.
-
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_bandexclusion 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
PrimitiveRefvariant, andGlyphReferenceis already an openCow<'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
Provenanceper 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
TextShapingIdentityis 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:
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
ClusterMapand everyShapedSegment::sourcebound is a valid UTF-8 boundary intext. - 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.
ClusterMapcarries, 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-stabilitymakes the solver's determinism an obligation at fixed version. -
Cost 2: F7's blast radius in full — fourth array, fourth
PrimitiveRefvariant, 19 exhaustive matches, plus the.texamendment (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_boxtherefore stops being an unshaped estimate and becomes a solver policy overbounds— 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 whatsegmentssays.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:
-
ResolvedTextas in §3E — source string, shaping identity, itemized positioned segments, cluster/caret map, measured bounds and reservation — as a fourth primitive array and a fourthPrimitiveRefvariant, with the.texamendment toreq:layoutir:resolved-primitivesand the §"ResolvedLayoutIR" listing (parallel-track work; this document is the request). -
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.
-
No ambient or unresolved host-font lookup. The rule, in the form the
.texshould 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.
-
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. -
v1 scope: the text that exists. Instrument/staff names at system left, the title block from
ScoreMetadata, andTextLineDefinition.texton 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 syntheticResolvedTextfixtures, which need no model work and no schema major. -
Design for itemization from day one even though v1 populates one segment.
segmentsis aVecandTextShapingIdentitycarries 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. -
Name F6's NFC gap as core-track work. The text primitive must not document its
textfield 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.
- 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. - 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.
- 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.
- 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
Stringdirectly), caret stops are grapheme-cluster boundaries carrying bidi affinity, and the Unicode version that defines that segmentation is part ofTextShapingIdentity— 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 canonicalClusterMaps, 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. - 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.
- The
.texamendment 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, whichreq:layoutir:resolved-primitivesalready added on those terms. - 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.
- 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.
- Lyrics do not jump the queue. Title and instrument text give real
model-backed coverage; bidi and fallback are covered by synthetic
ResolvedTextfixtures during the spike.