Editor T4-pre W2: the glyph outlines become a shared typed seam

A canvas tessellator needs typed vector paths, and the outlines were private to
render-svg as SVG `d` strings. They move to a new epiphany-glyphs crate, which
implements layout-ir's GlyphCatalog with a real render_data.

The seam was already designed and merely unpopulated: PathCommand,
GlyphRenderData, and GlyphCatalog::render_data all existed, and BravuraCatalog
returned None by deliberate documented honesty -- reporting Some would claim
render data that does not exist. Like W1, this packet fills a structure rather
than building one.

Typed paths are parsed from the `d` strings rather than regenerated, because
Bravura.otf is not vendored -- tools/ carries only the extractor and its OFL
notice, and the generated header pins source hashes verified at extraction
time. Equivalence is therefore proven rather than assumed: parse every bundled
glyph, re-emit, compare byte-for-byte. All thirty-seven round-trip exactly, so
the sanctioned coordinate-comparison fallback was not needed. The real grammar
is wider than the contract's summary claimed -- absolute V/H lineto shorthand
appears in twenty-three of thirty-seven glyphs, and numbers carry nought to
three fractional digits with trailing zeros stripped, never a fixed four. The
parser lowers V/H to LineTo and the emitter reconstructs the shorthand from
geometry.

Nothing rendered moves. The outline data is byte-identical to its previous home
(the only edit is BravuraOutline's visibility), render-svg keeps emitting the
stored strings and re-exports bundled_glyph_count and smufl_codepoint
unchanged, and BRAVURA_METRICS is untouched -- it feeds metrics_hash and hence
GlyphCatalogIdentity, which is inside the resolved layout's canonical bytes.
Verified: all eight reference fixtures produce byte-identical canonical layouts,
and all five GUI goldens are unchanged.

The cross-table test locks what glyph.rs asserted only in prose: every glyph's
declared metrics bbox contains its drawn ink, re-derived through true
cubic-bezier extrema rather than control points. Worst-case deviation across the
whole table is exactly zero, at noteheadBlack's left edge. Stated honestly, this
is a rounding-chain lock rather than an independent cross-check -- the same
extractor emits both tables and deliberately rounds the metric box outward so it
always contains the ink -- but it catches a unit-conversion error, a
regeneration mismatch, or a future inward rounding, none of which had a test
before.

Five of six mutations killed, all coordinator-re-verified independently. The
sixth is an honest survivor and a defect in the contract rather than the work:
it asked for proof that re-emitting from the typed form breaks the goldens,
which the round-trip pin makes a no-op. The goldens were re-proven live a
different way -- perturbing one glyph's stored path by one digit flips one pixel
of 279,992 and fails all five.

Gate: 35 suites / 1351 tests / 0 failed, conformance 9/9 with golden-gate and
8/8 without, requirement labels 6/6, clippy 0, cargo tree showing exactly one
dependency and no third-party crate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 18:54:30 -04:00
parent 4e30fe8475
commit 24f8c8099a
17 changed files with 1085 additions and 92 deletions

8
Cargo.lock generated
View File

@ -1244,6 +1244,13 @@ dependencies = [
"epiphany-testkit", "epiphany-testkit",
] ]
[[package]]
name = "epiphany-glyphs"
version = "0.0.0"
dependencies = [
"epiphany-layout-ir",
]
[[package]] [[package]]
name = "epiphany-layout-ir" name = "epiphany-layout-ir"
version = "0.0.0" version = "0.0.0"
@ -1269,6 +1276,7 @@ dependencies = [
"epiphany-core", "epiphany-core",
"epiphany-determinism", "epiphany-determinism",
"epiphany-engrave", "epiphany-engrave",
"epiphany-glyphs",
"epiphany-layout-ir", "epiphany-layout-ir",
"epiphany-testkit", "epiphany-testkit",
] ]

View File

@ -7,6 +7,7 @@ members = [
"crates/epiphany-ops", "crates/epiphany-ops",
"crates/epiphany-textproj", "crates/epiphany-textproj",
"crates/epiphany-layout-ir", "crates/epiphany-layout-ir",
"crates/epiphany-glyphs",
"crates/epiphany-engrave", "crates/epiphany-engrave",
"crates/epiphany-render-svg", "crates/epiphany-render-svg",
"crates/epiphany-editor-core", "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 # (v0 acceptance criterion 6), so its path is declared here alongside the other
# intra-workspace crates. # intra-workspace crates.
epiphany-layout-ir = { path = "crates/epiphany-layout-ir" } 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 # Agent I's Phase-2 visible-slice crates: epiphany-engrave is the real
# constraint solver (Chapter 9 Minimal tier — in progress) consuming the # constraint solver (Chapter 9 Minimal tier — in progress) consuming the
# ConstrainedLayoutIR; epiphany-render-svg is the SVG renderer behind the # ConstrainedLayoutIR; epiphany-render-svg is the SVG renderer behind the

View File

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

View File

@ -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<PathCommand>>`, 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 `<path
d="…">` 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.

View File

@ -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<PathCommand>` 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<PathCommand>> {
static TABLE: OnceLock<BTreeMap<&'static str, Vec<PathCommand>>> = 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<GlyphRenderData> {
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"
);
}
}

View File

@ -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"
);
}
}

View File

@ -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};

View File

@ -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<u32> {
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);
}
}
}

View File

@ -18,8 +18,10 @@
/// One bundled glyph outline: SMuFL name, codepoint, the SVG path `d` in /// One bundled glyph outline: SMuFL name, codepoint, the SVG path `d` in
/// staff-space / y-up coordinates, and the outline's tight bounding box /// staff-space / y-up coordinates, and the outline's tight bounding box
/// `[left, bottom, right, top]` in staff spaces. /// `[left, bottom, right, top]` in staff spaces. `pub`: this crate's whole
pub(crate) struct BravuraOutline { /// 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 name: &'static str,
pub codepoint: u32, pub codepoint: u32,
pub path: &'static str, pub path: &'static str,

View File

@ -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<PathCommand> {
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::<f32>()
.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);
}
}

View File

@ -1,14 +1,23 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Extract genuine Bravura SMuFL glyph outlines into a Rust table. """Extract genuine Bravura SMuFL glyph outlines into a Rust table.
Reproducible generator for `epiphany-render-svg`'s bundled outline data. It Reproducible generator for `epiphany-glyphs`'s bundled outline data (moved
fetches the official OFL `Bravura.otf` and the SMuFL `glyphnames.json`, then here from `epiphany-render-svg` at Editor T4-pre W2, the shared typed
emits `src/outlines_generated.rs` with each glyph's outline as an SVG path in glyph-asset seam `render-svg` now depends on this crate instead of owning
**staff-space**, **y-up** coordinates (the renderer's coordinate system). 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: Usage:
python3 -m venv .venv && . .venv/bin/activate && pip install fonttools 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 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 © Steinberg Media Technologies GmbH under the SIL Open Font License 1.1; the
@ -250,8 +259,10 @@ def main():
o.append("") o.append("")
o.append("/// One bundled glyph outline: SMuFL name, codepoint, the SVG path `d` in") 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("/// staff-space / y-up coordinates, and the outline's tight bounding box")
o.append("/// `[left, bottom, right, top]` in staff spaces.") o.append("/// `[left, bottom, right, top]` in staff spaces. `pub`: this crate's whole")
o.append("pub(crate) struct BravuraOutline {") 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 name: &'static str,")
o.append(" pub codepoint: u32,") o.append(" pub codepoint: u32,")
o.append(" pub path: &'static str,") o.append(" pub path: &'static str,")
@ -273,6 +284,10 @@ def main():
print(f"// extracted {len(rows)}/{len(NAMES)} glyphs", file=sys.stderr) print(f"// extracted {len(rows)}/{len(NAMES)} glyphs", file=sys.stderr)
# Optionally emit the embedded-font subset (renderer GlyphMode::EmbeddedFont). # 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: if "--font-out" in sys.argv:
import fontTools import fontTools
out_path = sys.argv[sys.argv.index("--font-out") + 1] out_path = sys.argv[sys.argv.index("--font-out") + 1]

View File

@ -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 # The renderer consumes the Chapter 7 ResolvedLayoutIR / RenderIR and the glyph
# catalog, all defined in epiphany-layout-ir. # catalog, all defined in epiphany-layout-ir.
epiphany-layout-ir.workspace = true 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] [dev-dependencies]
# The demo binary's `--solver=real` path drives Agent I's engrave solver; only # The demo binary's `--solver=real` path drives Agent I's engrave solver; only

View File

@ -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` the acceptance snapshot prints it and the `provenance_count == glyph + stroke`
invariant became `+ curve`. `GlyphClass` is untouched — curves are not glyphs, invariant became `+ curve`. `GlyphClass` is untouched — curves are not glyphs,
so they carry no `data-class`. 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).

View File

@ -55,23 +55,30 @@ only (display scale, margin, provenance attributes, and `glyph_mode` — inline
## Bundled Bravura data ## Bundled Bravura data
Two generated artifacts come from the official OFL `Bravura.otf` via Two generated artifacts come from the official OFL `Bravura.otf` via
`tools/extract_bravura_outlines.py` — the font is **not vendored**, only the `epiphany-glyphs`'s `tools/extract_bravura_outlines.py` — the font is **not
generated Rust is committed: 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 - `epiphany-glyphs/src/outlines_generated.rs` — the inline glyph outlines
byte-stable across fontTools versions); (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 - `src/font_subset_generated.rs` — a base64 OTF **subset** (just the pipeline's
glyphs) for `GlyphMode::EmbeddedFont`. As a Modified Version, its primary font 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 name is renamed off the Reserved Font Name "Bravura" per the OFL; a content
BLAKE3 + decoded length are committed alongside as an integrity lock. BLAKE3 + decoded length are committed alongside as an integrity lock.
Bravura is © Steinberg Media Technologies GmbH under the SIL Open Font License 1.1 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): regenerate both (the subset step also needs the `blake3` package):
```sh ```sh
cd crates/epiphany-render-svg/tools cd crates/epiphany-glyphs/tools
python3 -m venv .venv && . .venv/bin/activate && pip install fonttools blake3 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 > ../src/outlines_generated.rs
``` ```

View File

@ -21,12 +21,17 @@
//! //!
//! ## What it draws, and the non-overreach rule //! ## What it draws, and the non-overreach rule
//! //!
//! The bundled outlines are extracted from the official OFL `Bravura.otf` (see //! The bundled outlines are extracted from the official OFL `Bravura.otf` in
//! `tools/extract_bravura_outlines.py` and `tools/OFL.txt`) in staff-space, //! staff-space, y-up coordinates, by `epiphany-glyphs`'s
//! y-up coordinates. The renderer makes SVG-encoding choices only and never //! `tools/extract_bravura_outlines.py` and redistributed under
//! engraving-semantic ones; see the private `svg` module for the coordinate //! `epiphany-glyphs/tools/OFL.txt` (Editor T4-pre W2 moved the bundled table
//! system, the provenance-tracing contract, and the diagnostic-not-paper-over //! and its extractor out of this crate into that shared seam; this crate
//! rule. //! 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 //! ## Font availability
//! //!
@ -38,14 +43,15 @@
//! * [`GlyphMode::EmbeddedFont`] references glyphs by SMuFL codepoint via a //! * [`GlyphMode::EmbeddedFont`] references glyphs by SMuFL codepoint via a
//! `<text>` element and an `@font-face`-embedded Bravura *subset* (the same //! `<text>` element and an `@font-face`-embedded Bravura *subset* (the same
//! SHA-pinned font the outlines come from, base64 in `font_subset_generated`, //! 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 //! 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 //! 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. //! construction, while exact rasterisation is the consumer's font renderer's.
mod font_subset_generated; mod font_subset_generated;
mod outline; mod outline;
mod outlines_generated;
mod svg; mod svg;
pub mod xml; pub mod xml;

View File

@ -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 /// How many glyph outlines are bundled. Re-exported from `epiphany-glyphs`
/// sorted by name, so this is a binary search. /// even though nothing outside `render-svg` calls it today — an API that
pub(crate) fn outline(name: &str) -> Option<&'static BravuraOutline> { /// costs one `pub use` is not worth breaking (W2 pin 2).
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 { 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 /// The SMuFL codepoint of a bundled glyph name, if bundled. Used by the
/// embedded-font render mode (which references glyphs by codepoint) and for /// embedded-font render mode (which references glyphs by codepoint) and for
/// debugging glyph identity. /// debugging glyph identity.
pub fn smufl_codepoint(name: &str) -> Option<u32> { pub fn smufl_codepoint(name: &str) -> Option<u32> {
outline(name).map(|o| o.codepoint) epiphany_glyphs::smufl_codepoint(name)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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 /// A minimal RFC-4648 base64 decoder for the integrity test (the crate has no
/// base64 dependency); skips non-alphabet bytes, stops at padding. /// base64 dependency); skips non-alphabet bytes, stops at padding.
fn decode_base64(s: &str) -> Vec<u8> { fn decode_base64(s: &str) -> Vec<u8> {
@ -220,12 +180,18 @@ mod tests {
} }
#[test] #[test]
fn outlines_have_finite_bounds_and_nonempty_paths() { fn outline_delegates_to_epiphany_glyphs() {
for o in BRAVURA_OUTLINES { // A thin smoke test that the delegation is wired, not a re-test of
assert!(o.bbox.iter().all(|v| v.is_finite())); // epiphany-glyphs's own (more thorough) outline-data tests.
assert!(o.bbox[0] <= o.bbox[2] && o.bbox[1] <= o.bbox[3]); assert!(outline("noteheadBlack").is_some());
assert!(!o.path.is_empty()); assert!(outline("noSuchGlyph").is_none());
assert!(o.path.starts_with('M'), "{} path must start with M", o.name); assert_eq!(
} bundled_glyph_count(),
epiphany_glyphs::bundled_glyph_count()
);
assert_eq!(
smufl_codepoint("gClef"),
epiphany_glyphs::smufl_codepoint("gClef")
);
} }
} }