epiphany/crates/epiphany-render-svg/DECISIONS.md

160 lines
10 KiB
Markdown

# epiphany-render-svg — decisions and Pass 12 candidates
This file records (a) the QUICKSTART decisions Agent I made for the renderer,
and (b) ambiguities batched as **Pass 12 candidates** (`spec/PASS12_BATCH.md`)
rather than improvised in code.
## Scope and status
`epiphany-render-svg` is the SVG renderer behind the Chapter 7 `RenderIR`
interface: it turns a `ResolvedLayoutIR` into well-formed **SVG 1.1**, drawing
glyphs from genuine Bravura SMuFL data either as inline outline `<path>`s
(`GlyphMode::PathOutline`) or as `<text>` set in an embedded subset font
(`GlyphMode::EmbeddedFont`). Per the QUICKSTART development pattern it was
golden-locked against the stub solver's output first, then against the real
`epiphany-engrave` solver once the score→real-notation pass and re-spacing
landed. The renderer consumes any solver's `ResolvedLayoutIR`; it proves
renderer faithfulness — resolved geometry preserved, provenance traced,
output XML-valid and deterministic — independent of engraving quality.
## The non-overreach rule (Chapter 7 / QUICKSTART, Agent I)
The renderer makes **SVG-encoding choices only** and **no engraving-semantic**
choices. In the default **archival** mode every emitted element traces to a
`ResolvedGlyph` (and thus a score-graph source, via `data-prov`/`data-source-kind`)
or to a declared renderer wrapper (the `<svg>` root, the metadata comment, the
y-flip `<g>`, a per-layer `<g>`). Traces can be turned off
(`RenderOptions::emit_provenance = false`) for a smaller **display-only** SVG; that
is an explicit mode the **metadata comment declares** (archival → "carries a
data-prov trace", display-only → "provenance traces suppressed"), so a trace-free
SVG — including the empty canvas — announces itself rather than passing as
archival. A glyph with no bundled outline is **surfaced as a diagnostic** and drawn
as a visible bounding-box fallback `<rect>` — never silently dropped and never
invented. The acceptance harness (archival mode) asserts one drawn element per
glyph and one provenance trace per drawn element.
## Implementation decisions (QUICKSTART "Decisions you'll need to make")
1. **Spelling / 2. Solver architecture** — N/A here (Agent H; `epiphany-engrave`).
3. **Renderer SVG dialect — SVG 1.1 + inline presentation attributes.** Maximum
portability for what is effectively a viewer/print artifact; no CSS, no SVG 2
features. (CSS styling can come later if needed.) Output validates under the
system `xmllint` (libxml2) as well as the in-crate well-formedness checker.
4. **Catalog / 5. Binary Format versioning** — N/A (Agents K, J).
### Local decisions
- **Glyph rendering — two self-contained modes; inline outlines
(`GlyphMode::PathOutline`) is the default and the verified reference.** Path
outlines make the SVG self-contained (it renders in any browser, image tool, or
print pipeline with no font installed) and are byte-golden-locked, at the cost of
file size — the QUICKSTART's recommendation. `GlyphMode::EmbeddedFont` instead
references each glyph by SMuFL codepoint with a `<text>` element drawn from an
`@font-face`-embedded Bravura *subset* (only the ~33 named glyphs), so the SVG is
still self-contained (the font travels in it) and the text is selectable, at a
larger file size. The two modes anchor glyphs at the same origin (em = 4 staff
spaces), so placement is consistent by construction; the embedded mode is
structurally tested rather than byte-golden-locked, and exact rasterisation is
the consumer's font renderer's, so path mode remains the pixel-verified one.
- **Embedded-font subset — generated, not a vendored binary.** The subset is a
deterministic base64 OTF emitted into `src/font_subset_generated.rs` by
`tools/extract_bravura_outlines.py --font-out`, keeping the "only generated
artifacts committed" rule (no font binary is vendored). It retains the font's
OFL copyright/license name records (belt-and-suspenders with `tools/OFL.txt`).
Caveat: unlike the geometry-only outlines, the binary subset's exact bytes
depend on the fontTools version, which the generated header records.
- **Outline source — the official OFL `Bravura.otf`, extracted reproducibly.**
`tools/extract_bravura_outlines.py` fetches the font + SMuFL `glyphnames.json`
and emits `src/outlines_generated.rs`. The font is **not vendored**; only the
generated Rust is committed (`tools/OFL.txt` carries the SIL Open Font License
1.1 under which the outlines are redistributed). Exactly the glyph set the v0
pipeline can name (`layout-ir`'s `BRAVURA_METRICS`) is bundled; a test asserts
every pipeline glyph has an outline (so the table cannot silently fall behind).
- **Coordinate system — staff spaces, y-up, one global flip.** Outlines are
extracted in staff-space units (SMuFL em = 4 staff spaces, Bravura
unitsPerEm = 1000 ⇒ 1 staff space = 250 font units), **y-up** (musical
convention). The whole document is wrapped in one
`translate(-min_x, max_y) scale(1, -1)` group, so every glyph is placed with a
plain `translate(x, y)` and the `viewBox` is `0 0 W H` in staff spaces;
`width`/`height` carry the px display scale. The renderer never bakes the flip
into per-glyph data.
- **Determinism — fixed 4-decimal number formatting, `-0` normalised to `0`.**
Outline `d` data is fixed bundled text; every computed coordinate is formatted
identically, so identical input yields byte-identical SVG. The acceptance
harness golden-locks the full SVG and a machine snapshot (object/glyph/path/
provenance/layer/per-class/hard-constraint counts + XML validity).
- **No external XML dependency.** The workspace is deliberately dependency-light;
`xml::check_well_formed` is a hand-rolled validator for the subset the renderer
emits, and the acceptance test additionally cross-checks with `xmllint` when
present so the "XML-validates" claim rests on a real parser too.
## Pass 12 candidates
See `spec/PASS12_BATCH.md` (rows P12-I1, P12-I2, P12-I3). Most relevant here:
- **P12-I1 (resolved by I-1/I-3)** — the original stub-only renderer output was
a structural placeholder, so the human-review visual-acceptance gate ("the SVG
visually parses as standard music notation") was deferred until real engraving
landed. The real notation pass and real-Engraver goldens now close that gate;
the stub path remains locked as an interface/reference mode, not the visual
deliverable.
- **P12-I2 (resolved)** — the stable layout-object id derivation (`MUSCLOID`,
Pass-11 item 2.6) is wired: `epiphany-determinism` reserves the built-in
`MUSCLOID` tag and `layout-ir` provenance routes through it. The renderer traces
provenance by the (now `MUSCLOID`-tagged) `stable_id`; only the `data-prov` hex in
the goldens changed (the ids are non-canonical).
## Repeat glyphs bundled + `repeat` glyph class (E1, 2026-07-07)
The E1 repeat tranche added `repeatLeft`, `repeatRight`, `repeatRightLeft`,
and `repeatDots` to the extractor's `NAMES` set; outlines, companion
layout-ir metrics, and the embedded font subset were regenerated from the
same SHA-pinned `bravura-1.392` (fontTools 4.63.0 preserved, so the subset
diff is content-only). `GlyphClass` gained a `Repeat` class (token
`repeat`) so snapshots and `data-class` attributes separate repeat signs
from plain barlines. Volta ending numerals arrive as `timeSig` digit glyphs
(there is still no free-text primitive — unchanged).
## Curve primitive → stroked `<path>`, `GlyphClass` unaffected (E2, 2026-07-08)
A resolved `Curve` (slur) emits a stroked, unfilled cubic-bézier `<path d="M..
C.."` with `data-kind="curve"` and a `curve_provenance_attrs` trace — drawn
after strokes and before glyphs (a slur sits over the staff lines, under the
noteheads it joins), in its own layer-grouped loop parallel to the stroke
loop. `content_bounds` grows by each curve's control-point hull ± half-thickness
(a cubic's ink never bows past its hull). `RenderStats` gained `curve_count`;
the acceptance snapshot prints it and the `provenance_count == glyph + stroke`
invariant became `+ curve`. `GlyphClass` is untouched — curves are not glyphs,
so they carry no `data-class`.
## Bundled outline table moved to `epiphany-glyphs` (Editor T4-pre W2, 2026-07-24)
`src/outlines_generated.rs` and `tools/{extract_bravura_outlines.py,OFL.txt}`
moved out of this crate into the new `epiphany-glyphs` crate — the shared
typed glyph-asset seam a canvas tessellator needs (`spec/PLAN_EDITOR_APP.md`
§3.7 / Ruling A), populated on top of `layout-ir`'s already-designed
`PathCommand`/`GlyphRenderData`/`GlyphCatalog::render_data` interface. This
crate now depends on `epiphany-glyphs` instead of owning the table; `outline()`,
`bundled_glyph_count()`, and `smufl_codepoint()` in `src/outline.rs` became
thin delegations (the latter two stay `pub` here per the W2 contract's pin 2,
even though nothing outside this crate calls them — an API that costs one
`pub use` is not worth breaking). The table's own tests (sortedness, pipeline
coverage, metric/outline bbox containment, finite-bounds sanity) moved with
it to `epiphany-glyphs`; this crate's `outline.rs` test module now holds only
the font-subset-specific tests (`font_subset_generated.rs` deliberately
**stayed** — an embeddable font subset is a renderer concern, not a shared
asset) plus a thin delegation smoke test.
**Byte-neutrality is unconditional.** `svg.rs`'s `GlyphMode::PathOutline` arm
still reads `outline(name).path` — the stored `d` string — directly into the
emitted `<path d="…">`. `epiphany-glyphs` additionally parses that same
string into typed `PathCommand`s (for `BravuraGlyphCatalog::render_data`,
consumed by a future canvas renderer, not by this crate), but that parser and
its round-trip re-emitter are private to `epiphany-glyphs` and unreachable
from here — this renderer has no code path that could route through the
typed form even by mistake. Verified before/after against the base commit: a
throwaway probe captured `ResolvedLayoutIR::canonical_bytes()` for every
reference-suite fixture plus the two named W1 fixtures, and all five GUI
goldens were re-run; every byte was identical (reported in the W2 packet
report, not committed here).