epiphany/crates/epiphany-render-svg/src/svg.rs

990 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The SVG renderer: a [`ResolvedLayoutIR`] to well-formed SVG 1.1.
//!
//! ## What this renderer is, and is not
//!
//! It is a **renderer**, not an engraver. Per the QUICKSTART non-overreach rule
//! (`spec/PHASE2_QUICKSTART.md`, Agent I), every emitted SVG element traces to a
//! [`ResolvedGlyph`] (and thus to its score-graph source) or to a declared
//! renderer wrapper (the `<svg>` root, a metadata comment, the y-flip group, a
//! per-layer `<g>`). Provenance traces are on by default; turning them off (when
//! [`RenderOptions::emit_provenance`] is `false`) is an explicit display-only mode
//! that the metadata comment *declares*, so a trace-free SVG announces itself
//! rather than passing as an archival one. The renderer makes *SVG-encoding*
//! choices only — grouping,
//! `<path>` vs `<rect>` fallback, the viewBox, the coordinate flip, colour
//! representation. It makes **no engraving-semantic** choices (stem direction,
//! spacing, beam slope, accidental placement, glyph selection): those are the
//! solver's and the IR's. If a glyph it is asked to draw has no bundled outline,
//! it surfaces a [`Diagnostic`] and falls back to a visible bounding-box rect —
//! it never invents geometry.
//!
//! ## Coordinate system
//!
//! The IR is in **staff spaces, y-up** (musical convention: larger `y` = higher
//! pitch). SVG is y-down. The renderer keeps every coordinate in staff-space,
//! y-up world units and applies a single y-flip on the content group
//! (`translate(-min_x, max_y) scale(1, -1)`), so a world point `(x, y)` maps to
//! screen `(x - min_x, max_y - y)`. The `viewBox` is `0 0 W H` in staff spaces;
//! `width`/`height` carry the display scale in px
//! ([`RenderOptions::px_per_staff_space`]). Glyph outlines are bundled in the
//! same staff-space/y-up frame, so each is placed with a plain
//! `translate(x, y)`.
//!
//! ## Determinism
//!
//! Outline `d` data is fixed bundled text; every computed number is formatted to
//! at most 4 decimals with `-0` normalised to `0`, so identical input yields
//! byte-identical SVG (the acceptance snapshot golden-locks this).
use std::collections::BTreeMap;
use std::fmt::Write as _;
use epiphany_layout_ir::{BoundingBox, Provenance, ResolvedGlyph, ResolvedLayoutIR, Transform2D};
use crate::font_subset_generated::{
BRAVURA_SUBSET_FAMILY, BRAVURA_SUBSET_MIME, BRAVURA_SUBSET_OTF_BASE64,
};
use crate::outline::{outline, smufl_codepoint};
use crate::xml::{check_well_formed, escape_attr};
/// How glyphs are drawn.
#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
pub enum GlyphMode {
/// Inline genuine Bravura outline `<path>`s (default). Self-contained: the
/// SVG renders in any viewer with no font dependency (QUICKSTART, Agent I:
/// "inline path outlines for golden fixtures and the demonstrable
/// deliverable"). This is the byte-golden-locked, pixel-verified reference
/// mode.
#[default]
PathOutline,
/// Reference each glyph by its SMuFL codepoint with a `<text>` element, drawn
/// from an `@font-face`-embedded subset of Bravura (the same SHA-pinned font
/// the outlines come from, base64 in `font_subset_generated`). The result is
/// self-contained — the font travels in the SVG — and text-selectable, at the
/// cost of a larger file. Glyph placement is consistent with
/// [`GlyphMode::PathOutline`] by construction (same origin, em = 4 staff
/// spaces); exact glyph rasterisation is then the consumer's font renderer's.
EmbeddedFont,
}
/// Renderer configuration. SVG-encoding choices only — nothing here changes
/// engraving.
#[derive(Copy, Clone, PartialEq, Debug)]
pub struct RenderOptions {
/// Display scale: points (px) per staff space, applied as the `<svg>`
/// width/height while the viewBox stays in staff spaces.
pub px_per_staff_space: f32,
/// Blank margin around the content, in staff spaces.
pub margin: f32,
/// How glyphs are drawn.
pub glyph_mode: GlyphMode,
/// Emit `data-*` provenance attributes tracing each element to its source.
/// `true` (the default) is the archival/traceable mode; `false` is an explicit
/// display-only mode that the emitted SVG's metadata comment *declares*, so the
/// absence of traces is announced rather than silently produced.
pub emit_provenance: bool,
}
impl Default for RenderOptions {
fn default() -> Self {
RenderOptions {
px_per_staff_space: 10.0,
margin: 2.0,
glyph_mode: GlyphMode::PathOutline,
emit_provenance: true,
}
}
}
/// A coarse glyph category, used for the machine acceptance snapshot's
/// bounding-box-class counts.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub enum GlyphClass {
Notehead,
Clef,
Accidental,
Rest,
Flag,
TimeSignature,
Barline,
Dynamic,
AugmentationDot,
Other,
}
impl GlyphClass {
/// Classifies a SMuFL glyph name by its category prefix.
pub fn of(name: &str) -> GlyphClass {
if name.starts_with("notehead") {
GlyphClass::Notehead
} else if name.ends_with("Clef") {
GlyphClass::Clef
} else if name.starts_with("accidental") {
GlyphClass::Accidental
} else if name.starts_with("rest") {
GlyphClass::Rest
} else if name.starts_with("flag") {
GlyphClass::Flag
} else if name.starts_with("timeSig") {
GlyphClass::TimeSignature
} else if name.starts_with("barline") {
GlyphClass::Barline
} else if name.starts_with("dynamic") {
GlyphClass::Dynamic
} else if name == "augmentationDot" {
GlyphClass::AugmentationDot
} else {
GlyphClass::Other
}
}
/// A short, stable token for the SVG `data-class` attribute and snapshots.
pub fn token(self) -> &'static str {
match self {
GlyphClass::Notehead => "notehead",
GlyphClass::Clef => "clef",
GlyphClass::Accidental => "accidental",
GlyphClass::Rest => "rest",
GlyphClass::Flag => "flag",
GlyphClass::TimeSignature => "timeSig",
GlyphClass::Barline => "barline",
GlyphClass::Dynamic => "dynamic",
GlyphClass::AugmentationDot => "augmentationDot",
GlyphClass::Other => "other",
}
}
}
/// A non-fatal note the renderer surfaces instead of papering over a problem
/// (QUICKSTART, Agent I: "surface it via a diagnostic, don't paper over it").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Diagnostic {
pub message: String,
pub glyph: Option<String>,
}
/// Machine-readable facts about a render, for the golden-locked acceptance
/// snapshot (object/glyph/path/provenance/class counts + bounds).
#[derive(Clone, PartialEq, Debug)]
pub struct RenderStats {
/// Glyphs in the resolved layout (the renderer's input objects).
pub glyph_count: usize,
/// `<path>` elements emitted (glyphs drawn from a bundled outline, the
/// default [`GlyphMode::PathOutline`]).
pub path_count: usize,
/// `<text>` elements emitted (glyphs set in the embedded font, the
/// [`GlyphMode::EmbeddedFont`] mode). Zero in the default path mode.
pub text_count: usize,
/// Fallback `<rect>` elements emitted (glyphs with no bundled outline).
pub fallback_rect_count: usize,
/// `<line>` elements emitted (one per resolved stroke: staff line, stem, …).
pub stroke_count: usize,
/// Elements carrying a `data-prov` trace back to a score-graph source.
pub provenance_count: usize,
/// Distinct layers, each rendered as one `<g>` group.
pub layer_count: usize,
/// Per-class glyph counts.
pub class_counts: BTreeMap<GlyphClass, usize>,
/// The padded content bounds `[min_x, min_y, width, height]`, in staff
/// spaces. Note this is the *content extent*, not the emitted `viewBox`
/// attribute: the SVG is translated so its `viewBox` is always `0 0 W H`
/// (the `min_x`/`min_y` here are folded into the y-flip group's translate).
pub view_box: [f32; 4],
}
/// The result of a render: the SVG text, machine stats, and any diagnostics.
#[derive(Clone, PartialEq, Debug)]
pub struct RenderOutput {
pub svg: String,
pub stats: RenderStats,
pub diagnostics: Vec<Diagnostic>,
}
impl RenderOutput {
/// Whether the emitted SVG passes the in-crate well-formedness check.
pub fn is_well_formed(&self) -> bool {
check_well_formed(&self.svg).is_ok()
}
}
/// Renders a resolved layout to SVG. Pure and deterministic.
pub fn render(resolved: &ResolvedLayoutIR, options: &RenderOptions) -> RenderOutput {
let mut diagnostics = Vec::new();
let mut class_counts: BTreeMap<GlyphClass, usize> = BTreeMap::new();
for g in &resolved.glyphs {
*class_counts
.entry(GlyphClass::of(g.glyph.as_str()))
.or_insert(0) += 1;
}
// World bounds over each glyph's drawn extent (its outline bbox, or the IR
// bounding box for a glyph with no bundled outline).
let bounds = content_bounds(resolved, options.margin);
let (min_x, min_y, width, height) = match bounds {
Some(b) => b,
// Empty layout: a minimal, valid, honest empty canvas.
None => {
let svg = empty_svg(options.glyph_mode, options.emit_provenance);
let well_formed = check_well_formed(&svg).is_ok();
debug_assert!(well_formed);
return RenderOutput {
stats: RenderStats {
glyph_count: 0,
path_count: 0,
text_count: 0,
fallback_rect_count: 0,
stroke_count: 0,
provenance_count: 0,
layer_count: 0,
class_counts,
view_box: [0.0, 0.0, 1.0, 1.0],
},
svg,
diagnostics,
};
}
};
let max_y = min_y + height;
// Group glyphs and strokes by layer (ascending), preserving input order
// within. Strokes draw before glyphs at the same layer, so a staff line sits
// under the noteheads on it.
let mut glyph_layers: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
for (i, g) in resolved.glyphs.iter().enumerate() {
glyph_layers.entry(g.layer).or_default().push(i);
}
let mut stroke_layers: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
for (i, stroke) in resolved.strokes.iter().enumerate() {
stroke_layers.entry(stroke.layer).or_default().push(i);
}
let layer_ids: std::collections::BTreeSet<i32> = glyph_layers
.keys()
.chain(stroke_layers.keys())
.copied()
.collect();
let mut path_count = 0;
let mut text_count = 0;
let mut fallback_rect_count = 0;
let mut stroke_count = 0;
let mut provenance_count = 0;
let mut s = String::new();
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let _ = writeln!(
s,
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{}\" height=\"{}\" viewBox=\"0 0 {} {}\">",
num(width * options.px_per_staff_space),
num(height * options.px_per_staff_space),
num(width),
num(height),
);
// Declared metadata wrapper (a comment — honest about what this is, including
// how glyphs are drawn and whether provenance traces are present: suppressing
// them is an explicit display-only choice the output announces, not drops).
let _ = writeln!(
s,
" <!-- epiphany-render-svg: {}; geometry is the resolved layout \
verbatim, no engraving performed here; {} -->",
glyph_note(options.glyph_mode),
provenance_note(options.emit_provenance),
);
// In embedded-font mode, declare the Bravura subset once via `@font-face`; the
// `<text>` glyphs below reference it by its (non-reserved) family name (see the
// font subset's own header for its provenance and the OFL terms it carries).
if options.glyph_mode == GlyphMode::EmbeddedFont {
let _ = writeln!(
s,
" <defs><style>@font-face {{ font-family: \"{}\"; \
src: url(\"data:{};base64,{}\") format(\"opentype\"); }}</style></defs>",
BRAVURA_SUBSET_FAMILY, BRAVURA_SUBSET_MIME, BRAVURA_SUBSET_OTF_BASE64,
);
}
// The single y-flip wrapper: staff-space/y-up world -> SVG y-down.
let _ = writeln!(
s,
" <g transform=\"translate({} {}) scale(1 -1)\">",
num(-min_x),
num(max_y),
);
for layer in &layer_ids {
let _ = writeln!(s, " <g data-layer=\"{}\">", layer);
// Strokes (staff lines, stems, barlines, …) — drawn first so glyphs on
// the same layer sit over them.
if let Some(indices) = stroke_layers.get(layer) {
for &i in indices {
let stroke = &resolved.strokes[i];
let (stroke_fill, opacity) = stroke_colour(stroke.style.rgba);
let prov = if options.emit_provenance {
provenance_count += 1;
stroke_provenance_attrs(&stroke.provenance)
} else {
String::new()
};
stroke_count += 1;
let _ = writeln!(
s,
" <line x1=\"{}\" y1=\"{}\" x2=\"{}\" y2=\"{}\" \
stroke=\"{}\" stroke-width=\"{}\"{}{}/>",
num(stroke.from.x.0),
num(stroke.from.y.0),
num(stroke.to.x.0),
num(stroke.to.y.0),
stroke_fill,
num(stroke.thickness.0),
opacity,
prov,
);
}
}
if let Some(indices) = glyph_layers.get(layer) {
for &i in indices {
let g = &resolved.glyphs[i];
let name = g.glyph.as_str();
let (x, y) = (g.position.x.0, g.position.y.0);
// The glyph's resolved transform (scale/rotate/skew about its
// origin), composed after the placement translate. `None` ⇒ a bare
// translate, the common case.
let placement = placement_transform(x, y, &g.transform);
if let Some(t) = &g.transform {
if !is_affine(t) {
diagnostics.push(Diagnostic {
message: "non-affine (projective) glyph transform is not \
representable in SVG; rendered its affine projection"
.to_owned(),
glyph: Some(name.to_owned()),
});
}
}
let (fill, opacity) = colour(g.style.rgba);
let prov = if options.emit_provenance {
provenance_count += 1;
provenance_attrs(&g.provenance, name, GlyphClass::of(name))
} else {
String::new()
};
// The drawn element depends on the mode: an inline outline `<path>`
// (the self-contained default) or a `<text>` referencing the embedded
// Bravura by SMuFL codepoint. Both anchor at the same `(x, y)` origin,
// so the two modes are geometrically consistent. `None` (no bundled
// outline / no codepoint) falls through to the visible bbox rect.
let element = match options.glyph_mode {
GlyphMode::PathOutline => outline(name).map(|o| {
path_count += 1;
format!(
"<path d=\"{}\" transform=\"{}\" fill=\"{}\"{}{}/>",
o.path, placement, fill, opacity, prov,
)
}),
GlyphMode::EmbeddedFont => smufl_codepoint(name).map(|cp| {
text_count += 1;
// The font glyph is drawn upright by a per-glyph counter-flip
// (`scale(1 -1)`, innermost) cancelling the outer y-flip; the
// em is four staff spaces (SMuFL), so `font-size="4"`.
format!(
"<text transform=\"{placement} scale(1 -1)\" \
font-family=\"{BRAVURA_SUBSET_FAMILY}\" font-size=\"4\" \
fill=\"{fill}\"{opacity}{prov}>&#x{cp:X};</text>",
)
}),
};
match element {
Some(el) => {
let _ = writeln!(s, " {el}");
}
None => {
// Unrenderable in this mode: surface it and draw the IR
// bounding box so the missing glyph is visible, not silent.
fallback_rect_count += 1;
diagnostics.push(Diagnostic {
message: "no bundled Bravura glyph for this name; drew \
bounding-box fallback"
.to_owned(),
glyph: Some(name.to_owned()),
});
let bb = g.bounding_box;
let _ = writeln!(
s,
" <rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" \
transform=\"{}\" fill=\"none\" stroke=\"#cc0000\" \
stroke-width=\"0.05\"{}/>",
num(bb.left.0),
num(bb.bottom.0),
num((bb.right.0 - bb.left.0).max(0.0)),
num((bb.top.0 - bb.bottom.0).max(0.0)),
placement,
prov,
);
}
}
}
}
s.push_str(" </g>\n");
}
s.push_str(" </g>\n");
s.push_str("</svg>\n");
RenderOutput {
stats: RenderStats {
glyph_count: resolved.glyphs.len(),
path_count,
text_count,
fallback_rect_count,
stroke_count,
provenance_count,
layer_count: layer_ids.len(),
class_counts,
view_box: [num_f(min_x), num_f(min_y), num_f(width), num_f(height)],
},
svg: s,
diagnostics,
}
}
/// The content bounds `(min_x, min_y, width, height)` in staff spaces, padded by
/// `margin`, or `None` if there is nothing to draw.
fn content_bounds(resolved: &ResolvedLayoutIR, margin: f32) -> Option<(f32, f32, f32, f32)> {
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 any = false;
for g in &resolved.glyphs {
let bb = drawn_bbox(g);
let (x, y) = (g.position.x.0, g.position.y.0);
// All four bbox corners mapped through the *same* placement transform the
// renderer applies — a scale/rotation can push drawn geometry past the
// untransformed axis-aligned extent, which would crop it.
for (lx, ly) in [
(bb.left.0, bb.bottom.0),
(bb.right.0, bb.bottom.0),
(bb.left.0, bb.top.0),
(bb.right.0, bb.top.0),
] {
let (px, py) = placed_point(x, y, &g.transform, lx, ly);
if px.is_finite() && py.is_finite() {
any = true;
min_x = min_x.min(px);
min_y = min_y.min(py);
max_x = max_x.max(px);
max_y = max_y.max(py);
}
}
}
// Strokes extend the bounds by a half-thickness box around each endpoint, so a
// thick rule is not clipped perpendicular to its direction even at margin 0.
for stroke in &resolved.strokes {
let half = (stroke.thickness.0 * 0.5).max(0.0);
for point in [stroke.from, stroke.to] {
let (cx, cy) = (point.x.0, point.y.0);
for (px, py) in [(cx - half, cy - half), (cx + half, cy + half)] {
if px.is_finite() && py.is_finite() {
any = true;
min_x = min_x.min(px);
min_y = min_y.min(py);
max_x = max_x.max(px);
max_y = max_y.max(py);
}
}
}
}
if !any {
return None;
}
min_x -= margin;
min_y -= margin;
max_x += margin;
max_y += margin;
// Guard against a degenerate zero-area box (e.g. a single zero-size glyph).
let width = (max_x - min_x).max(f32::EPSILON);
let height = (max_y - min_y).max(f32::EPSILON);
Some((num_f(min_x), num_f(min_y), num_f(width), num_f(height)))
}
/// The bounding box the renderer uses for a glyph: its real outline extent when
/// bundled (what is actually drawn), else its IR bounding box.
fn drawn_bbox(g: &ResolvedGlyph) -> BoundingBox {
match outline(g.glyph.as_str()) {
Some(o) => BoundingBox::new(o.bbox[0], o.bbox[1], o.bbox[2], o.bbox[3]),
None => g.bounding_box,
}
}
/// `data-*` provenance attributes tracing an element to its score-graph source.
fn provenance_attrs(p: &Provenance, glyph: &str, class: GlyphClass) -> String {
format!(
" data-prov=\"{:032x}\" data-source-kind=\"{}\" data-glyph=\"{}\" data-class=\"{}\"",
p.stable_id.0,
p.source.discriminant(),
escape_attr(glyph),
class.token(),
)
}
/// `data-*` provenance attributes for a stroke (a non-glyph line primitive).
fn stroke_provenance_attrs(p: &Provenance) -> String {
format!(
" data-prov=\"{:032x}\" data-source-kind=\"{}\" data-kind=\"stroke\"",
p.stable_id.0,
p.source.discriminant(),
)
}
/// Whether a [`Transform2D`] is a pure 2-D affine — its bottom row is `[0, 0, 1]`
/// (within an `f32` tolerance). SVG transforms are affine, so a non-affine
/// (projective) transform cannot be represented; the renderer diagnoses it and
/// renders its affine projection.
fn is_affine(transform: &Transform2D) -> bool {
let bottom = transform.matrix[2];
bottom[0].abs() < 1e-6 && bottom[1].abs() < 1e-6 && (bottom[2] - 1.0).abs() < 1e-6
}
/// A glyph-local point mapped to world space through the renderer's placement
/// transform — the glyph's resolved affine applied about the origin, then
/// translated to `(px, py)`. Matches [`placement_transform`]'s SVG output (it
/// uses the affine projection, dropping any projective bottom row).
fn placed_point(px: f32, py: f32, transform: &Option<Transform2D>, lx: f32, ly: f32) -> (f32, f32) {
let (tx, ty) = match transform {
None => (lx, ly),
Some(t) => {
let m = t.matrix;
(
m[0][0] * lx + m[0][1] * ly + m[0][2],
m[1][0] * lx + m[1][1] * ly + m[1][2],
)
}
};
(px + tx, py + ty)
}
/// The SVG `transform` placing a glyph at `(x, y)` and applying its resolved
/// affine (`scale`/`rotate`/`skew` about the glyph origin) when present. The
/// affine is the inner (rightmost) transform, so the glyph's local outline is
/// transformed, then translated into place. `None` is the common case: a bare
/// translate, byte-identical to the pre-transform output. A non-affine transform
/// is rendered as its affine projection (the bottom row is dropped); the render
/// loop emits a diagnostic for that case.
fn placement_transform(x: f32, y: f32, transform: &Option<Transform2D>) -> String {
match transform {
None => format!("translate({} {})", num(x), num(y)),
Some(t) => {
let m = t.matrix;
// SVG matrix(a b c d e f) is the affine [[a c e],[b d f],[0 0 1]];
// map it from the row-major 3×3 (the bottom row is implicit).
format!(
"translate({} {}) matrix({} {} {} {} {} {})",
num(x),
num(y),
num(m[0][0]),
num(m[1][0]),
num(m[0][1]),
num(m[1][1]),
num(m[0][2]),
num(m[1][2]),
)
}
}
}
/// Splits an `0xRRGGBBAA` colour into an SVG `stroke` value and an optional
/// `stroke-opacity` attribute (empty when fully opaque).
fn stroke_colour(rgba: u32) -> (String, String) {
let r = (rgba >> 24) & 0xff;
let g = (rgba >> 16) & 0xff;
let b = (rgba >> 8) & 0xff;
let a = rgba & 0xff;
let stroke = format!("#{r:02x}{g:02x}{b:02x}");
let opacity = if a == 0xff {
String::new()
} else {
format!(" stroke-opacity=\"{}\"", num(a as f32 / 255.0))
};
(stroke, opacity)
}
/// Splits an `0xRRGGBBAA` colour into an SVG `fill` value and an optional
/// `fill-opacity` attribute (empty when fully opaque).
fn colour(rgba: u32) -> (String, String) {
let r = (rgba >> 24) & 0xff;
let g = (rgba >> 16) & 0xff;
let b = (rgba >> 8) & 0xff;
let a = rgba & 0xff;
let fill = format!("#{r:02x}{g:02x}{b:02x}");
let opacity = if a == 0xff {
String::new()
} else {
format!(" fill-opacity=\"{}\"", num(a as f32 / 255.0))
};
(fill, opacity)
}
/// The glyph-mode clause of the metadata comment: which drawing strategy produced
/// the SVG. Shared by the main render and [`empty_svg`] so the declared mode
/// boundary is the same on the empty path.
fn glyph_note(mode: GlyphMode) -> &'static str {
match mode {
GlyphMode::PathOutline => "glyphs are genuine Bravura SMuFL outlines inlined as paths",
GlyphMode::EmbeddedFont => "glyphs are Bravura SMuFL codepoints set in the embedded font",
}
}
/// The provenance-state clause of the metadata comment. Archival mode declares
/// traces present; display-only mode declares them suppressed — so a trace-free
/// SVG (including the empty canvas) announces itself rather than passing as
/// archival. Shared by the main render and [`empty_svg`] so neither can drift.
fn provenance_note(emit_provenance: bool) -> &'static str {
if emit_provenance {
"every glyph and stroke carries a data-prov trace to its score-graph source"
} else {
"provenance traces suppressed (display-only output, not archival)"
}
}
/// A minimal, valid empty SVG for a layout with nothing to draw — still declaring
/// its glyph mode and provenance state, so an empty render is honest like a full
/// one (the metadata is the same declared boundary on both paths).
fn empty_svg(glyph_mode: GlyphMode, emit_provenance: bool) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1\" height=\"1\" viewBox=\"0 0 1 1\">\n\
\x20\x20<!-- epiphany-render-svg: empty resolved layout (no glyphs); {}; {} -->\n\
</svg>\n",
glyph_note(glyph_mode),
provenance_note(emit_provenance),
)
}
/// Formats a coordinate to at most 4 decimals, trimming trailing zeros and
/// normalising `-0` to `0` for deterministic, compact output.
fn num(v: f32) -> String {
let v = num_f(v);
let mut s = format!("{v:.4}");
if s.contains('.') {
while s.ends_with('0') {
s.pop();
}
if s.ends_with('.') {
s.pop();
}
}
s
}
/// Normalises a coordinate value: `-0.0` (which compares equal to `0.0`) maps to
/// `0.0`; every other value passes through unchanged. Keeps the formatted form
/// and the stored stats agreeing.
fn num_f(v: f32) -> f32 {
if v == 0.0 {
0.0
} else {
v
}
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::generators::valid_score_rich;
use epiphany_layout_ir::{
to_constrained, to_logical, ConstraintSolver, GlyphStyle, Point, SolverConfig, StaffSpace,
Stroke, StubSolver, Transform2D,
};
fn stub_layout(seed: u64) -> ResolvedLayoutIR {
let constrained = to_constrained(&to_logical(&valid_score_rich(seed)));
StubSolver
.solve(&constrained, &SolverConfig::default())
.layout
}
#[test]
fn a_stroke_renders_as_a_traced_line() {
let mut layout = stub_layout(11);
let glyph_count = layout.glyphs.len();
// The engraver already emits strokes (staff lines, stems, …); this adds
// one more and checks it renders and traces on top of them.
let base_strokes = layout.strokes.len();
layout.strokes.push(Stroke {
provenance: layout.glyphs[0].provenance.clone(),
from: Point::new(0.0, 0.0),
to: Point::new(4.0, 0.0),
thickness: StaffSpace(0.13),
layer: -1,
style: GlyphStyle { rgba: 0x0000_00ff },
});
let out = render(&layout, &RenderOptions::default());
assert!(
out.is_well_formed(),
"SVG with a stroke must be well-formed"
);
assert_eq!(out.stats.stroke_count, base_strokes + 1);
assert!(
out.svg.contains("<line "),
"the stroke is drawn as a <line>"
);
assert!(
out.svg.contains("data-kind=\"stroke\""),
"the stroke carries a provenance trace"
);
assert_eq!(
out.stats.provenance_count,
glyph_count + base_strokes + 1,
"every glyph and stroke is traced"
);
}
#[test]
fn a_glyph_transform_is_applied_not_dropped() {
let mut layout = stub_layout(11);
// A 2× scale about the glyph origin: the renderer must emit it, not ignore
// it (otherwise a future solver's transform would silently disappear).
layout.glyphs[0].transform = Some(Transform2D {
matrix: [[2.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 1.0]],
});
let out = render(&layout, &RenderOptions::default());
assert!(out.is_well_formed());
assert!(
out.svg.contains("matrix(2 0 0 2 0 0)"),
"the glyph's resolved transform is applied"
);
}
#[test]
fn glyph_transform_expands_the_view_box() {
let base = stub_layout(11);
let base_width = render(&base, &RenderOptions::default()).stats.view_box[2];
let mut transformed = base.clone();
// Translate one glyph far to the right via its transform; the bounds must
// grow to contain it (untransformed bounds would crop it).
transformed.glyphs[0].transform = Some(Transform2D {
matrix: [[1.0, 0.0, 1000.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
});
let width = render(&transformed, &RenderOptions::default())
.stats
.view_box[2];
assert!(
width > base_width + 900.0,
"a transformed glyph widens the viewBox ({width} vs {base_width})"
);
}
#[test]
fn thick_stroke_expands_bounds_by_half_width() {
let mut layout = stub_layout(11);
let provenance = layout.glyphs[0].provenance.clone();
layout.glyphs.clear();
layout.strokes.push(Stroke {
provenance,
from: Point::new(0.0, 0.0),
to: Point::new(4.0, 0.0),
thickness: StaffSpace(2.0),
layer: 0,
style: GlyphStyle::default(),
});
let options = RenderOptions {
margin: 0.0,
..RenderOptions::default()
};
let height = render(&layout, &options).stats.view_box[3];
// A horizontal rule of thickness 2 spans y ∈ [1, 1]: a half-width each
// side, so the perpendicular extent is at least the full thickness.
assert!(
height >= 2.0,
"half-thickness expands the perpendicular extent (got {height})"
);
}
#[test]
fn non_affine_transform_is_diagnosed() {
let mut layout = stub_layout(11);
// A non-zero bottom row makes the transform projective, not affine.
layout.glyphs[0].transform = Some(Transform2D {
matrix: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.5, 0.0, 1.0]],
});
let out = render(&layout, &RenderOptions::default());
assert!(out.is_well_formed());
assert!(
out.diagnostics
.iter()
.any(|d| d.message.contains("non-affine")),
"a projective transform is surfaced as a diagnostic, not silently mis-rendered"
);
}
#[test]
fn renders_well_formed_svg_with_one_path_per_glyph() {
let layout = stub_layout(11);
let out = render(&layout, &RenderOptions::default());
assert!(out.is_well_formed(), "emitted SVG must be well-formed");
assert_eq!(out.stats.glyph_count, layout.glyphs.len());
// The stub pipeline only names bundled glyphs, so every glyph is a path.
assert_eq!(out.stats.path_count, layout.glyphs.len());
assert_eq!(out.stats.fallback_rect_count, 0);
assert!(out.diagnostics.is_empty());
assert_eq!(
out.stats.provenance_count,
layout.glyphs.len() + layout.strokes.len()
);
assert!(out.svg.contains("<svg"));
assert!(out.svg.contains("data-prov="));
}
#[test]
fn render_is_deterministic() {
let layout = stub_layout(3);
assert_eq!(
render(&layout, &RenderOptions::default()).svg,
render(&layout, &RenderOptions::default()).svg
);
}
#[test]
fn embedded_font_mode_sets_text_from_the_embedded_subset() {
let layout = stub_layout(11);
let out = render(
&layout,
&RenderOptions {
glyph_mode: GlyphMode::EmbeddedFont,
..RenderOptions::default()
},
);
assert!(
out.is_well_formed(),
"embedded-font SVG must be well-formed"
);
// The font is declared exactly once via @font-face with the base64 subset,
// under its non-reserved family name (OFL: not the Reserved Font Name).
assert_eq!(out.svg.matches("@font-face").count(), 1);
assert!(out.svg.contains("font-family: \"EpiphanyBravuraSubset\""));
assert!(!out.svg.contains("font-family: \"Bravura\""));
assert!(out.svg.contains("data:font/otf;base64,"));
// Every glyph is a `<text>` (the stub names only bundled glyphs), none a
// path or a fallback rect, and each carries a SMuFL codepoint reference.
assert_eq!(out.stats.text_count, layout.glyphs.len());
assert_eq!(out.stats.path_count, 0);
assert_eq!(out.stats.fallback_rect_count, 0);
assert!(out.diagnostics.is_empty());
assert_eq!(out.svg.matches("<text ").count(), layout.glyphs.len());
assert_eq!(out.svg.matches("&#x").count(), layout.glyphs.len());
// The metadata comment declares the embedded-font mode, and provenance is
// preserved exactly as in path mode.
assert!(out.svg.contains("set in the embedded font"));
assert_eq!(
out.stats.provenance_count,
layout.glyphs.len() + layout.strokes.len()
);
assert!(out.svg.contains("data-prov="));
}
#[test]
fn embedded_font_mode_is_deterministic_and_draws_every_glyph() {
let layout = stub_layout(7);
let a = render(
&layout,
&RenderOptions {
glyph_mode: GlyphMode::EmbeddedFont,
..RenderOptions::default()
},
);
let b = render(
&layout,
&RenderOptions {
glyph_mode: GlyphMode::EmbeddedFont,
..RenderOptions::default()
},
);
assert_eq!(a.svg, b.svg, "embedded-font render must be deterministic");
// Every glyph is accounted for (text or fallback rect), none dropped — the
// same no-silent-drop contract as path mode.
assert_eq!(
a.stats.text_count + a.stats.fallback_rect_count,
a.stats.glyph_count
);
// The default path mode draws no `<text>`; the modes do not bleed.
let path = render(&layout, &RenderOptions::default());
assert_eq!(path.stats.text_count, 0);
assert!(!path.svg.contains("@font-face"));
}
#[test]
fn empty_layout_renders_a_valid_empty_canvas() {
let layout = ResolvedLayoutIR {
source: Default::default(),
pages: vec![],
glyphs: vec![],
strokes: vec![],
engraving_decisions: vec![],
catalog: Default::default(),
};
let out = render(&layout, &RenderOptions::default());
assert!(out.is_well_formed());
assert_eq!(out.stats.glyph_count, 0);
assert_eq!(out.stats.path_count, 0);
// Even with nothing to draw, suppressing provenance is declared display-only
// — the empty canvas is held to the same honesty contract as a full render.
let suppressed = render(
&layout,
&RenderOptions {
emit_provenance: false,
..RenderOptions::default()
},
);
assert!(suppressed.is_well_formed());
assert!(suppressed.svg.contains("provenance traces suppressed"));
// The empty canvas also declares its glyph mode (the same boundary as a
// full render), so an empty embedded render is not mistaken for a path one.
let empty_embedded = render(
&layout,
&RenderOptions {
glyph_mode: GlyphMode::EmbeddedFont,
..RenderOptions::default()
},
);
assert!(empty_embedded.svg.contains("set in the embedded font"));
}
#[test]
fn provenance_can_be_suppressed_but_is_declared() {
let layout = stub_layout(5);
let out = render(
&layout,
&RenderOptions {
emit_provenance: false,
..RenderOptions::default()
},
);
assert_eq!(out.stats.provenance_count, 0);
assert!(!out.svg.contains("data-prov="));
assert!(out.is_well_formed());
// Suppression is announced, not silent: the metadata comment declares the
// output display-only, so it can't be mistaken for a traceable/archival one.
assert!(
out.svg.contains("provenance traces suppressed"),
"a trace-free SVG must declare itself display-only"
);
// The default (archival) render instead declares that traces are present.
let archival = render(&layout, &RenderOptions::default());
assert!(archival.svg.contains("data-prov trace"));
assert!(!archival.svg.contains("provenance traces suppressed"));
}
#[test]
fn classes_partition_the_glyphs() {
let layout = stub_layout(7);
let out = render(&layout, &RenderOptions::default());
let total: usize = out.stats.class_counts.values().sum();
assert_eq!(total, layout.glyphs.len());
}
}