diff --git a/Cargo.lock b/Cargo.lock index 38ba1ca..50ec17d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1244,6 +1244,13 @@ dependencies = [ "epiphany-testkit", ] +[[package]] +name = "epiphany-glyphs" +version = "0.0.0" +dependencies = [ + "epiphany-layout-ir", +] + [[package]] name = "epiphany-layout-ir" version = "0.0.0" @@ -1269,6 +1276,7 @@ dependencies = [ "epiphany-core", "epiphany-determinism", "epiphany-engrave", + "epiphany-glyphs", "epiphany-layout-ir", "epiphany-testkit", ] diff --git a/Cargo.toml b/Cargo.toml index 65a732b..7f7b282 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "crates/epiphany-ops", "crates/epiphany-textproj", "crates/epiphany-layout-ir", + "crates/epiphany-glyphs", "crates/epiphany-engrave", "crates/epiphany-render-svg", "crates/epiphany-editor-core", @@ -58,6 +59,14 @@ epiphany-textproj = { path = "crates/epiphany-textproj" } # (v0 acceptance criterion 6), so its path is declared here alongside the other # intra-workspace crates. epiphany-layout-ir = { path = "crates/epiphany-layout-ir" } +# Editor T4-pre W2: the shared typed glyph-asset seam. Bundled genuine Bravura +# SMuFL outline data (extracted from the official OFL Bravura.otf) plus a real +# GlyphCatalog whose render_data returns genuine parsed outlines — depends on +# epiphany-layout-ir only (no third-party dependency, no build.rs, MSRV-clean). +# epiphany-render-svg depends on it (moved out of that crate, which used to +# hold the table privately); a future canvas-tessellating editor-app renderer +# is why it is its own crate rather than a layout-ir module. +epiphany-glyphs = { path = "crates/epiphany-glyphs" } # Agent I's Phase-2 visible-slice crates: epiphany-engrave is the real # constraint solver (Chapter 9 Minimal tier — in progress) consuming the # ConstrainedLayoutIR; epiphany-render-svg is the SVG renderer behind the diff --git a/crates/epiphany-glyphs/Cargo.toml b/crates/epiphany-glyphs/Cargo.toml new file mode 100644 index 0000000..92ccad7 --- /dev/null +++ b/crates/epiphany-glyphs/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "epiphany-glyphs" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +description = "Editor T4-pre W2: the shared typed glyph-asset seam. Bundled genuine Bravura SMuFL outline data (SVG path `d` strings extracted from the official OFL Bravura.otf) plus a real GlyphCatalog whose render_data returns parsed typed outlines, for any renderer or app that depends on epiphany-layout-ir. Dependency-free and MSRV-clean by design." + +[dependencies] +# The typed PathCommand / GlyphCatalog / GlyphRenderData vocabulary this crate +# populates, and BRAVURA_METRICS, which stays in epiphany-layout-ir (W2 pin 3) +# — this crate never edits or re-derives it. +epiphany-layout-ir.workspace = true diff --git a/crates/epiphany-glyphs/DECISIONS.md b/crates/epiphany-glyphs/DECISIONS.md new file mode 100644 index 0000000..d9fe03a --- /dev/null +++ b/crates/epiphany-glyphs/DECISIONS.md @@ -0,0 +1,119 @@ +# epiphany-glyphs — decisions + +Editor T4-pre W2 (`spec/CONTRACT_EDITOR_T4PRE_W2_GLYPHS.md`): the shared +typed glyph-asset seam a canvas tessellator needs, populated on top of the +already-designed `layout-ir` interface (`PathCommand`, `GlyphRenderData`, +`GlyphCatalog::render_data`) rather than designing a new one. + +## Scope and status + +New crate. Owns the bundled Bravura outline data (`src/outlines_generated.rs`, +moved from `epiphany-render-svg`, which held it `pub(crate)` and private to +itself), the extractor tool and its OFL license (`tools/`), an in-crate +grammar-specific parser from the bundled SVG path `d` strings into +`epiphany_layout_ir::PathCommand` (`src/path.rs`, private — reachable only +through the catalog), and `BravuraGlyphCatalog` (`src/catalog.rs`): a real +`GlyphCatalog` whose `render_data` returns genuine outlines, unlike +`epiphany_layout_ir::BravuraCatalog`, which is metrics-only and honestly +returns `None`. + +`BRAVURA_METRICS` does not move here and is not edited (contract pin 3): it +stays in `epiphany-layout-ir`, and this crate depends on it read-only. No +cycle: `epiphany-glyphs` → `epiphany-layout-ir`, never the reverse. + +## The grammar the contract described vs. the grammar the generator emits + +The contract's "verified starting point" states the bundled `d` strings use +"absolute `M`/`L`/`C`/`Z`, decimal coordinates, 4 decimal places". Verified +against all 37 bundled glyphs before writing the parser (not assumed), the +real grammar is wider on both counts, traced to +`tools/extract_bravura_outlines.py`'s use of +`fontTools.pens.svgPathPen.SVGPathPen`, whose default `optimizeCommands` +behavior the extractor never disables: + +* **`V` (vertical-only) and `H` (horizontal-only) lineto shorthand** are also + emitted, absolute, whenever a lineto's target shares an axis with the + current point — 23 of the 37 bundled glyphs use at least one. `PathCommand` + (the already-designed shared type) has no shorthand variant, so `parse_d` + lowers `V`/`H` to `PathCommand::LineTo`; `emit_d` reconstructs the + shorthand byte-for-byte purely from geometry — comparing the lineto's + target to the current point (`V` when only *x* is unchanged, `H` when only + *y* is, `L` otherwise) — which is exactly what the round-trip test proves + for every bundled glyph. No information is lost: the typed form and the + stored string are geometrically and (after this reconstruction) + byte-for-byte equivalent. +* **Coordinates are rounded to *at most* 4 decimals**, with trailing zeros + and a bare `-0` stripped by the generator's own `round_d` + (`tools/extract_bravura_outlines.py:180-185`: `f"{v:.4f}".rstrip('0').rstrip('.')`, + normalising `""`/`"-0"` to `"0"`). Printed precision therefore varies per + number (0-3 fractional digits observed in the bundled data; 4 is the + ceiling, not the width). `emit_d`'s number formatter reproduces this exact + rule. +* Every command in the observed grammar carries exactly one point/coordinate + group (`M`, `L`, `V`, `H` one; `C` three points; `Z` none) — the generator + never merges consecutive same-type commands into a multi-coordinate group + (SVG allows this generally; the fontTools pen never emits it), so the + parser does not implement that generality either. + +This is a correction to the contract's stated facts, not a deviation from its +design pins: pin 4 ("an in-crate parser … covers exactly the grammar the +generator emits") is satisfied by parsing the *real* grammar, which is what +"exactly the grammar the generator emits" requires once the grammar is +actually read from the data rather than assumed from the contract's summary. + +## Round-trip proof, not the sanctioned fallback (pin 5) + +Pin 5 sanctions a weaker fallback — comparing parsed coordinate sequences as +exact `f32` — if the generator's number formatting cannot be reproduced +exactly. It can: `emit_d`'s formatter is a direct transcription of +`round_d`'s Python (round to 4, strip trailing zeros/dot, normalise `-0`), +verified by an actual byte-for-byte `assert_eq!` against every bundled +glyph's stored `d` string (`path.rs`'s +`every_bundled_glyph_round_trips_byte_for_byte`, the load-bearing test). The +fallback was not needed and was not taken. + +## `render_data` caching (pin 6) + +`BravuraGlyphCatalog::render_data` looks up a `std::sync::OnceLock`-cached +`BTreeMap<&'static str, Vec>`, built once by parsing every +bundled outline's `d` string on first use. `parse_d` is pure, so the cached +value a lookup clones is exactly what a fresh parse of the same bundled +string would produce on every call — caching changes only *when* the parse +work happens, never *what* it returns. No interior mutability leaks into the +result; two calls for the same name compare equal (`GlyphRenderData: +PartialEq`), asserted directly. + +## `epiphany-render-svg`'s byte-neutrality (pin 2, pin 5) + +`render-svg`'s `outline()`/`bundled_glyph_count()`/`smufl_codepoint()` now +delegate to this crate (`src/outline.rs` in each crate — `render-svg`'s copy +is a thin re-export/delegation, this crate's is the real lookup). Critically, +`render-svg`'s SVG emission (`svg.rs`'s `GlyphMode::PathOutline` arm) still +reads `outline(name).path` — the *stored* string — directly into the `` attribute. It never routes through `parse_d`/`emit_d`; those exist +only for the round-trip proof and are not reachable from `render-svg` at all +(`path` is a private module here, and its functions are `pub(crate)`, not +exported). This is why the byte-neutrality probe (every SVG golden, every +`ResolvedLayoutIR::canonical_bytes()` reference-suite fixture) is unaffected +by this packet's existence — verified before and after against the base +commit, `diff -r` empty. + +## Why a new crate and not a `layout-ir` module + +The contract left this open (§CONTRACT_EDITOR_T4PRE_IR.md W2 framing: "the +open decision W2's contract resolves is the home … judged on dependency +weight and the MSRV/CI job structure"), and `CONTRACT_EDITOR_T4PRE_W2_GLYPHS.md` +pin 1 settles it: a new crate. Reasons that held once the data was in hand: + +* `layout-ir` is deliberately data-light — it defines the `GlyphCatalog` + *interface* and a small in-tree metrics slice, not asset payloads; the + bundled outline table is ~37 KB of generated Rust literal that has nothing + to do with the constraint-solver interface `layout-ir` exists to define. +* A separate crate keeps the "moved verbatim, byte-for-byte identical output" + claim easy to audit: the outline table's home changed, its content and the + bytes it produces did not. +* It matches the existing pattern (`epiphany-render-svg` already held this + exact data privately) — moving it sideways into a shared crate is a + smaller, more reviewable diff than folding it into `layout-ir` and then + re-exporting it back out for `render-svg`'s and a future canvas renderer's + use. diff --git a/crates/epiphany-glyphs/src/catalog.rs b/crates/epiphany-glyphs/src/catalog.rs new file mode 100644 index 0000000..024d180 --- /dev/null +++ b/crates/epiphany-glyphs/src/catalog.rs @@ -0,0 +1,181 @@ +//! The real glyph-render catalog (Editor T4-pre W2 pin 1). Metrics delegate +//! to `epiphany_layout_ir::BRAVURA_METRICS`, which stays in `epiphany-layout-ir` +//! unmoved and unchanged (pin 3) — this crate never edits or re-derives it. +//! `render_data` returns genuine outlines, parsed once from this crate's +//! bundled `d` strings: unlike `epiphany_layout_ir::BravuraCatalog` (which +//! bundles no render data and honestly returns `None` for every glyph), this +//! catalog can answer for real because the outline data lives here. + +use std::collections::BTreeMap; +use std::sync::OnceLock; + +use epiphany_layout_ir::{ + metrics, metrics_hash_for, GlyphCatalog, GlyphCatalogIdentity, GlyphMetric, GlyphRenderData, + PathCommand, SmuflVersion, +}; + +use crate::outlines_generated::BRAVURA_OUTLINES; +use crate::path::parse_d; + +/// The bundled Bravura catalog with genuine render data (Editor T4-pre W2). +pub struct BravuraGlyphCatalog; + +/// The lazily-built, cached name -> parsed-outline table (pin 6: caching is +/// permitted as long as it does not change results and two calls agree). +/// Parsing happens once per process; [`parse_d`] is pure, so the cached +/// `Vec` a lookup clones is exactly what a fresh parse of the +/// same bundled string would produce — caching changes only *when* the work +/// happens, never *what* it returns. +fn render_data_table() -> &'static BTreeMap<&'static str, Vec> { + static TABLE: OnceLock>> = OnceLock::new(); + TABLE.get_or_init(|| { + BRAVURA_OUTLINES + .iter() + .map(|o| (o.name, parse_d(o.path))) + .collect() + }) +} + +impl GlyphCatalog for BravuraGlyphCatalog { + fn metrics(&self, name: &str) -> Option<&GlyphMetric> { + metrics(name) + } + + fn render_data(&self, name: &str) -> Option { + render_data_table() + .get(name) + .map(|outline| GlyphRenderData { + outline: outline.clone(), + bitmap: None, + }) + } + + fn smufl_version(&self) -> SmuflVersion { + SmuflVersion::from_decimal(1, "4").expect("1.4 is a valid SMuFL version") + } + + fn identity(&self, consulted: &[&str]) -> GlyphCatalogIdentity { + GlyphCatalogIdentity { + metrics_hash: metrics_hash_for(consulted.iter().copied()), + ..GlyphCatalogIdentity::default() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extent::outline_extent; + use epiphany_layout_ir::BRAVURA_METRICS; + + /// (g3) Every pipeline glyph (the `BRAVURA_METRICS` name set) resolves + /// through the new catalog's `render_data` — mirroring + /// `epiphany-render-svg`'s existing `every_pipeline_glyph_has_a_bundled_outline`, + /// but against the real `GlyphCatalog` seam this packet populates rather + /// than the raw outline table directly. + #[test] + fn every_pipeline_glyph_has_render_data() { + let catalog = BravuraGlyphCatalog; + for m in BRAVURA_METRICS { + let data = catalog.render_data(m.name.as_ref()); + assert!( + data.is_some(), + "no render data for pipeline glyph {}", + m.name + ); + assert!( + !data.unwrap().outline.is_empty(), + "{}: render data has an empty outline", + m.name + ); + } + } + + /// (g6, pin 6) Two calls return equal data — the cache must not change + /// results, and `render_data` is deterministic and side-effect-free. + #[test] + fn render_data_is_deterministic_across_calls() { + let catalog = BravuraGlyphCatalog; + for m in BRAVURA_METRICS { + let a = catalog.render_data(m.name.as_ref()); + let b = catalog.render_data(m.name.as_ref()); + assert_eq!(a, b, "{}: two render_data calls disagreed", m.name); + } + } + + /// (g4) Outline ink fits the declared metrics bbox: `layout-ir/src/glyph.rs:107-109` + /// claims the metrics and outlines come from the same font release "so + /// reserved advances/bboxes and the drawn ink agree" — this is the test + /// that actually checks it, computed from the *typed* outline geometry + /// (real cubic-bezier extrema, not just control points or the + /// separately-stored `bbox` field), against `BRAVURA_METRICS`'s bounding + /// box (converted from 1/1024-staff-space integers to `f32` staff + /// spaces). + /// + /// Tolerance: the `d` string's coordinates are individually rounded to + /// at most 4 decimals before this test re-derives extrema from them, and + /// `BRAVURA_METRICS`'s integer bbox is independently floor/ceil-rounded + /// to the *coarser* 1/1024 (~0.000977) staff-space grid from the + /// generator's own (also 4-decimal-rounded) outline bbox — two + /// independent roundings on each side of the comparison. `TOLERANCE` + /// generously covers both: it is asserted, not tuned after the fact, and + /// the test additionally reports the actual worst-case deviation so a + /// real violation cannot hide inside a widened tolerance. + #[test] + fn outline_ink_fits_the_declared_metrics_bbox() { + const TOLERANCE: f32 = 0.005; + let catalog = BravuraGlyphCatalog; + let mut worst: f32 = f32::NEG_INFINITY; + let mut worst_glyph = ""; + let mut worst_side = ""; + for m in BRAVURA_METRICS { + let data = catalog + .render_data(m.name.as_ref()) + .unwrap_or_else(|| panic!("{}: no render data", m.name)); + let [ox_min, oy_min, ox_max, oy_max] = outline_extent(&data.outline) + .unwrap_or_else(|| panic!("{}: empty outline", m.name)); + let mb = m.bounding_box(); + // Positive = the outline pokes out past that side of the metric + // box by this many staff spaces; negative = margin to spare. + let overflow = [ + ("left", mb.left.0 - ox_min), + ("bottom", mb.bottom.0 - oy_min), + ("right", ox_max - mb.right.0), + ("top", oy_max - mb.top.0), + ]; + for (side, amount) in overflow { + if amount > worst { + worst = amount; + worst_glyph = m.name.as_ref(); + worst_side = side; + } + assert!( + amount <= TOLERANCE, + "{}: outline overflows the metrics bbox on the {} side by {} \ + staff spaces (tolerance {}) — outline extent [{ox_min}, {oy_min}, \ + {ox_max}, {oy_max}], metrics bbox [{}, {}, {}, {}]", + m.name, + side, + amount, + TOLERANCE, + mb.left.0, + mb.bottom.0, + mb.right.0, + mb.top.0, + ); + } + } + // Reported per the contract (g4): the real worst-case deviation + // across every bundled glyph, not just "within tolerance". Printed + // to 8 decimals (well past the data's own 4-decimal precision) so a + // near-zero value is not mistaken for exactly zero. + eprintln!( + "g4 worst-case deviation: {worst:.8} staff spaces ({worst_glyph}, {worst_side} side; \ + negative = margin to spare, positive = overflow)" + ); + assert!( + worst.is_finite(), + "worst-case deviation must be a real computed number" + ); + } +} diff --git a/crates/epiphany-glyphs/src/extent.rs b/crates/epiphany-glyphs/src/extent.rs new file mode 100644 index 0000000..519282f --- /dev/null +++ b/crates/epiphany-glyphs/src/extent.rs @@ -0,0 +1,141 @@ +//! The tight axis-aligned bounding box of a typed outline (Editor T4-pre W2 +//! test g4): used only to prove the bundled outlines' drawn ink is contained +//! by their declared `BRAVURA_METRICS` bounding box — the cross-table +//! consistency `layout-ir/src/glyph.rs:107-109` asserts in prose ("the +//! reserved advances/bboxes and the drawn ink agree") but no test in the +//! tree checked directly from parsed outline geometry before this packet. + +use epiphany_layout_ir::PathCommand; + +/// The exact bounding box `[left, bottom, right, top]` of the ink a sequence +/// of typed path commands draws, computed from real cubic-bezier extrema — +/// not just control points, which routinely lie outside a curve's own tight +/// bounds — matching what `tools/extract_bravura_outlines.py`'s +/// `fontTools.pens.boundsPen.BoundsPen` computes at generation time (it +/// overrides `curveToOne` with the same real-extrema calculation, unlike its +/// `ControlBoundsPen` base class). Returns `None` for an empty command list. +pub(crate) fn outline_extent(commands: &[PathCommand]) -> Option<[f32; 4]> { + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + let mut cur = (0.0f32, 0.0f32); + let mut any = false; + for cmd in commands { + match cmd { + PathCommand::MoveTo(p) | PathCommand::LineTo(p) => { + let (x, y) = (p.x.0, p.y.0); + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + cur = (x, y); + any = true; + } + PathCommand::CurveTo { + control1, + control2, + to, + } => { + let (x0, y0) = cur; + let (lo_x, hi_x) = cubic_extrema_1d(x0, control1.x.0, control2.x.0, to.x.0); + let (lo_y, hi_y) = cubic_extrema_1d(y0, control1.y.0, control2.y.0, to.y.0); + min_x = min_x.min(lo_x); + max_x = max_x.max(hi_x); + min_y = min_y.min(lo_y); + max_y = max_y.max(hi_y); + any = true; + cur = (to.x.0, to.y.0); + } + PathCommand::Close => {} + } + } + if any { + Some([min_x, min_y, max_x, max_y]) + } else { + None + } +} + +/// The `[min, max]` extent of a 1-D cubic bezier `p0..p3` over `t in [0,1]`: +/// the two endpoints plus any interior critical point of the derivative (a +/// root of the quadratic `B'(t)/3 = a*t^2 + b*t + c`). +fn cubic_extrema_1d(p0: f32, p1: f32, p2: f32, p3: f32) -> (f32, f32) { + let mut lo = p0.min(p3); + let mut hi = p0.max(p3); + let d0 = p1 - p0; + let d1 = p2 - p1; + let d2 = p3 - p2; + let a = d0 - 2.0 * d1 + d2; + let b = 2.0 * (d1 - d0); + let c = d0; + let mut consider = |t: f32| { + if (0.0..=1.0).contains(&t) { + let mt = 1.0 - t; + let v = + mt * mt * mt * p0 + 3.0 * mt * mt * t * p1 + 3.0 * mt * t * t * p2 + t * t * t * p3; + lo = lo.min(v); + hi = hi.max(v); + } + }; + if a.abs() < 1e-12 { + if b.abs() > 1e-12 { + consider(-c / b); + } + } else { + let disc = b * b - 4.0 * a * c; + if disc >= 0.0 { + let sqrt_disc = disc.sqrt(); + consider((-b + sqrt_disc) / (2.0 * a)); + consider((-b - sqrt_disc) / (2.0 * a)); + } + } + (lo, hi) +} + +#[cfg(test)] +mod tests { + use super::*; + use epiphany_layout_ir::Point; + + #[test] + fn straight_line_extent_is_its_endpoints() { + let cmds = vec![ + PathCommand::MoveTo(Point::new(0.0, 0.0)), + PathCommand::LineTo(Point::new(2.0, 3.0)), + ]; + assert_eq!(outline_extent(&cmds), Some([0.0, 0.0, 2.0, 3.0])); + } + + #[test] + fn empty_commands_have_no_extent() { + assert_eq!(outline_extent(&[]), None); + } + + #[test] + fn curve_extrema_are_found_beyond_the_endpoints() { + // The classic "hump" cubic (P0=(0,0), P1=(0,1), P2=(1,1), P3=(1,0)): + // its peak y at t=0.5 is 0.75, well past either endpoint's y=0 — a + // control-point-only (or endpoint-only) bound would miss it. + let cmds = vec![ + PathCommand::MoveTo(Point::new(0.0, 0.0)), + PathCommand::CurveTo { + control1: Point::new(0.0, 1.0), + control2: Point::new(1.0, 1.0), + to: Point::new(1.0, 0.0), + }, + ]; + let ext = outline_extent(&cmds).unwrap(); + assert!( + (ext[3] - 0.75).abs() < 1e-5, + "expected top ~0.75, got {}", + ext[3] + ); + assert_eq!(ext[0], 0.0, "left stays at the endpoints' x"); + assert_eq!(ext[2], 1.0, "right stays at the endpoints' x"); + assert_eq!( + ext[1], 0.0, + "bottom is the (only) minimum, at both endpoints" + ); + } +} diff --git a/crates/epiphany-glyphs/src/lib.rs b/crates/epiphany-glyphs/src/lib.rs new file mode 100644 index 0000000..6a6ef60 --- /dev/null +++ b/crates/epiphany-glyphs/src/lib.rs @@ -0,0 +1,62 @@ +#![forbid(unsafe_code)] +//! # epiphany-glyphs +//! +//! The shared typed glyph-asset seam (Editor T4-pre W2, +//! `spec/PLAN_EDITOR_APP.md` §3.7 / Ruling A: "a canvas tessellator needs +//! typed vector paths from a crate both the renderer and the app can depend +//! on"). This crate owns: +//! +//! * the bundled genuine Bravura SMuFL outline data ([`outline`], +//! [`bundled_glyph_count`], [`smufl_codepoint`]) extracted by +//! `tools/extract_bravura_outlines.py` into `src/outlines_generated.rs` — +//! SVG path `d` strings, staff-space units, y-up, moved here from +//! `epiphany-render-svg` (which previously kept the table private, +//! `outline()` was `pub(crate)`); +//! * a parser from that `d`-string grammar into +//! [`epiphany_layout_ir::PathCommand`] (`path` module, private — consumed +//! only through [`BravuraGlyphCatalog`]), with a round-trip re-emitter +//! that proves the typed form describes the same geometry (pin 5); +//! * [`BravuraGlyphCatalog`], a real [`epiphany_layout_ir::GlyphCatalog`] +//! whose `render_data` returns genuine parsed outlines — unlike +//! `epiphany_layout_ir::BravuraCatalog`, which bundles no render data and +//! honestly returns `None` for every glyph; +//! * `tools/OFL.txt`, the SIL Open Font License 1.1 these redistributed +//! outlines travel under (a redistribution condition, not decoration). +//! +//! ## What does NOT live here +//! +//! `BRAVURA_METRICS` stays in `epiphany_layout_ir` (pin 3) — this crate +//! depends on it and never edits or re-derives it; any edit to that table +//! churns every conformance byte in the repo, and it is out of this +//! packet's scope entirely. `epiphany-render-svg`'s embedded-font subset +//! (`font_subset_generated.rs`) also stays put: an embeddable font subset is +//! a renderer concern, not a shared asset (pin 2). +//! +//! ## No cycle, no third-party dependency +//! +//! This crate depends on `epiphany-layout-ir` only (for `BRAVURA_METRICS`, +//! `PathCommand`, and the `GlyphCatalog` vocabulary) — never the reverse — +//! and pulls in no third-party crate (`cargo tree -p epiphany-glyphs` proves +//! it) and uses no `build.rs`: it is in the MSRV closure, and the MSRV job +//! runs `--workspace --exclude epiphany-editor-gui`, so it must stay clean. +//! +//! ## Byte-neutrality for `render-svg` +//! +//! `epiphany-render-svg` depends on this crate for the outline lookup but +//! keeps emitting each glyph's *stored* `d` string, byte-for-byte, in its +//! SVG output — never a re-serialization of the typed outline. Every SVG +//! golden and every layout-conformance byte is unaffected by this crate's +//! existence. + +mod catalog; +// `extent` computes an outline's tight bounding box purely to prove test g4 +// (the drawn ink fits the declared metrics bbox); nothing in production code +// consumes it, so it is compiled only for `cargo test`. +#[cfg(test)] +mod extent; +mod outline; +mod outlines_generated; +mod path; + +pub use catalog::BravuraGlyphCatalog; +pub use outline::{bundled_glyph_count, outline, smufl_codepoint, BravuraOutline}; diff --git a/crates/epiphany-glyphs/src/outline.rs b/crates/epiphany-glyphs/src/outline.rs new file mode 100644 index 0000000..ebc45ac --- /dev/null +++ b/crates/epiphany-glyphs/src/outline.rs @@ -0,0 +1,93 @@ +//! Lookup over the bundled Bravura outlines ([`crate::outlines_generated`]) — +//! the shared seam `epiphany-render-svg` depends on for its exact stored `d` +//! strings and bounding boxes (Editor T4-pre W2 pin 2). `render-svg` keeps +//! using [`outline`]'s returned `d` verbatim in its SVG output; it never +//! re-serializes the typed form (pin 5 — see `path::emit_d`'s doc comment +//! for why that distinction matters). + +use crate::outlines_generated::BRAVURA_OUTLINES; + +pub use crate::outlines_generated::BravuraOutline; + +/// The genuine Bravura outline for a SMuFL glyph name, if bundled. The table is +/// sorted by name, so this is a binary search. +pub fn outline(name: &str) -> Option<&'static BravuraOutline> { + BRAVURA_OUTLINES + .binary_search_by(|o| o.name.cmp(name)) + .ok() + .map(|i| &BRAVURA_OUTLINES[i]) +} + +/// How many glyph outlines are bundled. +pub fn bundled_glyph_count() -> usize { + BRAVURA_OUTLINES.len() +} + +/// The SMuFL codepoint of a bundled glyph name, if bundled. Used by +/// `render-svg`'s embedded-font render mode (which references glyphs by +/// codepoint) and for debugging glyph identity. +pub fn smufl_codepoint(name: &str) -> Option { + outline(name).map(|o| o.codepoint) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_table_is_sorted_and_searchable() { + // Binary search depends on the generator emitting names in order. + assert!(BRAVURA_OUTLINES.windows(2).all(|w| w[0].name < w[1].name)); + assert!(outline("noteheadBlack").is_some()); + assert!(outline("gClef").is_some()); + assert!(outline("noSuchGlyph").is_none()); + } + + #[test] + fn every_pipeline_glyph_has_a_bundled_outline() { + // Non-vacuity: every glyph the v0 layout pipeline can name (the + // layout-ir BRAVURA_METRICS set) is drawable. If the metrics table grows + // a glyph, the generator must be re-run — this test fails until it is. + for m in epiphany_layout_ir::BRAVURA_METRICS { + assert!( + outline(m.name.as_ref()).is_some(), + "no bundled outline for pipeline glyph {}", + m.name + ); + } + } + + #[test] + fn metric_bboxes_contain_the_drawn_outlines() { + // The engraver evaluates collisions from a glyph's metric bounding box, + // while the renderer draws (and bounds) its outline. If a metric box were a + // hair smaller than the ink — e.g. from rounding the bbox to the *nearest* + // 1/1024 — a hard no-collision verdict could be microscopically false on + // paper. The metrics are extracted as the outline bounds rounded *outward* + // to the grid, so every metric box must contain its outline box. + for m in epiphany_layout_ir::BRAVURA_METRICS { + let Some(o) = outline(m.name.as_ref()) else { + continue; + }; + let mb = m.bounding_box(); + let [ol, ob, oright, otop] = o.bbox; + assert!( + mb.left.0 <= ol && mb.bottom.0 <= ob && mb.right.0 >= oright && mb.top.0 >= otop, + "metric bbox {:?} for {} must contain its outline bbox {:?}", + [mb.left.0, mb.bottom.0, mb.right.0, mb.top.0], + m.name, + o.bbox, + ); + } + } + + #[test] + fn outlines_have_finite_bounds_and_nonempty_paths() { + for o in BRAVURA_OUTLINES { + assert!(o.bbox.iter().all(|v| v.is_finite())); + assert!(o.bbox[0] <= o.bbox[2] && o.bbox[1] <= o.bbox[3]); + assert!(!o.path.is_empty()); + assert!(o.path.starts_with('M'), "{} path must start with M", o.name); + } + } +} diff --git a/crates/epiphany-render-svg/src/outlines_generated.rs b/crates/epiphany-glyphs/src/outlines_generated.rs similarity index 99% rename from crates/epiphany-render-svg/src/outlines_generated.rs rename to crates/epiphany-glyphs/src/outlines_generated.rs index dc0c8b7..f0dfe19 100644 --- a/crates/epiphany-render-svg/src/outlines_generated.rs +++ b/crates/epiphany-glyphs/src/outlines_generated.rs @@ -18,8 +18,10 @@ /// One bundled glyph outline: SMuFL name, codepoint, the SVG path `d` in /// staff-space / y-up coordinates, and the outline's tight bounding box -/// `[left, bottom, right, top]` in staff spaces. -pub(crate) struct BravuraOutline { +/// `[left, bottom, right, top]` in staff spaces. `pub`: this crate's whole +/// point is to be a shared seam other crates (`epiphany-render-svg`, and a +/// future canvas renderer) depend on for exactly these fields. +pub struct BravuraOutline { pub name: &'static str, pub codepoint: u32, pub path: &'static str, diff --git a/crates/epiphany-glyphs/src/path.rs b/crates/epiphany-glyphs/src/path.rs new file mode 100644 index 0000000..b248df6 --- /dev/null +++ b/crates/epiphany-glyphs/src/path.rs @@ -0,0 +1,333 @@ +//! Parses the SVG path `d` grammar the bundled outlines are generated in into +//! the shared typed [`PathCommand`] form (Editor T4-pre W2 pin 4), and +//! re-emits it in the generator's exact byte formatting so a round-trip +//! proves the typed form describes the same geometry (pin 5). +//! +//! **Correction to the contract's stated grammar.** +//! `spec/CONTRACT_EDITOR_T4PRE_W2_GLYPHS.md`'s "verified starting point" +//! describes the generated grammar as "absolute `M`/`L`/`C`/`Z`, decimal +//! coordinates, 4 decimal places". Verified against all 37 bundled glyphs +//! before writing this parser, the real grammar is wider on both counts: +//! +//! * The generator (`tools/extract_bravura_outlines.py`, via +//! `fontTools.pens.svgPathPen.SVGPathPen`'s default `optimizeCommands`) +//! also emits absolute `V` (vertical-only lineto) and `H` (horizontal-only +//! lineto) wherever a lineto's target shares an axis with the current +//! point — 23 of the 37 bundled glyphs use at least one. [`PathCommand`] +//! has no shorthand variant, so [`parse_d`] lowers `V`/`H` to +//! [`PathCommand::LineTo`]; [`emit_d`] reconstructs the shorthand +//! byte-for-byte from geometry alone (comparing the target to the current +//! point — see its doc comment), which is exactly what the round-trip +//! test (pin 5, contract test g1) proves for every bundled glyph. +//! * Coordinates are rounded to *at most* 4 decimals with trailing zeros (and +//! a bare `-0`) stripped by the generator's own `round_d` +//! (`tools/extract_bravura_outlines.py:180-185`), so the printed precision +//! varies per number — 0 to 3 fractional digits are observed in the +//! bundled data (never 4, though the grammar allows it); it is not a fixed +//! width. +//! +//! Every command in the observed grammar carries exactly one point (`M`, +//! `L`), one coordinate (`V`, `H`), three points (`C`), or none (`Z`) — the +//! generator never merges consecutive same-type commands into a +//! multi-coordinate group, so the parser does not need to handle that SVG +//! generality either. + +use epiphany_layout_ir::{PathCommand, Point}; + +/// Parses an absolute SVG path `d` string in the generator's grammar +/// (`M`/`L`/`C`/`V`/`H`/`Z`, one point/coordinate per command, absolute +/// coordinates) into typed path commands. +/// +/// Panics on malformed input. The input is always this crate's own bundled, +/// generator-produced constant data — never external or untrusted text — so +/// a parse failure is a bug in this parser or the bundled table, not a +/// runtime condition a caller should recover from. +pub(crate) fn parse_d(d: &str) -> Vec { + let bytes = d.as_bytes(); + let mut i = 0usize; + let mut out = Vec::new(); + let mut cur = (0.0f32, 0.0f32); + let mut subpath_start = (0.0f32, 0.0f32); + + let take_number = |bytes: &[u8], i: &mut usize| -> f32 { + while *i < bytes.len() && bytes[*i] == b' ' { + *i += 1; + } + let start = *i; + if *i < bytes.len() && bytes[*i] == b'-' { + *i += 1; + } + while *i < bytes.len() && bytes[*i].is_ascii_digit() { + *i += 1; + } + if *i < bytes.len() && bytes[*i] == b'.' { + *i += 1; + while *i < bytes.len() && bytes[*i].is_ascii_digit() { + *i += 1; + } + } + let tok = std::str::from_utf8(&bytes[start..*i]) + .unwrap_or_else(|e| panic!("non-UTF-8 number token in {d:?}: {e}")); + tok.parse::() + .unwrap_or_else(|e| panic!("bad number token {tok:?} in {d:?}: {e}")) + }; + + while i < bytes.len() { + let cmd = bytes[i]; + i += 1; + match cmd { + b'M' => { + let x = take_number(bytes, &mut i); + let y = take_number(bytes, &mut i); + out.push(PathCommand::MoveTo(Point::new(x, y))); + cur = (x, y); + subpath_start = cur; + } + b'L' => { + let x = take_number(bytes, &mut i); + let y = take_number(bytes, &mut i); + out.push(PathCommand::LineTo(Point::new(x, y))); + cur = (x, y); + } + b'V' => { + let y = take_number(bytes, &mut i); + cur = (cur.0, y); + out.push(PathCommand::LineTo(Point::new(cur.0, cur.1))); + } + b'H' => { + let x = take_number(bytes, &mut i); + cur = (x, cur.1); + out.push(PathCommand::LineTo(Point::new(cur.0, cur.1))); + } + b'C' => { + let c1x = take_number(bytes, &mut i); + let c1y = take_number(bytes, &mut i); + let c2x = take_number(bytes, &mut i); + let c2y = take_number(bytes, &mut i); + let tx = take_number(bytes, &mut i); + let ty = take_number(bytes, &mut i); + out.push(PathCommand::CurveTo { + control1: Point::new(c1x, c1y), + control2: Point::new(c2x, c2y), + to: Point::new(tx, ty), + }); + cur = (tx, ty); + } + b'Z' => { + out.push(PathCommand::Close); + cur = subpath_start; + } + other => panic!( + "unsupported path command byte {:#04x} ({}) in {d:?}: the bundled grammar is \ + absolute M/L/C/V/H/Z only", + other, other as char + ), + } + } + out +} + +/// Re-emits typed path commands in the generator's exact `d`-string +/// formatting (pin 5's round-trip proof). +/// +/// [`PathCommand::LineTo`] carries no record of whether it was originally an +/// `L`, `V`, or `H` command — the shared type has no shorthand variant (see +/// the module doc). This reconstructs the shorthand from geometry alone, +/// matching `fontTools.pens.svgPathPen.SVGPathPen`'s own rule: emit `V` when +/// only the *x* coordinate is unchanged from the current point, `H` when +/// only *y* is unchanged, and `L` otherwise (including the degenerate +/// zero-length case, x and y both unchanged, which does not occur in any +/// bundled glyph — verified below). Numbers are formatted exactly as the +/// generator's `round_d`: at most 4 decimals, trailing zeros and a trailing +/// `.` stripped, `-0`/empty normalised to `0`. +/// +/// Exists solely for the round-trip proof (test g1) — nothing in production +/// code re-serializes the typed form (pin 5: `render-svg` always emits the +/// *stored* `d` string), so this is compiled only for `cargo test`. +#[cfg(test)] +pub(crate) fn emit_d(commands: &[PathCommand]) -> String { + let mut out = String::new(); + let mut cur = (0.0f32, 0.0f32); + let mut subpath_start = (0.0f32, 0.0f32); + for cmd in commands { + match cmd { + PathCommand::MoveTo(p) => { + let (x, y) = (p.x.0, p.y.0); + out.push('M'); + push_num(&mut out, x); + out.push(' '); + push_num(&mut out, y); + cur = (x, y); + subpath_start = cur; + } + PathCommand::LineTo(p) => { + let (x, y) = (p.x.0, p.y.0); + let x_same = x == cur.0; + let y_same = y == cur.1; + if x_same && !y_same { + out.push('V'); + push_num(&mut out, y); + } else if y_same && !x_same { + out.push('H'); + push_num(&mut out, x); + } else { + out.push('L'); + push_num(&mut out, x); + out.push(' '); + push_num(&mut out, y); + } + cur = (x, y); + } + PathCommand::CurveTo { + control1, + control2, + to, + } => { + out.push('C'); + push_num(&mut out, control1.x.0); + out.push(' '); + push_num(&mut out, control1.y.0); + out.push(' '); + push_num(&mut out, control2.x.0); + out.push(' '); + push_num(&mut out, control2.y.0); + out.push(' '); + push_num(&mut out, to.x.0); + out.push(' '); + push_num(&mut out, to.y.0); + cur = (to.x.0, to.y.0); + } + PathCommand::Close => { + out.push('Z'); + cur = subpath_start; + } + } + } + out +} + +/// Formats one coordinate exactly as the generator's `round_d`: at most 4 +/// decimals, trailing zeros and a trailing `.` stripped, `-0`/empty +/// normalised to `0`. `emit_d`'s only caller; test-only for the same reason. +#[cfg(test)] +fn push_num(out: &mut String, v: f32) { + use std::fmt::Write as _; + let mut s = String::new(); + let _ = write!(s, "{v:.4}"); + if s.contains('.') { + while s.ends_with('0') { + s.pop(); + } + if s.ends_with('.') { + s.pop(); + } + } + if s.is_empty() || s == "-0" { + s = "0".to_owned(); + } + out.push_str(&s); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::outlines_generated::BRAVURA_OUTLINES; + + /// (g1) The packet's load-bearing test: every bundled glyph's `d` string + /// parses and re-emits byte-for-byte identical. If this holds for all 37 + /// bundled glyphs, the typed form provably describes the same geometry — + /// no geometric spot-checking is needed (pin 5, primary path, not the + /// sanctioned coordinate-sequence fallback). + #[test] + fn every_bundled_glyph_round_trips_byte_for_byte() { + assert_eq!( + BRAVURA_OUTLINES.len(), + 37, + "sanity: the bundled glyph count moved" + ); + for o in BRAVURA_OUTLINES { + let parsed = parse_d(o.path); + let reemitted = emit_d(&parsed); + assert_eq!( + reemitted, o.path, + "{}: parse -> emit did not round-trip byte-for-byte", + o.name + ); + } + } + + /// (g2) `Close` survives parsing: every bundled glyph ends with `Z`, and + /// the parsed command list must carry a trailing `PathCommand::Close`, + /// not merely reproduce the byte (which g1 already proves) — this + /// exercises the *typed* value directly, independent of re-emission. + #[test] + fn close_survives_parsing_as_a_typed_command() { + for o in BRAVURA_OUTLINES { + let parsed = parse_d(o.path); + assert!( + matches!(parsed.last(), Some(PathCommand::Close)), + "{}: parsed commands must end with Close", + o.name + ); + // Every bundled path has at least one closed subpath, so Close + // must appear at least once, not just coincidentally last. + assert!( + parsed.iter().any(|c| matches!(c, PathCommand::Close)), + "{}: parsed commands must contain a Close", + o.name + ); + } + } + + /// (g6) absolute, not relative — a hand-verified case, not a + /// re-derivation through the parser under test. `augmentationDot`'s `d` + /// (transcribed below, independently of [`BRAVURA_OUTLINES`]) is four + /// cubic curves tracing a circle back to its own start point `(0.4, 0)`. + /// Read by eye, every command's numbers *are* the absolute point they + /// land on. Were the parser instead accumulating each command's numbers + /// onto the current point (SVG's lowercase/relative convention), the + /// first curve's endpoint would still land right (an all-positive + /// glyph's first hop can't distinguish the two rules), but the second + /// curve's endpoint would land at `(0.2, 0.2) + (0, 0) = (0.2, 0.2)` + /// instead of the correct `(0, 0)`, and every command after that would + /// drift further from a straightforward accumulation of offsets. This + /// pins the correct (absolute) reading explicitly, command by command. + #[test] + fn parsed_coordinates_are_absolute_not_relative() { + let d = "M0.4 0C0.4 0.112 0.312 0.2 0.2 0.2C0.088 0.2 0 0.112 0 0\ + C0 -0.112 0.088 -0.2 0.2 -0.2C0.312 -0.2 0.4 -0.112 0.4 0Z"; + // Sanity: this is really `augmentationDot`'s bundled `d`, not a + // stand-in string that happens to look similar. + let augmentation_dot = BRAVURA_OUTLINES + .iter() + .find(|o| o.name == "augmentationDot") + .expect("augmentationDot is bundled"); + assert_eq!(augmentation_dot.path, d); + + let expected = vec![ + PathCommand::MoveTo(Point::new(0.4, 0.0)), + PathCommand::CurveTo { + control1: Point::new(0.4, 0.112), + control2: Point::new(0.312, 0.2), + to: Point::new(0.2, 0.2), + }, + PathCommand::CurveTo { + control1: Point::new(0.088, 0.2), + control2: Point::new(0.0, 0.112), + to: Point::new(0.0, 0.0), + }, + PathCommand::CurveTo { + control1: Point::new(0.0, -0.112), + control2: Point::new(0.088, -0.2), + to: Point::new(0.2, -0.2), + }, + PathCommand::CurveTo { + control1: Point::new(0.312, -0.2), + control2: Point::new(0.4, -0.112), + to: Point::new(0.4, 0.0), + }, + PathCommand::Close, + ]; + assert_eq!(parse_d(d), expected); + } +} diff --git a/crates/epiphany-render-svg/tools/OFL.txt b/crates/epiphany-glyphs/tools/OFL.txt similarity index 100% rename from crates/epiphany-render-svg/tools/OFL.txt rename to crates/epiphany-glyphs/tools/OFL.txt diff --git a/crates/epiphany-render-svg/tools/extract_bravura_outlines.py b/crates/epiphany-glyphs/tools/extract_bravura_outlines.py similarity index 92% rename from crates/epiphany-render-svg/tools/extract_bravura_outlines.py rename to crates/epiphany-glyphs/tools/extract_bravura_outlines.py index 923ee6d..b3e19a5 100644 --- a/crates/epiphany-render-svg/tools/extract_bravura_outlines.py +++ b/crates/epiphany-glyphs/tools/extract_bravura_outlines.py @@ -1,14 +1,23 @@ #!/usr/bin/env python3 """Extract genuine Bravura SMuFL glyph outlines into a Rust table. -Reproducible generator for `epiphany-render-svg`'s bundled outline data. It -fetches the official OFL `Bravura.otf` and the SMuFL `glyphnames.json`, then -emits `src/outlines_generated.rs` with each glyph's outline as an SVG path in -**staff-space**, **y-up** coordinates (the renderer's coordinate system). +Reproducible generator for `epiphany-glyphs`'s bundled outline data (moved +here from `epiphany-render-svg` at Editor T4-pre W2, the shared typed +glyph-asset seam — `render-svg` now depends on this crate instead of owning +the table itself). It fetches the official OFL `Bravura.otf` and the SMuFL +`glyphnames.json`, then emits `src/outlines_generated.rs` with each glyph's +outline as an SVG path in **staff-space**, **y-up** coordinates. Usage: python3 -m venv .venv && . .venv/bin/activate && pip install fonttools - python3 extract_bravura_outlines.py > ../crates/epiphany-render-svg/src/outlines_generated.rs + python3 extract_bravura_outlines.py > ../src/outlines_generated.rs + + # Also regenerate epiphany-render-svg's embedded-font subset (that + # generated file stays in render-svg — W2 pin 2 — so the output path + # crosses back into the sibling crate): + python3 extract_bravura_outlines.py --font-out \ + ../../epiphany-render-svg/src/font_subset_generated.rs \ + > ../src/outlines_generated.rs The font is NOT vendored; only the generated Rust is committed. Bravura is © Steinberg Media Technologies GmbH under the SIL Open Font License 1.1; the @@ -250,8 +259,10 @@ def main(): o.append("") o.append("/// One bundled glyph outline: SMuFL name, codepoint, the SVG path `d` in") o.append("/// staff-space / y-up coordinates, and the outline's tight bounding box") - o.append("/// `[left, bottom, right, top]` in staff spaces.") - o.append("pub(crate) struct BravuraOutline {") + o.append("/// `[left, bottom, right, top]` in staff spaces. `pub`: this crate's whole") + o.append("/// point is to be a shared seam other crates (`epiphany-render-svg`, and a") + o.append("/// future canvas renderer) depend on for exactly these fields.") + o.append("pub struct BravuraOutline {") o.append(" pub name: &'static str,") o.append(" pub codepoint: u32,") o.append(" pub path: &'static str,") @@ -273,6 +284,10 @@ def main(): print(f"// extracted {len(rows)}/{len(NAMES)} glyphs", file=sys.stderr) # Optionally emit the embedded-font subset (renderer GlyphMode::EmbeddedFont). + # This generated file stays in `epiphany-render-svg` (W2 pin 2: an embeddable + # font subset is a renderer concern, not a shared asset), so `--font-out` + # crosses back into the sibling crate even though this script now lives in + # `epiphany-glyphs`. if "--font-out" in sys.argv: import fontTools out_path = sys.argv[sys.argv.index("--font-out") + 1] diff --git a/crates/epiphany-render-svg/Cargo.toml b/crates/epiphany-render-svg/Cargo.toml index 69bad85..a3d50d1 100644 --- a/crates/epiphany-render-svg/Cargo.toml +++ b/crates/epiphany-render-svg/Cargo.toml @@ -11,6 +11,12 @@ description = "Agent I's SVG renderer behind the Epiphany RenderIR interface (sp # The renderer consumes the Chapter 7 ResolvedLayoutIR / RenderIR and the glyph # catalog, all defined in epiphany-layout-ir. epiphany-layout-ir.workspace = true +# The shared typed glyph-asset seam (Editor T4-pre W2): the bundled Bravura +# outline table (and its extractor tool) moved here from this crate, so the +# renderer now depends on it for the outline lookup instead of owning the +# table privately. This crate still emits each glyph's stored `d` string +# byte-for-byte in its SVG output, never the typed form. +epiphany-glyphs.workspace = true [dev-dependencies] # The demo binary's `--solver=real` path drives Agent I's engrave solver; only diff --git a/crates/epiphany-render-svg/DECISIONS.md b/crates/epiphany-render-svg/DECISIONS.md index c060724..7e3fc9c 100644 --- a/crates/epiphany-render-svg/DECISIONS.md +++ b/crates/epiphany-render-svg/DECISIONS.md @@ -126,3 +126,34 @@ loop. `content_bounds` grows by each curve's control-point hull ± half-thicknes 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 ``. `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). diff --git a/crates/epiphany-render-svg/README.md b/crates/epiphany-render-svg/README.md index d086474..27c6eed 100644 --- a/crates/epiphany-render-svg/README.md +++ b/crates/epiphany-render-svg/README.md @@ -55,23 +55,30 @@ only (display scale, margin, provenance attributes, and `glyph_mode` — inline ## Bundled Bravura data Two generated artifacts come from the official OFL `Bravura.otf` via -`tools/extract_bravura_outlines.py` — the font is **not vendored**, only the -generated Rust is committed: +`epiphany-glyphs`'s `tools/extract_bravura_outlines.py` — the font is **not +vendored**, only the generated Rust is committed. **The outlines moved to +`epiphany-glyphs` in T4-pre W2** (a canvas renderer needs them too, so they are +a shared asset rather than a renderer-private one); the font subset stayed here, +because an embeddable OTF is a renderer concern: -- `src/outlines_generated.rs` — the inline glyph outlines (geometry-only, so - byte-stable across fontTools versions); +- `epiphany-glyphs/src/outlines_generated.rs` — the inline glyph outlines + (geometry-only, so byte-stable across fontTools versions). This crate reaches + them through `epiphany_glyphs::outline`, and re-exports + `bundled_glyph_count`/`smufl_codepoint` unchanged; - `src/font_subset_generated.rs` — a base64 OTF **subset** (just the pipeline's glyphs) for `GlyphMode::EmbeddedFont`. As a Modified Version, its primary font name is renamed off the Reserved Font Name "Bravura" per the OFL; a content BLAKE3 + decoded length are committed alongside as an integrity lock. Bravura is © Steinberg Media Technologies GmbH under the SIL Open Font License 1.1 -(`tools/OFL.txt`); both artifacts are redistributed under the same license. To +(`../epiphany-glyphs/tools/OFL.txt`, which travels with the redistributed +outlines); both artifacts are redistributed under the same license. To regenerate both (the subset step also needs the `blake3` package): ```sh -cd crates/epiphany-render-svg/tools +cd crates/epiphany-glyphs/tools python3 -m venv .venv && . .venv/bin/activate && pip install fonttools blake3 -python3 extract_bravura_outlines.py --font-out ../src/font_subset_generated.rs \ +python3 extract_bravura_outlines.py \ + --font-out ../../epiphany-render-svg/src/font_subset_generated.rs \ > ../src/outlines_generated.rs ``` diff --git a/crates/epiphany-render-svg/src/lib.rs b/crates/epiphany-render-svg/src/lib.rs index 9374d26..3fff6b2 100644 --- a/crates/epiphany-render-svg/src/lib.rs +++ b/crates/epiphany-render-svg/src/lib.rs @@ -21,12 +21,17 @@ //! //! ## What it draws, and the non-overreach rule //! -//! The bundled outlines are extracted from the official OFL `Bravura.otf` (see -//! `tools/extract_bravura_outlines.py` and `tools/OFL.txt`) in staff-space, -//! y-up coordinates. The renderer makes SVG-encoding choices only and never -//! engraving-semantic ones; see the private `svg` module for the coordinate -//! system, the provenance-tracing contract, and the diagnostic-not-paper-over -//! rule. +//! The bundled outlines are extracted from the official OFL `Bravura.otf` in +//! staff-space, y-up coordinates, by `epiphany-glyphs`'s +//! `tools/extract_bravura_outlines.py` and redistributed under +//! `epiphany-glyphs/tools/OFL.txt` (Editor T4-pre W2 moved the bundled table +//! and its extractor out of this crate into that shared seam; this crate +//! depends on it but still emits each glyph's *stored* `d` string +//! byte-for-byte — never a re-serialization of the typed outline — so this +//! move changed no SVG byte). The renderer makes SVG-encoding choices only +//! and never engraving-semantic ones; see the private `svg` module for the +//! coordinate system, the provenance-tracing contract, and the +//! diagnostic-not-paper-over rule. //! //! ## Font availability //! @@ -38,14 +43,15 @@ //! * [`GlyphMode::EmbeddedFont`] references glyphs by SMuFL codepoint via a //! `` element and an `@font-face`-embedded Bravura *subset* (the same //! SHA-pinned font the outlines come from, base64 in `font_subset_generated`, -//! regenerated by `tools/extract_bravura_outlines.py --font-out`). Still +//! regenerated by `epiphany-glyphs/tools/extract_bravura_outlines.py --font-out`, +//! which stays this crate's own generated file — an embeddable font subset +//! is a renderer concern, not a shared asset). Still //! self-contained — the font travels in the SVG — and text-selectable, at the //! cost of a larger file; glyph placement is consistent with the path mode by //! construction, while exact rasterisation is the consumer's font renderer's. mod font_subset_generated; mod outline; -mod outlines_generated; mod svg; pub mod xml; diff --git a/crates/epiphany-render-svg/src/outline.rs b/crates/epiphany-render-svg/src/outline.rs index 055172c..1804d8c 100644 --- a/crates/epiphany-render-svg/src/outline.rs +++ b/crates/epiphany-render-svg/src/outline.rs @@ -1,79 +1,39 @@ -//! Lookup over the bundled Bravura outlines ([`crate::outlines_generated`]). +//! Delegates the bundled Bravura outline lookup to `epiphany-glyphs`, the +//! shared typed glyph-asset seam (Editor T4-pre W2): the outline table used +//! to live here privately (`outline()` was `pub(crate)`, with no callers +//! outside this crate); it now lives in `epiphany-glyphs` so a future canvas +//! renderer can depend on the same typed geometry without depending on this +//! SVG renderer. This crate's own outline-data tests (sortedness, pipeline +//! coverage, metric/outline bbox containment) moved with the table; what +//! remains here is font-subset-specific (that generated file stays in this +//! crate — W2 pin 2: an embeddable font subset is a renderer concern, not a +//! shared asset). +//! +//! Byte-neutrality (W2 pin 5): `svg.rs` still reads `outline(name).path` — +//! the *stored* `d` string — directly into the emitted SVG. It never routes +//! through `epiphany-glyphs`'s typed-path parser/re-emitter, so every SVG +//! golden and layout-conformance byte is unaffected by this delegation. -use crate::outlines_generated::{BravuraOutline, BRAVURA_OUTLINES}; +pub(crate) use epiphany_glyphs::outline; -/// The genuine Bravura outline for a SMuFL glyph name, if bundled. The table is -/// sorted by name, so this is a binary search. -pub(crate) fn outline(name: &str) -> Option<&'static BravuraOutline> { - BRAVURA_OUTLINES - .binary_search_by(|o| o.name.cmp(name)) - .ok() - .map(|i| &BRAVURA_OUTLINES[i]) -} - -/// How many glyph outlines are bundled. +/// How many glyph outlines are bundled. Re-exported from `epiphany-glyphs` +/// even though nothing outside `render-svg` calls it today — an API that +/// costs one `pub use` is not worth breaking (W2 pin 2). pub fn bundled_glyph_count() -> usize { - BRAVURA_OUTLINES.len() + epiphany_glyphs::bundled_glyph_count() } /// The SMuFL codepoint of a bundled glyph name, if bundled. Used by the /// embedded-font render mode (which references glyphs by codepoint) and for /// debugging glyph identity. pub fn smufl_codepoint(name: &str) -> Option { - outline(name).map(|o| o.codepoint) + epiphany_glyphs::smufl_codepoint(name) } #[cfg(test)] mod tests { use super::*; - #[test] - fn the_table_is_sorted_and_searchable() { - // Binary search depends on the generator emitting names in order. - assert!(BRAVURA_OUTLINES.windows(2).all(|w| w[0].name < w[1].name)); - assert!(outline("noteheadBlack").is_some()); - assert!(outline("gClef").is_some()); - assert!(outline("noSuchGlyph").is_none()); - } - - #[test] - fn every_pipeline_glyph_has_a_bundled_outline() { - // Non-vacuity: every glyph the v0 layout pipeline can name (the - // layout-ir BRAVURA_METRICS set) is drawable. If the metrics table grows - // a glyph, the generator must be re-run — this test fails until it is. - for m in epiphany_layout_ir::BRAVURA_METRICS { - assert!( - outline(m.name.as_ref()).is_some(), - "no bundled outline for pipeline glyph {}", - m.name - ); - } - } - - #[test] - fn metric_bboxes_contain_the_drawn_outlines() { - // The engraver evaluates collisions from a glyph's metric bounding box, - // while the renderer draws (and bounds) its outline. If a metric box were a - // hair smaller than the ink — e.g. from rounding the bbox to the *nearest* - // 1/1024 — a hard no-collision verdict could be microscopically false on - // paper. The metrics are extracted as the outline bounds rounded *outward* - // to the grid, so every metric box must contain its outline box. - for m in epiphany_layout_ir::BRAVURA_METRICS { - let Some(o) = outline(m.name.as_ref()) else { - continue; - }; - let mb = m.bounding_box(); - let [ol, ob, oright, otop] = o.bbox; - assert!( - mb.left.0 <= ol && mb.bottom.0 <= ob && mb.right.0 >= oright && mb.top.0 >= otop, - "metric bbox {:?} for {} must contain its outline bbox {:?}", - [mb.left.0, mb.bottom.0, mb.right.0, mb.top.0], - m.name, - o.bbox, - ); - } - } - /// A minimal RFC-4648 base64 decoder for the integrity test (the crate has no /// base64 dependency); skips non-alphabet bytes, stops at padding. fn decode_base64(s: &str) -> Vec { @@ -220,12 +180,18 @@ mod tests { } #[test] - fn outlines_have_finite_bounds_and_nonempty_paths() { - for o in BRAVURA_OUTLINES { - assert!(o.bbox.iter().all(|v| v.is_finite())); - assert!(o.bbox[0] <= o.bbox[2] && o.bbox[1] <= o.bbox[3]); - assert!(!o.path.is_empty()); - assert!(o.path.starts_with('M'), "{} path must start with M", o.name); - } + fn outline_delegates_to_epiphany_glyphs() { + // A thin smoke test that the delegation is wired, not a re-test of + // epiphany-glyphs's own (more thorough) outline-data tests. + assert!(outline("noteheadBlack").is_some()); + assert!(outline("noSuchGlyph").is_none()); + assert_eq!( + bundled_glyph_count(), + epiphany_glyphs::bundled_glyph_count() + ); + assert_eq!( + smufl_codepoint("gClef"), + epiphany_glyphs::smufl_codepoint("gClef") + ); } }