646 lines
25 KiB
Rust
646 lines
25 KiB
Rust
//! Stage 3 — `ResolvedLayoutIR` (Chapter 7 §"ResolvedLayoutIR").
|
||
//!
|
||
//! The output of the constraint solver: every glyph has a definitive position.
|
||
//! This is the IR the renderer consumes. v0 carries the resolved glyphs, the
|
||
//! engraving decisions (including any the solver itself made), and the catalog
|
||
//! identity under which the solve ran (Chapter 7 §7.3.2 / Chapter 9
|
||
//! within-implementation determinism), together with the page/system interface
|
||
//! populated by casting-off implementations.
|
||
//!
|
||
//! ## Canonical serialization
|
||
//!
|
||
//! Positions are working `f32` staff-space coordinates ([`Point`]); the
|
||
//! **canonical** form quantizes them to the `1/1024` grid at serialization time
|
||
//! (Appendix D §"Quantized Layout Coordinates"). [`ResolvedLayoutIR`] implements
|
||
//! [`CanonicalEncode`] over its *full* content — every glyph's provenance
|
||
//! (source, stable id, synthesis kind, dependencies) and quantized position,
|
||
//! every engraving decision, and the complete catalog identity — so two layouts
|
||
//! that differ in any of these produce different canonical bytes. A non-finite or
|
||
//! out-of-range coordinate is a determinism violation; it is **rejected** with a
|
||
//! panic (faulting in every build, debug and release alike), never silently
|
||
//! normalized to the origin (Appendix D: invalid geometry is rejected, not
|
||
//! aliased).
|
||
|
||
use epiphany_core::{MeasureId, StaffId, TypedObjectId};
|
||
use epiphany_determinism::{CanonicalEncode, CanonicalF64, QuantizedCoord};
|
||
|
||
use crate::constrained::{Curve, GlyphObjectId, GlyphStyle, Stroke};
|
||
use crate::engraving::{DecisionSource, EngravingDecision, EngravingDecisionKind};
|
||
use crate::glyph::{GlyphCatalogIdentity, GlyphReference};
|
||
use crate::logical::ScoreVersion;
|
||
use crate::provenance::{Provenance, SynthesisKind};
|
||
use crate::spatial::{BoundingBox, Margins, Point, Rect, Size2D, StaffSpace, Transform2D};
|
||
use crate::StemDirection;
|
||
|
||
/// A glyph with a definitive position (Chapter 7 §"ResolvedLayoutIR":
|
||
/// `ResolvedGlyph`). Carries the SMuFL [`GlyphReference`] so the renderer knows
|
||
/// *what symbol to draw*, and
|
||
/// the `f32` staff-space position; canonical output quantizes the position (see
|
||
/// the module's canonical-serialization note).
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct ResolvedGlyph {
|
||
pub provenance: Provenance,
|
||
/// The SMuFL glyph to draw (carried from the constrained glyph).
|
||
pub glyph: GlyphReference,
|
||
pub position: Point,
|
||
pub transform: Option<Transform2D>,
|
||
pub bounding_box: BoundingBox,
|
||
pub style: GlyphStyle,
|
||
pub layer: i32,
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct ResolvedPage {
|
||
pub provenance: Provenance,
|
||
pub number: u32,
|
||
pub size: Size2D,
|
||
pub margins: Margins,
|
||
pub systems: Vec<ResolvedSystem>,
|
||
pub free_objects: Vec<GlyphObjectId>,
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct ResolvedSystem {
|
||
pub provenance: Provenance,
|
||
pub bounding_box: Rect,
|
||
pub staves: Vec<ResolvedStaff>,
|
||
pub measures: Vec<ResolvedMeasure>,
|
||
/// Which of the layout's flat `glyphs`/`strokes`/`curves` this system
|
||
/// owns — index lists, not copies (see [`PrimitiveIndices`]). A primitive
|
||
/// no system claims is not omitted; it is in [`ResolvedLayoutIR::unowned`]
|
||
/// instead, so the partition stays total (Chapter 7's flat arrays are
|
||
/// otherwise untouched by this field's existence — no primitive is split,
|
||
/// merged, reordered, or renumbered to populate it).
|
||
pub primitives: PrimitiveIndices,
|
||
}
|
||
|
||
/// Index lists into a [`ResolvedLayoutIR`]'s flat `glyphs`/`strokes`/`curves`
|
||
/// arrays — never the primitives themselves. `u32`: matches the flat arrays'
|
||
/// own canonical count-prefix width and comfortably exceeds any real layout
|
||
/// (no resolved layout nears 4 billion primitives).
|
||
///
|
||
/// One of these lives on every [`ResolvedSystem`] (what it owns) and one on
|
||
/// [`ResolvedLayoutIR`] itself (`unowned`: claimed by no system — e.g. every
|
||
/// primitive under the stub solver, which resolves no per-system geometry
|
||
/// and so publishes everything unowned rather than fabricating an
|
||
/// attribution). For each of the three flat arrays, the union of every
|
||
/// system's list plus the unowned bucket is exactly `0..len`, each index
|
||
/// appearing exactly once — a tested invariant (`epiphany-engrave`'s casting
|
||
/// module), not merely a convention.
|
||
///
|
||
/// **Not part of the canonical encoding** (see the exclusion note on
|
||
/// [`ResolvedLayoutIR::canonical_bytes`]): this partition draws nothing, so
|
||
/// two layouts differing only in it render identically and hash alike.
|
||
#[derive(Clone, PartialEq, Eq, Debug, Default)]
|
||
pub struct PrimitiveIndices {
|
||
pub glyphs: Vec<u32>,
|
||
pub strokes: Vec<u32>,
|
||
pub curves: Vec<u32>,
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct ResolvedStaff {
|
||
pub provenance: Provenance,
|
||
pub staff: StaffId,
|
||
pub bounding_box: Rect,
|
||
}
|
||
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct ResolvedMeasure {
|
||
pub provenance: Provenance,
|
||
pub measure: MeasureId,
|
||
pub bounding_box: Rect,
|
||
}
|
||
|
||
/// The resolved IR: every glyph positioned (Chapter 7 §"ResolvedLayoutIR").
|
||
#[derive(Clone, PartialEq, Debug)]
|
||
pub struct ResolvedLayoutIR {
|
||
pub source: ScoreVersion,
|
||
pub pages: Vec<ResolvedPage>,
|
||
pub glyphs: Vec<ResolvedGlyph>,
|
||
/// Resolved non-glyph line primitives (staff lines, stems, barlines, …),
|
||
/// positioned by the solver alongside the glyphs.
|
||
pub strokes: Vec<Stroke>,
|
||
/// Resolved cubic-bézier curve primitives (slurs, …), positioned by the
|
||
/// solver alongside the glyphs and strokes.
|
||
pub curves: Vec<Curve>,
|
||
pub engraving_decisions: Vec<EngravingDecision>,
|
||
/// The catalog identity under which this layout was produced — required for
|
||
/// any byte-equal conformance claim (Chapter 7 §7.3.2).
|
||
pub catalog: GlyphCatalogIdentity,
|
||
/// The primitives no system claims — the other half of the total,
|
||
/// disjoint partition [`ResolvedSystem::primitives`] describes. See
|
||
/// [`PrimitiveIndices`].
|
||
pub unowned: PrimitiveIndices,
|
||
}
|
||
|
||
impl ResolvedLayoutIR {
|
||
/// Every system across every page, in page order (a page's systems in
|
||
/// their own stored order, pages visited in `pages` order) — top to
|
||
/// bottom, reading order. `epiphany-editor-core` hand-rolls this same
|
||
/// flatten today (`editor-core/src/lib.rs`); it adopts this accessor at
|
||
/// T4, not before.
|
||
pub fn systems(&self) -> impl Iterator<Item = &ResolvedSystem> {
|
||
self.pages.iter().flat_map(|page| page.systems.iter())
|
||
}
|
||
|
||
/// The canonical serialized output (Appendix D §"Quantized Layout
|
||
/// Coordinates"): the layout's *rendering fingerprint*, with glyph positions
|
||
/// quantized to the `1/1024` grid. Equivalent to
|
||
/// [`CanonicalEncode::to_canonical_bytes`].
|
||
///
|
||
/// It encodes what a conformant renderer draws and what a conformance claim
|
||
/// compares — every primitive's provenance, geometry, style, and layer — and
|
||
/// **excludes non-canonical layout-attribution metadata**. Concretely:
|
||
/// [`ResolvedGlyph`] drops its band on the way out of the constrained stage,
|
||
/// while [`Stroke`] and [`Curve`] (whose types are shared with that stage)
|
||
/// carry `vertical_band` through but do not encode it. Band ownership tells a
|
||
/// vertical solver which staff owns a primitive; it draws nothing, so two
|
||
/// layouts differing only in it are the same rendered layout and hash alike.
|
||
/// **Per-system primitive ownership is excluded for the identical reason**:
|
||
/// [`ResolvedSystem::primitives`] and [`ResolvedLayoutIR::unowned`] name
|
||
/// which system (or no system) a primitive belongs to, and draw nothing —
|
||
/// two layouts differing only in that partition are the same rendered
|
||
/// layout and hash alike. Pinning it on the wire (if a future normative
|
||
/// requirement wants that) is a spec-side schema-major decision, not one
|
||
/// this type makes silently; see `DECISIONS.md`.
|
||
///
|
||
/// Two solves whose internal f32 computations agree to better than `1/2048`
|
||
/// staff space at every coordinate produce identical bytes; two layouts that
|
||
/// differ in any provenance, engraving decision, or catalog field produce
|
||
/// different bytes. Panics on a non-finite or out-of-range coordinate (a
|
||
/// determinism violation that must be rejected, not normalized).
|
||
pub fn canonical_bytes(&self) -> Vec<u8> {
|
||
self.to_canonical_bytes()
|
||
}
|
||
}
|
||
|
||
impl CanonicalEncode for ResolvedLayoutIR {
|
||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||
out.extend_from_slice(&self.source.0);
|
||
push_len(out, self.pages.len());
|
||
for page in &self.pages {
|
||
encode_page(out, page);
|
||
}
|
||
push_len(out, self.glyphs.len());
|
||
for glyph in &self.glyphs {
|
||
encode_provenance(out, &glyph.provenance);
|
||
// The glyph reference itself (so swapping two glyphs' symbols, even
|
||
// with the consulted-name set unchanged, changes the canonical bytes
|
||
// — the encoding is injective in glyph identity).
|
||
let name = glyph.glyph.as_str().as_bytes();
|
||
push_len(out, name.len());
|
||
out.extend_from_slice(name);
|
||
let (qx, qy) = quantize(glyph.position);
|
||
qx.encode_canonical(out);
|
||
qy.encode_canonical(out);
|
||
match glyph.transform {
|
||
None => out.push(0),
|
||
Some(transform) => {
|
||
out.push(1);
|
||
for row in transform.matrix {
|
||
for value in row {
|
||
encode_f32(out, value);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
encode_bounding_box(out, glyph.bounding_box);
|
||
out.extend_from_slice(&glyph.style.rgba.to_le_bytes());
|
||
out.extend_from_slice(&glyph.layer.to_le_bytes());
|
||
}
|
||
push_len(out, self.strokes.len());
|
||
for stroke in &self.strokes {
|
||
encode_provenance(out, &stroke.provenance);
|
||
let (fx, fy) = quantize(stroke.from);
|
||
fx.encode_canonical(out);
|
||
fy.encode_canonical(out);
|
||
let (tx, ty) = quantize(stroke.to);
|
||
tx.encode_canonical(out);
|
||
ty.encode_canonical(out);
|
||
encode_staff_space(out, stroke.thickness);
|
||
out.extend_from_slice(&stroke.style.rgba.to_le_bytes());
|
||
out.extend_from_slice(&stroke.layer.to_le_bytes());
|
||
}
|
||
push_len(out, self.curves.len());
|
||
for curve in &self.curves {
|
||
encode_provenance(out, &curve.provenance);
|
||
for point in curve.control_points() {
|
||
let (qx, qy) = quantize(point);
|
||
qx.encode_canonical(out);
|
||
qy.encode_canonical(out);
|
||
}
|
||
encode_staff_space(out, curve.thickness);
|
||
out.extend_from_slice(&curve.style.rgba.to_le_bytes());
|
||
out.extend_from_slice(&curve.layer.to_le_bytes());
|
||
out.push(match curve.line {
|
||
epiphany_core::LineStyle::Solid => 0,
|
||
epiphany_core::LineStyle::Dashed => 1,
|
||
epiphany_core::LineStyle::Dotted => 2,
|
||
});
|
||
}
|
||
push_len(out, self.engraving_decisions.len());
|
||
for decision in &self.engraving_decisions {
|
||
encode_decision(out, decision);
|
||
}
|
||
encode_catalog(out, &self.catalog);
|
||
}
|
||
}
|
||
|
||
fn encode_page(out: &mut Vec<u8>, page: &ResolvedPage) {
|
||
encode_provenance(out, &page.provenance);
|
||
out.extend_from_slice(&page.number.to_le_bytes());
|
||
encode_staff_space(out, page.size.width);
|
||
encode_staff_space(out, page.size.height);
|
||
for margin in [
|
||
page.margins.top,
|
||
page.margins.right,
|
||
page.margins.bottom,
|
||
page.margins.left,
|
||
] {
|
||
encode_staff_space(out, margin);
|
||
}
|
||
push_len(out, page.systems.len());
|
||
for system in &page.systems {
|
||
encode_provenance(out, &system.provenance);
|
||
encode_rect(out, system.bounding_box);
|
||
push_len(out, system.staves.len());
|
||
for staff in &system.staves {
|
||
encode_provenance(out, &staff.provenance);
|
||
out.extend_from_slice(&staff.staff.canonical_bytes());
|
||
encode_rect(out, staff.bounding_box);
|
||
}
|
||
push_len(out, system.measures.len());
|
||
for measure in &system.measures {
|
||
encode_provenance(out, &measure.provenance);
|
||
out.extend_from_slice(&measure.measure.canonical_bytes());
|
||
encode_rect(out, measure.bounding_box);
|
||
}
|
||
}
|
||
push_len(out, page.free_objects.len());
|
||
for object in &page.free_objects {
|
||
push_u128(out, object.0);
|
||
}
|
||
}
|
||
|
||
fn encode_rect(out: &mut Vec<u8>, rect: Rect) {
|
||
let (x, y) = quantize(rect.origin);
|
||
x.encode_canonical(out);
|
||
y.encode_canonical(out);
|
||
encode_staff_space(out, rect.size.width);
|
||
encode_staff_space(out, rect.size.height);
|
||
}
|
||
|
||
fn encode_bounding_box(out: &mut Vec<u8>, bounds: BoundingBox) {
|
||
for coordinate in [bounds.left, bounds.bottom, bounds.right, bounds.top] {
|
||
encode_staff_space(out, coordinate);
|
||
}
|
||
}
|
||
|
||
fn encode_staff_space(out: &mut Vec<u8>, value: StaffSpace) {
|
||
value
|
||
.quantize()
|
||
.unwrap_or_else(|| panic!("invalid staff-space value in canonical layout"))
|
||
.encode_canonical(out);
|
||
}
|
||
|
||
fn encode_f32(out: &mut Vec<u8>, value: f32) {
|
||
CanonicalF64::new(value as f64)
|
||
.unwrap_or_else(|| panic!("non-finite transform in canonical layout"))
|
||
.encode_canonical(out);
|
||
}
|
||
|
||
/// Appends a `u32` little-endian length/count prefix (schema major 1: the
|
||
/// resolved-layout unifies its length prefixes to `u32`, matching the core
|
||
/// codec's `put_len`; no resolved-layout count nears 4 GB). The resolved layout
|
||
/// is a non-canonical, encode-only determinism fingerprint (Appendix D
|
||
/// §"Quantized Layout Coordinates"), so this width change has no persisted-format
|
||
/// migration — a cross-major layout cache is regenerated, never decoded.
|
||
fn push_len(out: &mut Vec<u8>, n: usize) {
|
||
debug_assert!(n <= u32::MAX as usize, "resolved-layout length exceeds u32");
|
||
out.extend_from_slice(&(n as u32).to_le_bytes());
|
||
}
|
||
|
||
fn push_u128(out: &mut Vec<u8>, v: u128) {
|
||
out.extend_from_slice(&v.to_le_bytes());
|
||
}
|
||
|
||
/// Quantizes a working f32 position to the canonical grid, **rejecting** a
|
||
/// non-finite or out-of-range coordinate with a panic (Appendix D: invalid
|
||
/// geometry must be rejected, not aliased to the origin).
|
||
fn quantize(p: Point) -> (QuantizedCoord, QuantizedCoord) {
|
||
p.quantize().unwrap_or_else(|| {
|
||
panic!("non-finite or out-of-range resolved coordinate in canonical output")
|
||
})
|
||
}
|
||
|
||
/// Length-prefixes an id's canonical bytes (self-delimiting).
|
||
fn encode_source(out: &mut Vec<u8>, source: &TypedObjectId) {
|
||
let bytes = source.to_canonical_bytes();
|
||
push_len(out, bytes.len());
|
||
out.extend_from_slice(&bytes);
|
||
}
|
||
|
||
fn encode_provenance(out: &mut Vec<u8>, p: &Provenance) {
|
||
encode_source(out, &p.source);
|
||
push_u128(out, p.stable_id.0);
|
||
match p.synthesis {
|
||
None => out.push(0),
|
||
Some(kind) => {
|
||
out.push(1);
|
||
encode_synthesis(out, kind);
|
||
}
|
||
}
|
||
// Dependencies are a set: canonical (sorted) order, deduplicated.
|
||
let mut deps: Vec<Vec<u8>> = p
|
||
.dependencies
|
||
.iter()
|
||
.map(|d| d.to_canonical_bytes())
|
||
.collect();
|
||
deps.sort();
|
||
deps.dedup();
|
||
push_len(out, deps.len());
|
||
for bytes in deps {
|
||
push_len(out, bytes.len());
|
||
out.extend_from_slice(&bytes);
|
||
}
|
||
}
|
||
|
||
fn encode_synthesis(out: &mut Vec<u8>, kind: SynthesisKind) {
|
||
match kind {
|
||
SynthesisKind::CancellationAccidental => out.push(0),
|
||
SynthesisKind::KeySignatureNatural => out.push(1),
|
||
SynthesisKind::GeneratedRest => out.push(2),
|
||
SynthesisKind::EngravedBreak => out.push(3),
|
||
SynthesisKind::MultimeasureRest => out.push(4),
|
||
SynthesisKind::Cautionary => out.push(5),
|
||
SynthesisKind::Registered(id) => {
|
||
out.push(6);
|
||
push_u128(out, id.0);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn encode_decision(out: &mut Vec<u8>, d: &EngravingDecision) {
|
||
push_u128(out, d.id.0);
|
||
push_u128(out, d.target.0);
|
||
match &d.kind {
|
||
EngravingDecisionKind::StemDirection(dir) => {
|
||
out.push(0);
|
||
out.push(matches!(dir, StemDirection::Up) as u8);
|
||
}
|
||
EngravingDecisionKind::LedgerLineCount(n) => {
|
||
out.push(1);
|
||
out.push(*n);
|
||
}
|
||
EngravingDecisionKind::SystemBreak => out.push(2),
|
||
EngravingDecisionKind::PageBreak => out.push(3),
|
||
EngravingDecisionKind::Registered(id) => {
|
||
out.push(4);
|
||
push_u128(out, id.0);
|
||
}
|
||
}
|
||
match d.source {
|
||
DecisionSource::Automatic => out.push(0),
|
||
DecisionSource::UserOverride(id) => {
|
||
out.push(1);
|
||
push_u128(out, id.0);
|
||
}
|
||
DecisionSource::IrOverride => out.push(2),
|
||
}
|
||
}
|
||
|
||
fn encode_catalog(out: &mut Vec<u8>, c: &GlyphCatalogIdentity) {
|
||
out.extend_from_slice(&c.smufl_version.major.to_le_bytes());
|
||
out.extend_from_slice(&c.smufl_version.minor_centi.to_le_bytes());
|
||
let font = c.font_id.0.as_bytes();
|
||
push_len(out, font.len());
|
||
out.extend_from_slice(font);
|
||
match c.font_version {
|
||
None => out.push(0),
|
||
Some(v) => {
|
||
out.push(1);
|
||
out.extend_from_slice(&v.major.to_le_bytes());
|
||
out.extend_from_slice(&v.minor.to_le_bytes());
|
||
out.extend_from_slice(&v.patch.to_le_bytes());
|
||
}
|
||
}
|
||
out.extend_from_slice(&c.metrics_hash);
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::engraving::EngravingDecisionKind;
|
||
use crate::provenance::{LayoutObjectId, Provenance};
|
||
use epiphany_core::EventId;
|
||
|
||
fn glyph(raw: u128, x: f32) -> ResolvedGlyph {
|
||
glyph_named(raw, x, "noteheadBlack")
|
||
}
|
||
|
||
fn glyph_named(raw: u128, x: f32, name: &'static str) -> ResolvedGlyph {
|
||
let source = TypedObjectId::Event(EventId::from_raw(raw));
|
||
ResolvedGlyph {
|
||
provenance: Provenance::projected(source, vec![]),
|
||
glyph: GlyphReference::borrowed(name),
|
||
position: Point::new(x, 0.0),
|
||
transform: None,
|
||
bounding_box: BoundingBox::default(),
|
||
style: GlyphStyle { rgba: 0x0000_00ff },
|
||
layer: 0,
|
||
}
|
||
}
|
||
|
||
fn ir(glyphs: Vec<ResolvedGlyph>, decisions: Vec<EngravingDecision>) -> ResolvedLayoutIR {
|
||
ResolvedLayoutIR {
|
||
source: ScoreVersion::default(),
|
||
pages: vec![],
|
||
glyphs,
|
||
strokes: vec![],
|
||
curves: vec![],
|
||
engraving_decisions: decisions,
|
||
catalog: GlyphCatalogIdentity::default(),
|
||
unowned: PrimitiveIndices::default(),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn canonical_bytes_are_quantized_and_stable() {
|
||
let base = ir(vec![glyph(1, 1.0), glyph(2, 2.5)], vec![]);
|
||
let a = base.canonical_bytes();
|
||
assert_eq!(a, base.canonical_bytes(), "canonical bytes must be stable");
|
||
|
||
// Sub-grid f32 jitter is absorbed by quantization.
|
||
let mut jittered = base.clone();
|
||
jittered.glyphs[1].position = Point::new(2.5 + 1.0 / 4096.0, 0.0);
|
||
assert_eq!(a, jittered.canonical_bytes());
|
||
|
||
// A full grid unit changes the output.
|
||
let mut moved = base.clone();
|
||
moved.glyphs[1].position = Point::new(2.5 + 1.0 / 1024.0, 0.0);
|
||
assert_ne!(a, moved.canonical_bytes());
|
||
}
|
||
|
||
#[test]
|
||
fn count_prefixes_are_u32_width_locked() {
|
||
// Schema major 1 unifies the resolved-layout length/count prefixes to
|
||
// u32 (Binary Format companion §"Schema Major 1"). This locks the byte
|
||
// shape so a revert to the old u64 prefixes fails: an empty layout
|
||
// encodes its five counts — pages, glyphs, strokes, curves,
|
||
// engraving_decisions — as u32 zeros (20 bytes) right after the 32-byte
|
||
// ScoreVersion source, then the catalog. Under u64 that region would be
|
||
// 40 bytes, shifting the catalog and lengthening the output by 20.
|
||
let bytes = ir(vec![], vec![]).canonical_bytes();
|
||
let source_len = ScoreVersion::default().0.len();
|
||
assert_eq!(source_len, 32, "ScoreVersion source is 32 bytes");
|
||
// The first count prefix (pages) is a 4-byte u32 zero — not 8 bytes.
|
||
assert_eq!(&bytes[source_len..source_len + 4], &0u32.to_le_bytes());
|
||
// The five count prefixes occupy exactly 5 × 4 bytes; then the catalog,
|
||
// whose length we recompute independently (no magic number).
|
||
let catalog_len = {
|
||
let mut c = Vec::new();
|
||
encode_catalog(&mut c, &GlyphCatalogIdentity::default());
|
||
c.len()
|
||
};
|
||
assert_eq!(
|
||
bytes.len(),
|
||
source_len + 5 * 4 + catalog_len,
|
||
"five u32 count prefixes (20 bytes), not u64 (40 bytes)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn canonical_bytes_capture_engraving_decisions_and_catalog() {
|
||
let base = ir(vec![glyph(1, 1.0)], vec![]);
|
||
// Adding/altering an engraving decision changes the bytes.
|
||
let with_decision = ir(
|
||
vec![glyph(1, 1.0)],
|
||
vec![EngravingDecision::automatic(
|
||
LayoutObjectId(7),
|
||
EngravingDecisionKind::SystemBreak,
|
||
)],
|
||
);
|
||
assert_ne!(base.canonical_bytes(), with_decision.canonical_bytes());
|
||
|
||
// A different catalog identity changes the bytes.
|
||
let mut other_catalog = base.clone();
|
||
other_catalog.catalog.metrics_hash[0] ^= 1;
|
||
assert_ne!(base.canonical_bytes(), other_catalog.canonical_bytes());
|
||
|
||
let mut other_source = base.clone();
|
||
other_source.source.0[0] = 1;
|
||
assert_ne!(base.canonical_bytes(), other_source.canonical_bytes());
|
||
|
||
let mut other_style = base.clone();
|
||
other_style.glyphs[0].style.rgba ^= 1;
|
||
assert_ne!(base.canonical_bytes(), other_style.canonical_bytes());
|
||
|
||
let mut other_bounds = base.clone();
|
||
other_bounds.glyphs[0].bounding_box.right = StaffSpace(1.0);
|
||
assert_ne!(base.canonical_bytes(), other_bounds.canonical_bytes());
|
||
|
||
let mut transformed = base.clone();
|
||
transformed.glyphs[0].transform = Some(Transform2D::default());
|
||
assert_ne!(base.canonical_bytes(), transformed.canonical_bytes());
|
||
}
|
||
|
||
#[test]
|
||
fn swapping_glyph_names_changes_canonical_bytes() {
|
||
// Two glyphs whose names are swapped between their sources — the
|
||
// consulted-name *set* (and so the metrics hash) is unchanged, but the
|
||
// per-glyph assignment differs, so the canonical bytes MUST differ
|
||
// (the encoding is injective in glyph identity).
|
||
let a = ir(
|
||
vec![
|
||
glyph_named(1, 1.0, "noteheadBlack"),
|
||
glyph_named(2, 2.0, "gClef"),
|
||
],
|
||
vec![],
|
||
);
|
||
let b = ir(
|
||
vec![
|
||
glyph_named(1, 1.0, "gClef"),
|
||
glyph_named(2, 2.0, "noteheadBlack"),
|
||
],
|
||
vec![],
|
||
);
|
||
assert_ne!(a.canonical_bytes(), b.canonical_bytes());
|
||
}
|
||
|
||
#[test]
|
||
fn synthesis_and_stable_id_are_part_of_canonical_bytes() {
|
||
let src = TypedObjectId::Event(EventId::from_raw(1));
|
||
let mut plain = glyph(1, 1.0);
|
||
plain.provenance = Provenance::projected(src, vec![]);
|
||
let mut synth = glyph(1, 1.0);
|
||
synth.provenance = Provenance::synthesized(
|
||
src,
|
||
SynthesisKind::Cautionary,
|
||
crate::SynthesisInstanceKey(0),
|
||
vec![],
|
||
);
|
||
// Same source and position, but synthesis kind + stable id differ.
|
||
assert_ne!(
|
||
ir(vec![plain], vec![]).canonical_bytes(),
|
||
ir(vec![synth], vec![]).canonical_bytes()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[should_panic(expected = "non-finite")]
|
||
fn non_finite_geometry_is_rejected_not_normalized() {
|
||
let bad = ir(vec![glyph(1, f32::NAN)], vec![]);
|
||
let _ = bad.canonical_bytes();
|
||
}
|
||
|
||
#[test]
|
||
fn primitive_ownership_is_excluded_from_canonical_bytes() {
|
||
// (m6) Perturbing ONLY the ownership lists — a system's `primitives`
|
||
// and the layout's `unowned` bucket — must not change
|
||
// `canonical_bytes()`, even though `PartialEq` sees the difference:
|
||
// the same exclusion `vertical_band` gets (Chapter 7's ownership
|
||
// partition draws nothing).
|
||
let system = ResolvedSystem {
|
||
provenance: Provenance::projected(TypedObjectId::Event(EventId::from_raw(9)), vec![]),
|
||
bounding_box: Rect::default(),
|
||
staves: vec![],
|
||
measures: vec![],
|
||
primitives: PrimitiveIndices::default(),
|
||
};
|
||
let page = ResolvedPage {
|
||
provenance: Provenance::projected(TypedObjectId::Event(EventId::from_raw(10)), vec![]),
|
||
number: 1,
|
||
size: Size2D::default(),
|
||
margins: Margins::default(),
|
||
systems: vec![system],
|
||
free_objects: vec![],
|
||
};
|
||
let mut base = ir(vec![glyph(1, 1.0)], vec![]);
|
||
base.pages = vec![page];
|
||
let base_bytes = base.canonical_bytes();
|
||
|
||
let mut perturbed = base.clone();
|
||
perturbed.pages[0].systems[0].primitives = PrimitiveIndices {
|
||
glyphs: vec![0],
|
||
strokes: vec![],
|
||
curves: vec![],
|
||
};
|
||
perturbed.unowned = PrimitiveIndices {
|
||
glyphs: vec![],
|
||
strokes: vec![7],
|
||
curves: vec![3, 4],
|
||
};
|
||
|
||
assert_ne!(
|
||
base, perturbed,
|
||
"PartialEq must see the ownership difference"
|
||
);
|
||
assert_eq!(
|
||
base_bytes,
|
||
perturbed.canonical_bytes(),
|
||
"canonical bytes must not see it"
|
||
);
|
||
}
|
||
}
|