//! 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 `` root, a metadata comment, the y-flip group, a //! per-layer ``). 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, //! `` vs `` 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 ``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 `` 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 `` /// 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, } /// 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, /// `` elements emitted (glyphs drawn from a bundled outline, the /// default [`GlyphMode::PathOutline`]). pub path_count: usize, /// `` elements emitted (glyphs set in the embedded font, the /// [`GlyphMode::EmbeddedFont`] mode). Zero in the default path mode. pub text_count: usize, /// Fallback `` elements emitted (glyphs with no bundled outline). pub fallback_rect_count: usize, /// `` 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 `` group. pub layer_count: usize, /// Per-class glyph counts. pub class_counts: BTreeMap, /// 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, } 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 = 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> = BTreeMap::new(); for (i, g) in resolved.glyphs.iter().enumerate() { glyph_layers.entry(g.layer).or_default().push(i); } let mut stroke_layers: BTreeMap> = 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 = 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("\n"); let _ = writeln!( s, "", 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, " ", glyph_note(options.glyph_mode), provenance_note(options.emit_provenance), ); // In embedded-font mode, declare the Bravura subset once via `@font-face`; the // `` 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, " ", 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, " ", num(-min_x), num(max_y), ); for layer in &layer_ids { let _ = writeln!(s, " ", 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, " ", 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 `` // (the self-contained default) or a `` 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!( "", 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!( "&#x{cp:X};", ) }), }; 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, " ", 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(" \n"); } s.push_str(" \n"); s.push_str("\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, 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) -> 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!( "\n\ \n\ \x20\x20\n\ \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("" ); 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("` (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("`; 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()); } }