Skip to main content

acorde_layout/
print.rs

1use crate::{LayoutConfig, SpanMark, compute_layout};
2use acorde_core::{Barline, NoteAddr, PartGroupSymbol, Score, StyledText, TextStyle};
3use serde::{Deserialize, Serialize};
4
5/// Font-independent metrics for one print glyph, expressed in millimetres.
6///
7/// Hosts may resolve a resource key to a real font, but layout can use these metrics without
8/// loading fonts or depending on an operating system. `advance_mm` is the cursor advance;
9/// the bounding box is relative to the glyph origin and is used for collision checks.
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
11pub struct GlyphMetrics {
12    pub advance_mm: f32,
13    pub left_mm: f32,
14    pub top_mm: f32,
15    pub width_mm: f32,
16    pub height_mm: f32,
17}
18
19/// Version of the host-neutral glyph resource descriptor contract.
20pub const GLYPH_RESOURCE_CONTRACT_VERSION: u16 = 1;
21
22/// How a print host should behave when the primary glyph resource is unavailable.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
24pub enum GlyphFallbackPolicy {
25    /// Fail preflight rather than silently changing notation appearance.
26    #[default]
27    Reject,
28    /// Use another explicitly declared resource key.
29    UseResource(String),
30}
31
32/// Reproducible metadata for a host-resolved font or notation glyph resource.
33///
34/// `acorde` does not load, embed, or license-check the resource. It does require the host to
35/// identify the resource, its metrics contract, license notice, and fallback behavior before a
36/// publication export can claim reproducibility.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct GlyphResourceDescriptor {
39    pub contract_version: u16,
40    pub resource_key: String,
41    pub metrics_contract_version: u16,
42    pub license_notice: String,
43    #[serde(default)]
44    pub fallback: GlyphFallbackPolicy,
45}
46
47/// Validation failures for a host-provided glyph resource descriptor.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
49pub enum GlyphResourceDescriptorError {
50    #[error("unsupported glyph resource contract version")]
51    UnsupportedContractVersion,
52    #[error("glyph resource key is empty")]
53    EmptyResourceKey,
54    #[error("glyph resource metrics contract version is invalid")]
55    InvalidMetricsContractVersion,
56    #[error("glyph resource license notice is empty")]
57    EmptyLicenseNotice,
58    #[error("glyph fallback resource key is empty")]
59    EmptyFallbackResourceKey,
60    #[error("glyph fallback resource must differ from the primary resource")]
61    FallbackMatchesPrimary,
62}
63
64impl GlyphResourceDescriptor {
65    /// Validate the metadata needed to resolve a reproducible host resource.
66    pub fn validate(&self) -> Result<(), GlyphResourceDescriptorError> {
67        if self.contract_version != GLYPH_RESOURCE_CONTRACT_VERSION {
68            return Err(GlyphResourceDescriptorError::UnsupportedContractVersion);
69        }
70        if self.resource_key.trim().is_empty() {
71            return Err(GlyphResourceDescriptorError::EmptyResourceKey);
72        }
73        if self.metrics_contract_version == 0 {
74            return Err(GlyphResourceDescriptorError::InvalidMetricsContractVersion);
75        }
76        if self.license_notice.trim().is_empty() {
77            return Err(GlyphResourceDescriptorError::EmptyLicenseNotice);
78        }
79        if let GlyphFallbackPolicy::UseResource(key) = &self.fallback {
80            if key.trim().is_empty() {
81                return Err(GlyphResourceDescriptorError::EmptyFallbackResourceKey);
82            }
83            if key == &self.resource_key {
84                return Err(GlyphResourceDescriptorError::FallbackMatchesPrimary);
85            }
86        }
87        Ok(())
88    }
89}
90
91/// A positioned print glyph with a deterministic collision priority.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct GlyphPlacement {
94    pub resource_key: String,
95    pub metrics: GlyphMetrics,
96    pub x_mm: f32,
97    pub y_mm: f32,
98    /// Higher-priority glyphs keep their requested position when possible.
99    pub priority: u8,
100}
101
102/// Semantic collision classes used to make dense print placement deterministic.
103///
104/// The class is supplied alongside placements so the existing [`GlyphPlacement`] JSON shape
105/// remains backwards-compatible. Higher-priority placements still win; the class is the stable
106/// tie-breaker for placements with equal priority.
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108pub enum GlyphCollisionClass {
109    /// Primary notation that must retain its requested position when possible.
110    #[default]
111    Critical,
112    /// Spacing-bearing symbols such as accidentals and noteheads.
113    Spacing,
114    /// Text and other semantic annotations.
115    Annotation,
116    /// Optional visual decoration.
117    Decorative,
118}
119
120/// The permitted escape direction for a lower-priority placement in a unified collision pass.
121///
122/// The direction is semantic host input rather than an inferred writing direction: for example,
123/// a lyric lane generally moves down while a rehearsal mark lane moves up. Keeping that choice
124/// explicit lets one deterministic pass serve text, dynamics, spanners, tablature, and other
125/// annotation owners without loading a font or assuming a renderer coordinate system.
126#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
127pub enum GlyphCollisionDirection {
128    /// Keep the placement at its authored or previously resolved coordinate.
129    ///
130    /// Fixed placements act as collision obstacles for later, lower-priority entries. They make
131    /// it possible for renderers to extend an already resolved skyline without reflowing emitted
132    /// content.
133    Fixed,
134    /// Move toward increasing x coordinates.
135    Right,
136    /// Move toward decreasing x coordinates.
137    Left,
138    /// Move toward increasing y coordinates.
139    #[default]
140    Down,
141    /// Move toward decreasing y coordinates.
142    Up,
143}
144
145/// The content bounds of a validated glyph placement collection, in millimetres.
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
147pub struct GlyphExtents {
148    pub left_mm: f32,
149    pub top_mm: f32,
150    pub right_mm: f32,
151    pub bottom_mm: f32,
152}
153
154impl GlyphExtents {
155    /// Return the horizontal content span in millimetres.
156    pub fn width_mm(self) -> f32 {
157        self.right_mm - self.left_mm
158    }
159
160    /// Return the vertical content span in millimetres.
161    pub fn height_mm(self) -> f32 {
162        self.bottom_mm - self.top_mm
163    }
164}
165
166/// Validation failures for host-provided print glyph geometry.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
168pub enum GlyphPlacementError {
169    #[error("glyph placement {index} contains non-finite geometry")]
170    NonFinite { index: usize },
171    #[error("glyph placement {index} has a negative bounding-box extent")]
172    NegativeExtent { index: usize },
173    #[error("glyph spacing is non-finite or overflows")]
174    NonFiniteSpacing,
175    #[error("glyph placement {index} has an empty resource key")]
176    EmptyResourceKey { index: usize },
177    #[error("glyph placement {index} has a negative advance")]
178    NegativeAdvance { index: usize },
179    #[error("collision class count {classes} does not match placement count {placements}")]
180    CollisionClassCount { placements: usize, classes: usize },
181    #[error("collision direction count {directions} does not match placement count {placements}")]
182    CollisionDirectionCount {
183        placements: usize,
184        directions: usize,
185    },
186}
187
188/// Validate font-independent glyph geometry before collision resolution.
189pub fn validate_glyph_placements(placements: &[GlyphPlacement]) -> Result<(), GlyphPlacementError> {
190    for (index, placement) in placements.iter().enumerate() {
191        if placement.resource_key.trim().is_empty() {
192            return Err(GlyphPlacementError::EmptyResourceKey { index });
193        }
194        let values = [
195            placement.metrics.advance_mm,
196            placement.metrics.left_mm,
197            placement.metrics.top_mm,
198            placement.metrics.width_mm,
199            placement.metrics.height_mm,
200            placement.x_mm,
201            placement.y_mm,
202        ];
203        if values.iter().any(|value| !value.is_finite()) {
204            return Err(GlyphPlacementError::NonFinite { index });
205        }
206        if placement.metrics.width_mm < 0.0 || placement.metrics.height_mm < 0.0 {
207            return Err(GlyphPlacementError::NegativeExtent { index });
208        }
209        if placement.metrics.advance_mm < 0.0 {
210            return Err(GlyphPlacementError::NegativeAdvance { index });
211        }
212    }
213    Ok(())
214}
215
216/// Compute content-aware bounds for glyph placements without loading a font resource.
217pub fn glyph_extents(
218    placements: &[GlyphPlacement],
219) -> Result<Option<GlyphExtents>, GlyphPlacementError> {
220    validate_glyph_placements(placements)?;
221    let Some(first) = placements.first() else {
222        return Ok(None);
223    };
224    let (first_left, first_right) = horizontal_bounds(first);
225    let (first_top, first_bottom) = vertical_bounds(first);
226    if [first_left, first_right, first_top, first_bottom]
227        .iter()
228        .any(|value| !value.is_finite())
229    {
230        return Err(GlyphPlacementError::NonFinite { index: 0 });
231    }
232    let mut extents = GlyphExtents {
233        left_mm: first_left,
234        top_mm: first_top,
235        right_mm: first_right,
236        bottom_mm: first_bottom,
237    };
238    for (index, placement) in placements.iter().enumerate().skip(1) {
239        let (left, right) = horizontal_bounds(placement);
240        let (top, bottom) = vertical_bounds(placement);
241        if [left, right, top, bottom]
242            .iter()
243            .any(|value| !value.is_finite())
244        {
245            return Err(GlyphPlacementError::NonFinite { index });
246        }
247        extents.left_mm = extents.left_mm.min(left);
248        extents.top_mm = extents.top_mm.min(top);
249        extents.right_mm = extents.right_mm.max(right);
250        extents.bottom_mm = extents.bottom_mm.max(bottom);
251    }
252    Ok(Some(extents))
253}
254
255/// Distribute additional horizontal space evenly between glyph placements.
256pub fn distribute_glyph_spacing(
257    placements: &mut [GlyphPlacement],
258    extra_mm: f32,
259) -> Result<usize, GlyphPlacementError> {
260    validate_glyph_placements(placements)?;
261    if !extra_mm.is_finite() {
262        return Err(GlyphPlacementError::NonFiniteSpacing);
263    }
264    if extra_mm <= 0.0 || placements.len() < 2 {
265        return Ok(0);
266    }
267    let mut order: Vec<usize> = (0..placements.len()).collect();
268    order.sort_by(|&left, &right| {
269        placements[left]
270            .x_mm
271            .total_cmp(&placements[right].x_mm)
272            .then(left.cmp(&right))
273    });
274    let denominator = (order.len() - 1) as f32;
275    let mut shifts = Vec::with_capacity(order.len().saturating_sub(1));
276    for (rank, &index) in order.iter().enumerate().skip(1) {
277        let shift = extra_mm * rank as f32 / denominator;
278        if !shift.is_finite() || !(placements[index].x_mm + shift).is_finite() {
279            return Err(GlyphPlacementError::NonFiniteSpacing);
280        }
281        shifts.push((index, shift));
282    }
283    let mut moved = 0;
284    for (index, shift) in shifts {
285        placements[index].x_mm += shift;
286        if shift > f32::EPSILON {
287            moved += 1;
288        }
289    }
290    Ok(moved)
291}
292
293/// Move lower-priority glyphs vertically until their bounding boxes no longer overlap.
294///
295/// This is intentionally a small, backend-neutral primitive: it does not choose fonts or
296/// draw anything. The stable input order breaks ties, and the return value reports how many
297/// placements were moved so a host can expose a preflight diagnostic.
298pub fn resolve_glyph_collisions(placements: &mut [GlyphPlacement], gap_mm: f32) -> usize {
299    let order = collision_order(placements, None);
300    resolve_glyph_collisions_ordered(placements, gap_mm, &order)
301}
302
303/// Resolve vertical collisions using explicit semantic classes.
304///
305/// This is the class-aware counterpart to [`resolve_glyph_collisions`]. It validates the class
306/// vector before mutating placements, then uses priority followed by class and source order as
307/// the deterministic ownership rule.
308pub fn resolve_glyph_collisions_with_classes(
309    placements: &mut [GlyphPlacement],
310    classes: &[GlyphCollisionClass],
311    gap_mm: f32,
312) -> Result<usize, GlyphPlacementError> {
313    validate_glyph_placements(placements)?;
314    if classes.len() != placements.len() {
315        return Err(GlyphPlacementError::CollisionClassCount {
316            placements: placements.len(),
317            classes: classes.len(),
318        });
319    }
320    if !gap_mm.is_finite() {
321        return Err(GlyphPlacementError::NonFiniteSpacing);
322    }
323    let mut candidate = placements.to_vec();
324    let order = collision_order(&candidate, Some(classes));
325    let moved = resolve_glyph_collisions_ordered(&mut candidate, gap_mm, &order);
326    glyph_extents(&candidate)?;
327    placements.clone_from_slice(&candidate);
328    Ok(moved)
329}
330
331/// Resolve a mixed collection of glyph placements in one deterministic constraint pass.
332///
333/// Higher-priority placements, then lower collision-class ranks, retain their requested
334/// positions. Every later placement moves only along its declared [`GlyphCollisionDirection`]
335/// until it no longer intersects an earlier placement. This is a host-neutral skyline primitive:
336/// it carries no font, SVG, CSS, or page-coordinate assumptions, but gives all annotation kinds
337/// the same ownership and tie-breaking contract.
338pub fn resolve_glyph_collisions_constrained(
339    placements: &mut [GlyphPlacement],
340    classes: &[GlyphCollisionClass],
341    directions: &[GlyphCollisionDirection],
342    gap_mm: f32,
343) -> Result<usize, GlyphPlacementError> {
344    validate_glyph_placements(placements)?;
345    if classes.len() != placements.len() {
346        return Err(GlyphPlacementError::CollisionClassCount {
347            placements: placements.len(),
348            classes: classes.len(),
349        });
350    }
351    if directions.len() != placements.len() {
352        return Err(GlyphPlacementError::CollisionDirectionCount {
353            placements: placements.len(),
354            directions: directions.len(),
355        });
356    }
357    if !gap_mm.is_finite() {
358        return Err(GlyphPlacementError::NonFiniteSpacing);
359    }
360    let mut candidate = placements.to_vec();
361    let order = collision_order(&candidate, Some(classes));
362    let moved = resolve_glyph_collisions_constrained_ordered(
363        &mut candidate,
364        directions,
365        gap_mm.max(0.0),
366        &order,
367    );
368    glyph_extents(&candidate)?;
369    placements.clone_from_slice(&candidate);
370    Ok(moved)
371}
372
373fn resolve_glyph_collisions_constrained_ordered(
374    placements: &mut [GlyphPlacement],
375    directions: &[GlyphCollisionDirection],
376    gap_mm: f32,
377    order: &[usize],
378) -> usize {
379    let mut moved = 0;
380    for (position, &index) in order.iter().enumerate() {
381        let original = placements[index].clone();
382        let mut next = original.clone();
383        for &previous in &order[..position] {
384            let (left, right) = horizontal_bounds(&next);
385            let (top, bottom) = vertical_bounds(&next);
386            let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
387            let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
388            if right <= previous_left
389                || previous_right <= left
390                || bottom <= previous_top
391                || previous_bottom <= top
392            {
393                continue;
394            }
395            match directions[index] {
396                GlyphCollisionDirection::Fixed => {}
397                GlyphCollisionDirection::Right => {
398                    next.x_mm = previous_right + gap_mm - next.metrics.left_mm;
399                }
400                GlyphCollisionDirection::Left => {
401                    next.x_mm =
402                        previous_left - gap_mm - next.metrics.left_mm - next.metrics.width_mm;
403                }
404                GlyphCollisionDirection::Down => {
405                    next.y_mm = previous_bottom + gap_mm - next.metrics.top_mm;
406                }
407                GlyphCollisionDirection::Up => {
408                    next.y_mm =
409                        previous_top - gap_mm - next.metrics.top_mm - next.metrics.height_mm;
410                }
411            }
412        }
413        if next.x_mm != original.x_mm || next.y_mm != original.y_mm {
414            placements[index] = next;
415            moved += 1;
416        }
417    }
418    moved
419}
420
421fn resolve_glyph_collisions_ordered(
422    placements: &mut [GlyphPlacement],
423    gap_mm: f32,
424    order: &[usize],
425) -> usize {
426    let gap_mm = if gap_mm.is_finite() {
427        gap_mm.max(0.0)
428    } else {
429        0.0
430    };
431    let mut moved = 0;
432    for position in 0..order.len() {
433        let index = order[position];
434        let (left, right) = horizontal_bounds(&placements[index]);
435        let mut next_y = placements[index].y_mm;
436        for &previous in &order[..position] {
437            let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
438            if right <= previous_left || previous_right <= left {
439                continue;
440            }
441            let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
442            let current_top = next_y + placements[index].metrics.top_mm;
443            let current_bottom = current_top + placements[index].metrics.height_mm;
444            if current_bottom <= previous_top || previous_bottom <= current_top {
445                continue;
446            }
447            if current_top < previous_bottom + gap_mm {
448                next_y = previous_bottom + gap_mm - placements[index].metrics.top_mm;
449            }
450        }
451        if (next_y - placements[index].y_mm).abs() > f32::EPSILON {
452            placements[index].y_mm = next_y;
453            moved += 1;
454        }
455    }
456    moved
457}
458
459/// Validate glyph geometry, then apply deterministic vertical collision resolution.
460pub fn resolve_glyph_collisions_checked(
461    placements: &mut [GlyphPlacement],
462    gap_mm: f32,
463) -> Result<usize, GlyphPlacementError> {
464    validate_glyph_placements(placements)?;
465    if !gap_mm.is_finite() {
466        return Err(GlyphPlacementError::NonFiniteSpacing);
467    }
468    let mut candidate = placements.to_vec();
469    let moved = resolve_glyph_collisions(&mut candidate, gap_mm);
470    glyph_extents(&candidate)?;
471    placements.clone_from_slice(&candidate);
472    Ok(moved)
473}
474
475/// Move lower-priority glyphs horizontally until their bounding boxes no longer overlap.
476///
477/// Higher-priority placements retain their requested coordinates. When several placements
478/// overlap, stable input order breaks ties and the return value reports how many placements moved.
479pub fn resolve_glyph_horizontal_collisions(
480    placements: &mut [GlyphPlacement],
481    gap_mm: f32,
482) -> usize {
483    let order = collision_order(placements, None);
484    resolve_glyph_horizontal_collisions_ordered(placements, gap_mm, &order)
485}
486
487/// Resolve horizontal collisions using explicit semantic classes.
488pub fn resolve_glyph_horizontal_collisions_with_classes(
489    placements: &mut [GlyphPlacement],
490    classes: &[GlyphCollisionClass],
491    gap_mm: f32,
492) -> Result<usize, GlyphPlacementError> {
493    validate_glyph_placements(placements)?;
494    if classes.len() != placements.len() {
495        return Err(GlyphPlacementError::CollisionClassCount {
496            placements: placements.len(),
497            classes: classes.len(),
498        });
499    }
500    if !gap_mm.is_finite() {
501        return Err(GlyphPlacementError::NonFiniteSpacing);
502    }
503    let mut candidate = placements.to_vec();
504    let order = collision_order(&candidate, Some(classes));
505    let moved = resolve_glyph_horizontal_collisions_ordered(&mut candidate, gap_mm, &order);
506    glyph_extents(&candidate)?;
507    placements.clone_from_slice(&candidate);
508    Ok(moved)
509}
510
511fn resolve_glyph_horizontal_collisions_ordered(
512    placements: &mut [GlyphPlacement],
513    gap_mm: f32,
514    order: &[usize],
515) -> usize {
516    let gap_mm = if gap_mm.is_finite() {
517        gap_mm.max(0.0)
518    } else {
519        0.0
520    };
521    let mut moved = 0;
522    for position in 0..order.len() {
523        let index = order[position];
524        let original_x = placements[index].x_mm;
525        let mut next_x = original_x;
526        for &previous in &order[..position] {
527            let current = GlyphPlacement {
528                x_mm: next_x,
529                ..placements[index].clone()
530            };
531            let (left, right) = horizontal_bounds(&current);
532            let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
533            let (top, bottom) = vertical_bounds(&current);
534            let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
535            if right <= previous_left
536                || previous_right <= left
537                || bottom <= previous_top
538                || previous_bottom <= top
539            {
540                continue;
541            }
542            next_x = previous_right + gap_mm - placements[index].metrics.left_mm;
543        }
544        if (next_x - original_x).abs() > f32::EPSILON {
545            placements[index].x_mm = next_x;
546            moved += 1;
547        }
548    }
549    moved
550}
551
552fn collision_order(
553    placements: &[GlyphPlacement],
554    classes: Option<&[GlyphCollisionClass]>,
555) -> Vec<usize> {
556    let class_rank = |index: usize| {
557        classes
558            .and_then(|values| values.get(index))
559            .map_or(0, |class| match class {
560                GlyphCollisionClass::Critical => 0,
561                GlyphCollisionClass::Spacing => 1,
562                GlyphCollisionClass::Annotation => 2,
563                GlyphCollisionClass::Decorative => 3,
564            })
565    };
566    let mut order: Vec<usize> = (0..placements.len()).collect();
567    order.sort_by_key(|&index| {
568        (
569            std::cmp::Reverse(placements[index].priority),
570            class_rank(index),
571            index,
572        )
573    });
574    order
575}
576
577/// Validate glyph geometry, then apply deterministic horizontal collision resolution.
578pub fn resolve_glyph_horizontal_collisions_checked(
579    placements: &mut [GlyphPlacement],
580    gap_mm: f32,
581) -> Result<usize, GlyphPlacementError> {
582    validate_glyph_placements(placements)?;
583    if !gap_mm.is_finite() {
584        return Err(GlyphPlacementError::NonFiniteSpacing);
585    }
586    let mut candidate = placements.to_vec();
587    let moved = resolve_glyph_horizontal_collisions(&mut candidate, gap_mm);
588    glyph_extents(&candidate)?;
589    placements.clone_from_slice(&candidate);
590    Ok(moved)
591}
592
593fn horizontal_bounds(placement: &GlyphPlacement) -> (f32, f32) {
594    (
595        placement.x_mm + placement.metrics.left_mm,
596        placement.x_mm + placement.metrics.left_mm + placement.metrics.width_mm,
597    )
598}
599
600fn vertical_bottom(placement: &GlyphPlacement) -> f32 {
601    placement.y_mm + placement.metrics.top_mm + placement.metrics.height_mm
602}
603
604fn vertical_bounds(placement: &GlyphPlacement) -> (f32, f32) {
605    (
606        placement.y_mm + placement.metrics.top_mm,
607        vertical_bottom(placement),
608    )
609}
610
611/// A paper size expressed in physical millimetres.
612#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
613pub enum PaperSize {
614    A4,
615    Letter,
616    Legal,
617    Custom { width_mm: f32, height_mm: f32 },
618}
619
620impl PaperSize {
621    fn dimensions_mm(self) -> (f32, f32) {
622        match self {
623            Self::A4 => (210.0, 297.0),
624            Self::Letter => (215.9, 279.4),
625            Self::Legal => (215.9, 355.6),
626            Self::Custom {
627                width_mm,
628                height_mm,
629            } => (width_mm, height_mm),
630        }
631    }
632}
633
634/// Page orientation for a logical print layout.
635#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
636pub enum PageOrientation {
637    Portrait,
638    Landscape,
639}
640
641/// Policy for the page number exposed in logical page metadata.
642#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
643pub enum PageNumbering {
644    None,
645    OneBased,
646}
647
648/// Policy for distributing systems when automatic pagination would leave a one-system final page.
649#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
650pub enum FinalPagePolicy {
651    /// Preserve the configured page capacity, even when the final page is short.
652    #[default]
653    AllowSingleSystem,
654    /// Redistribute automatically paginated systems as evenly as possible across pages.
655    Balance,
656}
657
658/// Policy for reserving the first system for a partial pickup measure.
659#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
660pub enum PickupPolicy {
661    /// Detect a non-empty partial first measure automatically (the default).
662    #[default]
663    Auto,
664    /// Do not infer pickup measures from score content.
665    Preserve,
666    /// Detect a non-empty first measure shorter than its time signature and isolate it.
667    DetectFirstMeasure,
668}
669
670/// Policy for preserving repeat-ending notation while systems are reflowed.
671#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
672pub enum NotationBreakPolicy {
673    /// Keep the score's normal automatic system breaks.
674    #[default]
675    Preserve,
676    /// Keep each contiguous volta ending in one system when it fits.
677    KeepVoltaTogether,
678    /// Keep each repeat section on one page when it fits the page capacity.
679    KeepRepeatsTogether,
680}
681
682/// Color intent for a print-capable host.
683#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
684pub enum PrintColorPolicy {
685    #[default]
686    Monochrome,
687    Preserve,
688}
689
690/// Whether a host should expose crop marks at the configured bleed boundary.
691#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
692pub enum CropMarkPolicy {
693    #[default]
694    None,
695    BleedEdges,
696}
697
698/// How a host resolves fonts and notation glyph resources for print output.
699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
700pub enum GlyphResourcePolicy {
701    /// Use the renderer's deterministic built-in vector glyphs where available.
702    #[default]
703    BuiltInVector,
704    /// Resolve a host-owned resource identified by this stable application key.
705    HostProvided(String),
706}
707
708/// Selects the score scope used by print pagination and notation metadata.
709#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
710pub enum PartLayoutPolicy {
711    /// Keep all parts in the score-level layout contract.
712    #[default]
713    FullScore,
714    /// Produce an extracted-part layout for the zero-based part index.
715    ExtractedPart { part_index: usize },
716}
717
718/// Alternate running text for odd and even numbered music pages.
719///
720/// When either field is present, the template replaces the legacy single header/footer text for
721/// that role. A missing side intentionally emits no block, which supports mirror-page designs.
722#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
723#[serde(default)]
724pub struct PublicationPageTemplate {
725    pub odd: Option<String>,
726    pub even: Option<String>,
727}
728
729/// The page scope in which a host should place a publication image resource.
730///
731/// The resource is identified by an opaque key; layout never reads a path, URL, or image bytes.
732#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
733pub enum PublicationImagePlacement {
734    /// Place the resource only on the generated title page.
735    TitlePage,
736    /// Place the resource on every non-title music page.
737    #[default]
738    MusicPages,
739    /// Place the resource on every page, including a title page when one is generated.
740    EveryPage,
741}
742
743/// A host-resolved image reference in physical print coordinates.
744///
745/// `resource_key` is deliberately an opaque identifier, not a filesystem path or URL. A host
746/// owns retrieval, decoding, licensing, and raster/SVG safety checks before it draws anything.
747#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
748pub struct PublicationImageResource {
749    pub resource_key: String,
750    pub alt_text: String,
751    #[serde(default)]
752    pub placement: PublicationImagePlacement,
753    pub x_mm: f32,
754    pub y_mm: f32,
755    pub width_mm: f32,
756    pub height_mm: f32,
757}
758
759/// A named publication section beginning at one physical measure.
760///
761/// Sections belong to a `PrintConfig`, rather than the editable score, so a host can prepare
762/// editions or parts with different headings and page starts without changing notation data.
763#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
764pub struct PublicationSection {
765    /// Zero-based physical measure at which this section begins.
766    pub first_measure: usize,
767    pub title: String,
768    /// When true, begin this section on a fresh physical page.
769    #[serde(default)]
770    pub start_on_new_page: bool,
771}
772
773/// Extra vertical space inserted before the system that begins at one physical measure.
774///
775/// Spacers are publication policy, not score notation. Their height is consumed by pagination
776/// and reflected in the following system's `top_mm`.
777#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
778pub struct PublicationSpacer {
779    /// Zero-based physical measure at which the following system receives extra space.
780    pub before_measure: usize,
781    pub height_mm: f32,
782}
783
784/// The page scope in which a host should draw a publication frame.
785#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
786pub enum PublicationFramePlacement {
787    TitlePage,
788    #[default]
789    MusicPages,
790    EveryPage,
791}
792
793/// A host-rendered rectangular publication frame in physical page coordinates.
794///
795/// This is geometry only. Stroke color, dashes, and PDF/SVG drawing remain a host policy.
796#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
797pub struct PublicationFrame {
798    #[serde(default)]
799    pub placement: PublicationFramePlacement,
800    pub x_mm: f32,
801    pub y_mm: f32,
802    pub width_mm: f32,
803    pub height_mm: f32,
804    pub stroke_width_mm: f32,
805}
806
807impl PublicationPageTemplate {
808    fn is_configured(&self) -> bool {
809        self.odd.is_some() || self.even.is_some()
810    }
811
812    fn resolve(&self, page_number: Option<usize>) -> Option<&String> {
813        match page_number {
814            Some(number) if number % 2 == 0 => self.even.as_ref(),
815            Some(_) => self.odd.as_ref(),
816            None => None,
817        }
818    }
819}
820
821/// Host-neutral publication metadata policy carried into each page artifact.
822#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
823#[serde(default)]
824pub struct PublicationConfig {
825    /// Insert a metadata-only title page before the music pages.
826    pub title_page: bool,
827    /// Optional running title shown by a host on non-title pages.
828    pub running_title: Option<String>,
829    pub show_part_names: bool,
830    pub show_measure_numbers: bool,
831    /// Optional text placed in the logical page header.
832    pub header_text: Option<String>,
833    /// Optional text placed in the logical page footer.
834    pub footer_text: Option<String>,
835    /// Odd/even header text. When configured, this supersedes `header_text` and `running_title`.
836    pub header_template: PublicationPageTemplate,
837    /// Odd/even footer text. When configured, this supersedes `footer_text`.
838    pub footer_template: PublicationPageTemplate,
839    /// Add the logical page number as a footer text block when numbering is enabled.
840    pub page_number_in_footer: bool,
841    pub header_alignment: PublicationTextAlignment,
842    pub footer_alignment: PublicationTextAlignment,
843    pub title_alignment: PublicationTextAlignment,
844    /// Logical line-box height for publication text blocks, in millimetres.
845    pub line_height_mm: f32,
846    /// Host-resolved publication images carried as safe opaque references.
847    #[serde(default)]
848    pub image_resources: Vec<PublicationImageResource>,
849    /// Publication-only section headings and optional forced page starts.
850    #[serde(default)]
851    pub sections: Vec<PublicationSection>,
852    /// Publication-only vertical gaps inserted before selected systems.
853    #[serde(default)]
854    pub spacers: Vec<PublicationSpacer>,
855    /// Host-rendered page frames with validated physical geometry.
856    #[serde(default)]
857    pub frames: Vec<PublicationFrame>,
858}
859
860impl Default for PublicationConfig {
861    fn default() -> Self {
862        Self {
863            title_page: false,
864            running_title: None,
865            show_part_names: true,
866            show_measure_numbers: true,
867            header_text: None,
868            footer_text: None,
869            header_template: PublicationPageTemplate::default(),
870            footer_template: PublicationPageTemplate::default(),
871            page_number_in_footer: false,
872            header_alignment: PublicationTextAlignment::Left,
873            footer_alignment: PublicationTextAlignment::Left,
874            title_alignment: PublicationTextAlignment::Center,
875            line_height_mm: 4.0,
876            image_resources: Vec::new(),
877            sections: Vec::new(),
878            spacers: Vec::new(),
879            frames: Vec::new(),
880        }
881    }
882}
883
884/// Semantic role for a host-rendered publication text block.
885#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
886pub enum PublicationTextRole {
887    Header,
888    Footer,
889    Title,
890    Subtitle,
891    Credit,
892    Copyright,
893}
894
895/// Horizontal alignment within a publication text block's physical width.
896#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
897pub enum PublicationTextAlignment {
898    #[default]
899    Left,
900    Center,
901    Right,
902}
903
904/// A page text block with deterministic physical placement.
905#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
906pub struct PublicationTextBlock {
907    pub role: PublicationTextRole,
908    pub text: String,
909    pub x_mm: f32,
910    pub y_mm: f32,
911    pub width_mm: f32,
912    pub height_mm: f32,
913    #[serde(default)]
914    pub alignment: PublicationTextAlignment,
915}
916
917/// A part label suitable for a score header or extracted-part host renderer.
918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
919pub struct PartLabel {
920    pub part_index: usize,
921    pub name: String,
922    pub short_name: String,
923}
924
925/// A score-level part connector for a publication host.
926#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
927pub struct PartGroupMark {
928    pub first_part: usize,
929    pub last_part: usize,
930    pub symbol: PartGroupSymbol,
931    pub barlines_connect: bool,
932}
933
934/// Publication information for one logical page.
935#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
936#[serde(default)]
937pub struct PagePublication {
938    #[serde(default)]
939    pub is_title_page: bool,
940    pub title: String,
941    pub movement_title: String,
942    pub composer: String,
943    pub lyricist: String,
944    pub copyright: String,
945    pub running_title: Option<String>,
946    /// Score-level styled text retained for title-page and host publication rendering.
947    #[serde(default)]
948    pub score_texts: Vec<StyledText>,
949    pub part_labels: Vec<PartLabel>,
950    #[serde(default)]
951    pub part_groups: Vec<PartGroupMark>,
952    pub measure_numbers: Vec<u32>,
953    #[serde(default)]
954    pub text_blocks: Vec<PublicationTextBlock>,
955    /// Image references selected for this page. Hosts resolve the opaque keys safely.
956    #[serde(default)]
957    pub image_resources: Vec<PublicationImageResource>,
958    /// Publication sections that begin on this page, in physical measure order.
959    #[serde(default)]
960    pub sections: Vec<PublicationSection>,
961    /// Publication spacers applied before systems on this page.
962    #[serde(default)]
963    pub spacers: Vec<PublicationSpacer>,
964    /// Publication frames selected for this page.
965    #[serde(default)]
966    pub frames: Vec<PublicationFrame>,
967}
968
969/// A contiguous range of physical measures that must remain in one printed system.
970///
971/// Both endpoints are zero-based and inclusive. This is intentionally a layout request,
972/// not a score-model mutation, so hosts can apply publication presets without changing the
973/// editable score.
974#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
975pub struct KeepTogetherRange {
976    pub first_measure: usize,
977    pub last_measure: usize,
978}
979
980/// Host-neutral inputs for deterministic page and system layout.
981///
982/// This contract describes physical page geometry only. It intentionally does not select
983/// fonts, emit PDF, access printers, or perform filesystem I/O.
984#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
985#[serde(default)]
986pub struct PrintConfig {
987    pub paper_size: PaperSize,
988    pub orientation: PageOrientation,
989    pub margin_top_mm: f32,
990    pub margin_right_mm: f32,
991    pub margin_bottom_mm: f32,
992    pub margin_left_mm: f32,
993    pub bleed_top_mm: f32,
994    pub bleed_right_mm: f32,
995    pub bleed_bottom_mm: f32,
996    pub bleed_left_mm: f32,
997    pub safe_top_mm: f32,
998    pub safe_right_mm: f32,
999    pub safe_bottom_mm: f32,
1000    pub safe_left_mm: f32,
1001    pub system_height_mm: f32,
1002    /// Content scale factor. `1.0` preserves the configured system height.
1003    pub scale: f32,
1004    pub measures_per_system: usize,
1005    /// Optional measure capacity for the first system, useful for pickup/title systems.
1006    #[serde(default)]
1007    pub first_system_measures: Option<usize>,
1008    #[serde(default)]
1009    pub pickup_policy: PickupPolicy,
1010    #[serde(default)]
1011    pub notation_break_policy: NotationBreakPolicy,
1012    /// Override the number of systems per page. When omitted it is derived from the usable
1013    /// page height and `system_height_mm`.
1014    pub systems_per_page: Option<usize>,
1015    pub page_numbering: PageNumbering,
1016    #[serde(default)]
1017    pub final_page_policy: FinalPagePolicy,
1018    #[serde(default)]
1019    pub color_policy: PrintColorPolicy,
1020    #[serde(default)]
1021    pub crop_mark_policy: CropMarkPolicy,
1022    #[serde(default)]
1023    pub glyph_resources: GlyphResourcePolicy,
1024    #[serde(default)]
1025    pub publication: PublicationConfig,
1026    #[serde(default)]
1027    pub part_layout: PartLayoutPolicy,
1028    /// Physical measure ranges that must not be split across systems.
1029    #[serde(default)]
1030    pub keep_together: Vec<KeepTogetherRange>,
1031}
1032
1033impl Default for PrintConfig {
1034    fn default() -> Self {
1035        Self {
1036            paper_size: PaperSize::A4,
1037            orientation: PageOrientation::Portrait,
1038            margin_top_mm: 16.0,
1039            margin_right_mm: 14.0,
1040            margin_bottom_mm: 16.0,
1041            margin_left_mm: 14.0,
1042            bleed_top_mm: 0.0,
1043            bleed_right_mm: 0.0,
1044            bleed_bottom_mm: 0.0,
1045            bleed_left_mm: 0.0,
1046            safe_top_mm: 0.0,
1047            safe_right_mm: 0.0,
1048            safe_bottom_mm: 0.0,
1049            safe_left_mm: 0.0,
1050            system_height_mm: 24.0,
1051            scale: 1.0,
1052            measures_per_system: 4,
1053            first_system_measures: None,
1054            pickup_policy: PickupPolicy::Auto,
1055            notation_break_policy: NotationBreakPolicy::Preserve,
1056            systems_per_page: None,
1057            page_numbering: PageNumbering::OneBased,
1058            final_page_policy: FinalPagePolicy::AllowSingleSystem,
1059            color_policy: PrintColorPolicy::Monochrome,
1060            crop_mark_policy: CropMarkPolicy::None,
1061            glyph_resources: GlyphResourcePolicy::BuiltInVector,
1062            publication: PublicationConfig::default(),
1063            part_layout: PartLayoutPolicy::FullScore,
1064            keep_together: Vec::new(),
1065        }
1066    }
1067}
1068
1069/// Version of the built-in host-neutral print preset data.
1070pub const PRINT_PRESET_SCHEMA_VERSION: u16 = 1;
1071/// Version of the serialized host-neutral print layout contract.
1072pub const PRINT_LAYOUT_CONTRACT_VERSION: u16 = 32;
1073
1074/// Reproducible starting configurations for common publication workflows.
1075#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1076pub enum PrintPreset {
1077    A4Score,
1078    LetterScore,
1079    A4Part { part_index: usize },
1080    LetterPart { part_index: usize },
1081}
1082
1083impl PrintPreset {
1084    /// Build a configuration without consulting host defaults or installed resources.
1085    pub fn config(self) -> PrintConfig {
1086        let (paper_size, part_layout) = match self {
1087            Self::A4Score => (PaperSize::A4, PartLayoutPolicy::FullScore),
1088            Self::LetterScore => (PaperSize::Letter, PartLayoutPolicy::FullScore),
1089            Self::A4Part { part_index } => (
1090                PaperSize::A4,
1091                PartLayoutPolicy::ExtractedPart { part_index },
1092            ),
1093            Self::LetterPart { part_index } => (
1094                PaperSize::Letter,
1095                PartLayoutPolicy::ExtractedPart { part_index },
1096            ),
1097        };
1098        PrintConfig {
1099            paper_size,
1100            part_layout,
1101            ..PrintConfig::default()
1102        }
1103    }
1104
1105    /// Build this preset with the publication title-page policy explicitly selected.
1106    pub fn config_with_title_page(self, title_page: bool) -> PrintConfig {
1107        let mut config = self.config();
1108        config.publication.title_page = title_page;
1109        config
1110    }
1111
1112    /// Return the schema version for this preset data.
1113    pub const fn schema_version(self) -> u16 {
1114        PRINT_PRESET_SCHEMA_VERSION
1115    }
1116}
1117
1118/// A logical system placed on a page.
1119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1120pub struct SystemLayout {
1121    pub address: SystemAddress,
1122    pub system_index: usize,
1123    pub page_index: usize,
1124    pub measure_indices: Vec<usize>,
1125    /// Physical intervals represented by the system, including multi-rest spans.
1126    #[serde(default)]
1127    pub measure_spans: Vec<MeasureSpan>,
1128    /// Span segments touching this system, with start/end ownership for host continuation marks.
1129    #[serde(default)]
1130    pub span_segments: Vec<SpanSegment>,
1131    /// Repeat, ending, navigation, and rehearsal marks belonging to this system.
1132    #[serde(default)]
1133    pub measure_marks: Vec<MeasureMark>,
1134    pub top_mm: f32,
1135    pub height_mm: f32,
1136    pub break_reason: BreakReason,
1137}
1138
1139/// Stable address of a page within one print-layout result.
1140#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1141pub struct PageAddress {
1142    pub page_index: usize,
1143}
1144
1145/// Stable address of a system, including global and page-local positions.
1146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1147pub struct SystemAddress {
1148    pub system_index: usize,
1149    pub page_index: usize,
1150    pub index_on_page: usize,
1151}
1152
1153/// Physical measure interval represented by one visual measure slot.
1154#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1155pub struct MeasureSpan {
1156    pub first_measure: usize,
1157    pub last_measure: usize,
1158}
1159
1160/// A span's intersection with one printed system.
1161#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1162pub struct SpanSegment {
1163    pub span_index: usize,
1164    pub starts_here: bool,
1165    pub ends_here: bool,
1166}
1167
1168/// A cross-system span's intersection with one printed page.
1169#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1170pub struct PageSpanSegment {
1171    pub span_index: usize,
1172    pub starts_here: bool,
1173    pub ends_here: bool,
1174}
1175
1176/// Host-neutral notation marks attached to one physical measure in a print system.
1177///
1178/// This is presentation metadata only: playback order remains the responsibility of
1179/// [`acorde_core::measure_sequence`].
1180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1181pub struct MeasureMark {
1182    pub measure_index: usize,
1183    pub repeat_start: bool,
1184    pub repeat_end: bool,
1185    pub volta_number: Option<u8>,
1186    pub volta_kind: Option<String>,
1187    pub navigation: Option<String>,
1188    pub rehearsal: Option<String>,
1189    /// Explicit and legacy measure-level text in deterministic source order.
1190    #[serde(default)]
1191    pub text_annotations: Vec<StyledText>,
1192}
1193
1194/// Explains why a system or page ended at its final measure.
1195#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1196pub enum BreakReason {
1197    MeasureCapacity,
1198    ExplicitSystemBreak,
1199    ExplicitPageBreak,
1200    SectionBreak,
1201    PageCapacity,
1202    EndOfScore,
1203    TitlePage,
1204}
1205
1206/// One page in a [`PrintLayoutResult`].
1207#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1208pub struct PageLayout {
1209    pub address: PageAddress,
1210    pub page_index: usize,
1211    pub page_number: Option<usize>,
1212    #[serde(default)]
1213    pub color_policy: PrintColorPolicy,
1214    #[serde(default)]
1215    pub crop_mark_policy: CropMarkPolicy,
1216    #[serde(default)]
1217    pub glyph_resources: GlyphResourcePolicy,
1218    #[serde(default)]
1219    pub publication: PagePublication,
1220    pub width_mm: f32,
1221    pub height_mm: f32,
1222    pub content_width_mm: f32,
1223    pub content_height_mm: f32,
1224    pub bleed_top_mm: f32,
1225    pub bleed_right_mm: f32,
1226    pub bleed_bottom_mm: f32,
1227    pub bleed_left_mm: f32,
1228    pub systems: Vec<SystemLayout>,
1229    /// Span intersections on this page, aggregated from its systems.
1230    #[serde(default)]
1231    pub span_segments: Vec<PageSpanSegment>,
1232    /// Repeat and navigation marks on this page, in physical measure order.
1233    #[serde(default)]
1234    pub measure_marks: Vec<MeasureMark>,
1235    pub break_reason: BreakReason,
1236}
1237
1238/// A host-neutral page export descriptor.
1239///
1240/// This is intentionally geometry and metadata only. Hosts may turn each descriptor into
1241/// SVG, PDF, or another artifact without making this crate depend on a file format or UI API.
1242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1243pub struct PageArtifact {
1244    pub address: PageAddress,
1245    pub page_index: usize,
1246    pub page_number: Option<usize>,
1247    pub width_mm: f32,
1248    pub height_mm: f32,
1249    pub content_width_mm: f32,
1250    pub content_height_mm: f32,
1251    pub measure_span: Option<MeasureSpan>,
1252    pub diagnostics: Vec<PageArtifactDiagnostic>,
1253    pub layout: PageLayout,
1254}
1255
1256/// Version of the serializable page-render tree contract.
1257pub const PAGE_RENDER_TREE_CONTRACT_VERSION: u16 = 1;
1258
1259/// Canonical score address or page-owned publication address for one render node.
1260#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1261#[serde(rename_all = "kebab-case")]
1262pub enum PageRenderAddress {
1263    Note(NoteAddr),
1264    Spanner {
1265        id: String,
1266    },
1267    Publication {
1268        page_index: usize,
1269        block_index: usize,
1270    },
1271    Resource {
1272        page_index: usize,
1273        resource_key: String,
1274    },
1275    Frame {
1276        page_index: usize,
1277        frame_index: usize,
1278    },
1279}
1280
1281/// Backend-neutral semantic node in a page render tree.
1282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1283#[serde(rename_all = "kebab-case")]
1284pub enum PageRenderNodeKind {
1285    Note,
1286    Rest,
1287    Spanner { starts_here: bool, ends_here: bool },
1288    PublicationBlock,
1289    Resource,
1290    Frame,
1291}
1292
1293/// One node with its physical page and system ownership.
1294#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1295pub struct PageRenderNode {
1296    pub address: PageRenderAddress,
1297    pub kind: PageRenderNodeKind,
1298    #[serde(default, skip_serializing_if = "Option::is_none")]
1299    pub system: Option<SystemAddress>,
1300}
1301
1302/// Deterministic semantic tree for one physical page.
1303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1304pub struct PageRenderTree {
1305    pub contract_version: u16,
1306    #[serde(default, skip_serializing_if = "Option::is_none")]
1307    pub view_id: Option<String>,
1308    pub page: PageArtifact,
1309    pub nodes: Vec<PageRenderNode>,
1310}
1311
1312impl PageRenderTree {
1313    /// Verify that every serialized node still refers to this page and to a
1314    /// canonical score or publication object. Hosts can run this before using
1315    /// a persisted tree, rather than trusting page-local indices.
1316    pub fn validate(&self, score: &Score) -> Result<(), PrintLayoutError> {
1317        if self.contract_version != PAGE_RENDER_TREE_CONTRACT_VERSION {
1318            return Err(PrintLayoutError::UnsupportedPageRenderTreeContractVersion {
1319                found: self.contract_version,
1320            });
1321        }
1322        let view = self
1323            .view_id
1324            .as_deref()
1325            .map(|view_id| {
1326                score
1327                    .views
1328                    .iter()
1329                    .find(|view| view.id == view_id)
1330                    .ok_or(PrintLayoutError::InvalidView)
1331            })
1332            .transpose()?;
1333        for (node_index, node) in self.nodes.iter().enumerate() {
1334            let system = node.system.and_then(|address| {
1335                self.page
1336                    .layout
1337                    .systems
1338                    .iter()
1339                    .find(|system| system.address == address)
1340            });
1341            let score_owned = matches!(
1342                &node.address,
1343                PageRenderAddress::Note(_) | PageRenderAddress::Spanner { .. }
1344            );
1345            if score_owned != node.system.is_some() || node.system.is_some() && system.is_none() {
1346                return Err(PrintLayoutError::InvalidRenderTreeNode { node_index });
1347            }
1348            let valid = match &node.address {
1349                PageRenderAddress::Note(address) => score
1350                    .parts
1351                    .get(address.part)
1352                    .and_then(|part| part.staves.get(address.staff))
1353                    .and_then(|staff| staff.measures.get(address.measure))
1354                    .and_then(|measure| measure.voices.get(address.voice))
1355                    .and_then(|voice| voice.get(address.note))
1356                    .is_some_and(|note| {
1357                        matches!(
1358                            (&node.kind, note.is_rest),
1359                            (PageRenderNodeKind::Note, false) | (PageRenderNodeKind::Rest, true)
1360                        )
1361                    }),
1362                PageRenderAddress::Spanner { id } => {
1363                    matches!(&node.kind, PageRenderNodeKind::Spanner { .. })
1364                        && score.spanners.iter().any(|span| span.id == *id)
1365                }
1366                PageRenderAddress::Publication {
1367                    page_index,
1368                    block_index,
1369                } => {
1370                    matches!(&node.kind, PageRenderNodeKind::PublicationBlock)
1371                        && *page_index == self.page.page_index
1372                        && self
1373                            .page
1374                            .layout
1375                            .publication
1376                            .text_blocks
1377                            .get(*block_index)
1378                            .is_some()
1379                }
1380                PageRenderAddress::Resource {
1381                    page_index,
1382                    resource_key,
1383                } => {
1384                    matches!(&node.kind, PageRenderNodeKind::Resource)
1385                        && *page_index == self.page.page_index
1386                        && self
1387                            .page
1388                            .layout
1389                            .publication
1390                            .image_resources
1391                            .iter()
1392                            .any(|image| image.resource_key == *resource_key)
1393                }
1394                PageRenderAddress::Frame {
1395                    page_index,
1396                    frame_index,
1397                } => {
1398                    matches!(&node.kind, PageRenderNodeKind::Frame)
1399                        && *page_index == self.page.page_index
1400                        && self
1401                            .page
1402                            .layout
1403                            .publication
1404                            .frames
1405                            .get(*frame_index)
1406                            .is_some()
1407                }
1408            };
1409            if !valid {
1410                return Err(PrintLayoutError::InvalidRenderTreeNode { node_index });
1411            }
1412            let visible_in_view = match (&node.address, view) {
1413                (PageRenderAddress::Note(address), Some(view)) => {
1414                    view.parts.contains(&address.part)
1415                        && !view.layout.hidden_staves.iter().any(|hidden| {
1416                            hidden.part == address.part && hidden.staff == address.staff
1417                        })
1418                }
1419                (PageRenderAddress::Spanner { id }, Some(view)) => score
1420                    .spanners
1421                    .iter()
1422                    .find(|spanner| spanner.id == *id)
1423                    .is_some_and(|spanner| {
1424                        let endpoint_is_visible = |part: usize, staff: usize| {
1425                            view.parts.contains(&part)
1426                                && !view
1427                                    .layout
1428                                    .hidden_staves
1429                                    .iter()
1430                                    .any(|hidden| hidden.part == part && hidden.staff == staff)
1431                        };
1432                        endpoint_is_visible(spanner.start.part, spanner.start.staff)
1433                            && endpoint_is_visible(spanner.end.part, spanner.end.staff)
1434                    }),
1435                _ => true,
1436            };
1437            if !visible_in_view {
1438                return Err(PrintLayoutError::InvalidRenderTreeNode { node_index });
1439            }
1440        }
1441        Ok(())
1442    }
1443}
1444
1445/// Typed, host-neutral diagnostics attached to a page export descriptor.
1446#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1447pub enum PageArtifactDiagnostic {
1448    /// The page uses a host-owned glyph resource and must be resolved by the exporter.
1449    GlyphResourceRequired,
1450    /// Host-provided glyph extents exceed the page content area on one or more sides.
1451    GlyphOverflow {
1452        left: bool,
1453        top: bool,
1454        right: bool,
1455        bottom: bool,
1456    },
1457    /// A span continues across a page boundary and needs a continuation mark in the host.
1458    SpanContinuation {
1459        span_index: usize,
1460        starts_here: bool,
1461        ends_here: bool,
1462    },
1463}
1464
1465impl PageLayout {
1466    /// Return the inclusive physical measure range represented on this page.
1467    pub fn measure_span(&self) -> Option<MeasureSpan> {
1468        let mut spans = self
1469            .systems
1470            .iter()
1471            .flat_map(|system| system.measure_spans.iter().copied());
1472        let first = spans.next()?;
1473        Some(spans.fold(first, |range, span| MeasureSpan {
1474            first_measure: range.first_measure.min(span.first_measure),
1475            last_measure: range.last_measure.max(span.last_measure),
1476        }))
1477    }
1478
1479    /// Whether a span continues into or out of another printed page.
1480    pub fn has_span_continuation(&self) -> bool {
1481        self.span_segments
1482            .iter()
1483            .any(|segment| !segment.starts_here || !segment.ends_here)
1484    }
1485
1486    /// Build deterministic page diagnostics from optional host-computed glyph extents.
1487    ///
1488    /// Extents are expressed relative to the page content origin. This keeps overflow
1489    /// detection independent of fonts and renderers while allowing a host to report a
1490    /// clipping risk before producing an SVG, PDF, or print artifact.
1491    pub fn artifact_diagnostics(
1492        &self,
1493        glyph_extents: Option<GlyphExtents>,
1494    ) -> Vec<PageArtifactDiagnostic> {
1495        let mut diagnostics = Vec::new();
1496        if matches!(self.glyph_resources, GlyphResourcePolicy::HostProvided(_)) {
1497            diagnostics.push(PageArtifactDiagnostic::GlyphResourceRequired);
1498        }
1499        if let Some(extents) = glyph_extents {
1500            let overflow = PageArtifactDiagnostic::GlyphOverflow {
1501                left: extents.left_mm < 0.0,
1502                top: extents.top_mm < 0.0,
1503                right: extents.right_mm > self.content_width_mm,
1504                bottom: extents.bottom_mm > self.content_height_mm,
1505            };
1506            if let PageArtifactDiagnostic::GlyphOverflow {
1507                left,
1508                top,
1509                right,
1510                bottom,
1511            } = overflow
1512                && (left || top || right || bottom)
1513            {
1514                diagnostics.push(overflow);
1515            }
1516        }
1517        diagnostics.extend(
1518            self.span_segments
1519                .iter()
1520                .filter(|segment| !segment.starts_here || !segment.ends_here)
1521                .map(|segment| PageArtifactDiagnostic::SpanContinuation {
1522                    span_index: segment.span_index,
1523                    starts_here: segment.starts_here,
1524                    ends_here: segment.ends_here,
1525                }),
1526        );
1527        diagnostics
1528    }
1529}
1530
1531/// Deterministic page/system geometry for a score.
1532#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1533pub struct PrintLayoutResult {
1534    pub contract_version: u16,
1535    pub pages: Vec<PageLayout>,
1536}
1537
1538impl PrintLayoutResult {
1539    /// Validate page and system addresses before consuming a serialized layout.
1540    ///
1541    /// Layouts produced by [`compute_print_layout`] satisfy this contract. The explicit
1542    /// validation is useful for hosts that persist or transport `PrintLayoutResult` values.
1543    pub fn validate(&self) -> Result<(), PrintLayoutError> {
1544        if self.contract_version != PRINT_LAYOUT_CONTRACT_VERSION {
1545            return Err(PrintLayoutError::UnsupportedContractVersion {
1546                found: self.contract_version,
1547            });
1548        }
1549        let mut expected_system_index = 0;
1550        let mut previous_page_number = None;
1551        let mut numbered_pages = None;
1552        for (page_index, page) in self.pages.iter().enumerate() {
1553            if page.page_index != page_index || page.address.page_index != page_index {
1554                return Err(PrintLayoutError::InvalidPageAddress { page_index });
1555            }
1556            if !page.width_mm.is_finite()
1557                || !page.height_mm.is_finite()
1558                || page.width_mm <= 0.0
1559                || page.height_mm <= 0.0
1560                || !page.content_width_mm.is_finite()
1561                || !page.content_height_mm.is_finite()
1562                || page.content_width_mm <= 0.0
1563                || page.content_height_mm <= 0.0
1564                || page.content_width_mm > page.width_mm
1565                || page.content_height_mm > page.height_mm
1566                || !page.bleed_top_mm.is_finite()
1567                || !page.bleed_right_mm.is_finite()
1568                || !page.bleed_bottom_mm.is_finite()
1569                || !page.bleed_left_mm.is_finite()
1570                || page.bleed_top_mm < 0.0
1571                || page.bleed_right_mm < 0.0
1572                || page.bleed_bottom_mm < 0.0
1573                || page.bleed_left_mm < 0.0
1574            {
1575                return Err(PrintLayoutError::InvalidPageGeometry { page_index });
1576            }
1577            if page.publication.image_resources.iter().any(|image| {
1578                !publication_image_resource_is_valid(image, page.width_mm, page.height_mm)
1579            }) || page
1580                .publication
1581                .frames
1582                .iter()
1583                .any(|frame| !publication_frame_is_valid(frame, page.width_mm, page.height_mm))
1584                || !publication_page_sections_are_valid(&page.publication.sections)
1585                || !publication_page_spacers_are_valid(&page.publication.spacers)
1586            {
1587                return Err(PrintLayoutError::InvalidPublicationMetadata { page_index });
1588            }
1589            let is_title_break = page.break_reason == BreakReason::TitlePage;
1590            if is_title_break != page.publication.is_title_page
1591                || (is_title_break && (page_index != 0 || !page.systems.is_empty()))
1592            {
1593                return Err(PrintLayoutError::InvalidTitlePage { page_index });
1594            }
1595            match page.page_number {
1596                Some(page_number)
1597                    if page_number == 0
1598                        || numbered_pages == Some(false)
1599                        || page_index.checked_add(1) != Some(page_number)
1600                        || previous_page_number.is_some_and(|previous| page_number <= previous) =>
1601                {
1602                    return Err(PrintLayoutError::InvalidPageNumber { page_index });
1603                }
1604                Some(page_number) => {
1605                    numbered_pages = Some(true);
1606                    previous_page_number = Some(page_number);
1607                }
1608                None if numbered_pages == Some(true) => {
1609                    return Err(PrintLayoutError::InvalidPageNumber { page_index });
1610                }
1611                None => numbered_pages = Some(false),
1612            }
1613            let mut previous_system_bottom_mm = 0.0;
1614            for (index_on_page, system) in page.systems.iter().enumerate() {
1615                if system.page_index != page_index
1616                    || system.address.page_index != page_index
1617                    || system.address.index_on_page != index_on_page
1618                    || system.system_index != expected_system_index
1619                    || system.address.system_index != expected_system_index
1620                {
1621                    return Err(PrintLayoutError::InvalidSystemAddress {
1622                        page_index,
1623                        index_on_page,
1624                        system_index: expected_system_index,
1625                    });
1626                }
1627                let system_bottom_mm = system.top_mm + system.height_mm;
1628                if !system.top_mm.is_finite()
1629                    || system.top_mm < 0.0
1630                    || !system.height_mm.is_finite()
1631                    || system.height_mm <= 0.0
1632                    || !system_bottom_mm.is_finite()
1633                    || system_bottom_mm > page.height_mm
1634                    || system.top_mm < previous_system_bottom_mm
1635                {
1636                    return Err(PrintLayoutError::InvalidSystemGeometry {
1637                        page_index,
1638                        index_on_page,
1639                    });
1640                }
1641                previous_system_bottom_mm = system_bottom_mm;
1642                expected_system_index += 1;
1643            }
1644        }
1645        Ok(())
1646    }
1647
1648    /// Retrieve one page artifact by its stable address without recomputing layout.
1649    pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
1650        self.pages
1651            .get(address.page_index)
1652            .filter(|page| page.address == address)
1653    }
1654
1655    /// Export validated page descriptors for host renderers and archival backends.
1656    ///
1657    /// The returned vector preserves physical page order. No filesystem, PDF backend, font
1658    /// loader, or renderer-specific object is involved; hosts can serialize or render each
1659    /// descriptor independently. Validation happens before any descriptor is returned.
1660    pub fn export_page_artifacts(&self) -> Result<Vec<PageArtifact>, PrintLayoutError> {
1661        self.validate()?;
1662        Ok(self
1663            .pages
1664            .iter()
1665            .map(|page| PageArtifact {
1666                address: page.address,
1667                page_index: page.page_index,
1668                page_number: page.page_number,
1669                width_mm: page.width_mm,
1670                height_mm: page.height_mm,
1671                content_width_mm: page.content_width_mm,
1672                content_height_mm: page.content_height_mm,
1673                measure_span: page.measure_span(),
1674                diagnostics: page.artifact_diagnostics(None),
1675                layout: page.clone(),
1676            })
1677            .collect())
1678    }
1679
1680    /// Project this validated pagination and score into page-owned semantic nodes.
1681    ///
1682    /// Nodes keep canonical score addresses so a host never has to reverse-engineer
1683    /// ownership from SVG groups or page-local indices.
1684    pub fn export_page_render_trees(
1685        &self,
1686        score: &Score,
1687    ) -> Result<Vec<PageRenderTree>, PrintLayoutError> {
1688        let artifacts = self.export_page_artifacts()?;
1689        Ok(self
1690            .pages
1691            .iter()
1692            .zip(artifacts)
1693            .map(|(page, artifact)| {
1694                let mut nodes = Vec::new();
1695                for system in &page.systems {
1696                    for span in &system.measure_spans {
1697                        for (part_index, part) in score.parts.iter().enumerate() {
1698                            for (staff_index, staff) in part.staves.iter().enumerate() {
1699                                for measure_index in span.first_measure..=span.last_measure {
1700                                    let Some(measure) = staff.measures.get(measure_index) else {
1701                                        continue;
1702                                    };
1703                                    for (voice, notes) in measure.voices.iter().enumerate() {
1704                                        for (note, value) in notes.iter().enumerate() {
1705                                            nodes.push(PageRenderNode {
1706                                                address: PageRenderAddress::Note(NoteAddr {
1707                                                    part: part_index,
1708                                                    staff: staff_index,
1709                                                    measure: measure_index,
1710                                                    voice,
1711                                                    note,
1712                                                }),
1713                                                kind: if value.is_rest {
1714                                                    PageRenderNodeKind::Rest
1715                                                } else {
1716                                                    PageRenderNodeKind::Note
1717                                                },
1718                                                system: Some(system.address),
1719                                            });
1720                                        }
1721                                    }
1722                                }
1723                            }
1724                        }
1725                    }
1726                    for segment in &system.span_segments {
1727                        if let Some(spanner) = score.spanners.get(segment.span_index) {
1728                            nodes.push(PageRenderNode {
1729                                address: PageRenderAddress::Spanner {
1730                                    id: spanner.id.clone(),
1731                                },
1732                                kind: PageRenderNodeKind::Spanner {
1733                                    starts_here: segment.starts_here,
1734                                    ends_here: segment.ends_here,
1735                                },
1736                                system: Some(system.address),
1737                            });
1738                        }
1739                    }
1740                }
1741                for (block_index, _) in page.publication.text_blocks.iter().enumerate() {
1742                    nodes.push(PageRenderNode {
1743                        address: PageRenderAddress::Publication {
1744                            page_index: page.page_index,
1745                            block_index,
1746                        },
1747                        kind: PageRenderNodeKind::PublicationBlock,
1748                        system: None,
1749                    });
1750                }
1751                for image in &page.publication.image_resources {
1752                    nodes.push(PageRenderNode {
1753                        address: PageRenderAddress::Resource {
1754                            page_index: page.page_index,
1755                            resource_key: image.resource_key.clone(),
1756                        },
1757                        kind: PageRenderNodeKind::Resource,
1758                        system: None,
1759                    });
1760                }
1761                for (frame_index, _) in page.publication.frames.iter().enumerate() {
1762                    nodes.push(PageRenderNode {
1763                        address: PageRenderAddress::Frame {
1764                            page_index: page.page_index,
1765                            frame_index,
1766                        },
1767                        kind: PageRenderNodeKind::Frame,
1768                        system: None,
1769                    });
1770                }
1771                PageRenderTree {
1772                    contract_version: PAGE_RENDER_TREE_CONTRACT_VERSION,
1773                    view_id: None,
1774                    page: artifact,
1775                    nodes,
1776                }
1777            })
1778            .collect())
1779    }
1780
1781    /// Export page trees for a linked view without rewriting canonical source addresses.
1782    pub fn export_page_render_trees_for_view(
1783        &self,
1784        score: &Score,
1785        view_id: &str,
1786    ) -> Result<Vec<PageRenderTree>, PrintLayoutError> {
1787        let view = score
1788            .views
1789            .iter()
1790            .find(|view| view.id == view_id)
1791            .ok_or(PrintLayoutError::InvalidView)?;
1792        let selected_parts = &view.parts;
1793        let hidden_staves = &view.layout.hidden_staves;
1794        let staff_is_visible = |part: usize, staff: usize| {
1795            selected_parts.contains(&part)
1796                && !hidden_staves
1797                    .iter()
1798                    .any(|hidden| hidden.part == part && hidden.staff == staff)
1799        };
1800        let mut trees = self.export_page_render_trees(score)?;
1801        for tree in &mut trees {
1802            tree.view_id = Some(view.id.clone());
1803            tree.nodes.retain(|node| match &node.address {
1804                PageRenderAddress::Note(address) => staff_is_visible(address.part, address.staff),
1805                PageRenderAddress::Spanner { id } => score
1806                    .spanners
1807                    .iter()
1808                    .find(|spanner| spanner.id == *id)
1809                    .is_some_and(|spanner| {
1810                        staff_is_visible(spanner.start.part, spanner.start.staff)
1811                            && staff_is_visible(spanner.end.part, spanner.end.staff)
1812                    }),
1813                PageRenderAddress::Publication { .. } => true,
1814                PageRenderAddress::Resource { .. } => true,
1815                PageRenderAddress::Frame { .. } => true,
1816            });
1817        }
1818        Ok(trees)
1819    }
1820}
1821
1822#[derive(Debug, thiserror::Error, PartialEq)]
1823pub enum PrintLayoutError {
1824    #[error("unsupported page render tree contract version {found}")]
1825    UnsupportedPageRenderTreeContractVersion { found: u16 },
1826    #[error("page render tree node {node_index} has invalid ownership or address")]
1827    InvalidRenderTreeNode { node_index: usize },
1828    #[error("unknown score view")]
1829    InvalidView,
1830    #[error("paper dimensions must be finite and greater than zero")]
1831    InvalidPaperDimensions,
1832    #[error("margins must be finite and non-negative")]
1833    InvalidMargins,
1834    #[error("system height must be finite, positive, and fit the usable page area")]
1835    InvalidSystemHeight,
1836    #[error("print scale must be finite and greater than zero")]
1837    InvalidScale,
1838    #[error("margins leave no usable page area")]
1839    NoUsablePageArea,
1840    #[error("keep-together range is outside the score or reversed")]
1841    InvalidKeepTogetherRange,
1842    #[error("keep-together range exceeds the measures-per-system capacity")]
1843    KeepTogetherExceedsSystemCapacity,
1844    #[error("keep-together range conflicts with an explicit system or page break")]
1845    KeepTogetherConflictsWithExplicitBreak,
1846    #[error("repeat section exceeds the systems-per-page capacity")]
1847    RepeatRangeExceedsPageCapacity,
1848    #[error("extracted part index is outside the score")]
1849    InvalidPartIndex,
1850    #[error("publication line height must be finite and greater than zero")]
1851    InvalidPublicationLineHeight,
1852    #[error("host-provided glyph resource key must not be empty")]
1853    InvalidGlyphResourceKey,
1854    #[error("publication image resource {index} is invalid")]
1855    InvalidPublicationImageResource { index: usize },
1856    #[error("publication section {index} is invalid")]
1857    InvalidPublicationSection { index: usize },
1858    #[error("publication section {index} starts inside a keep-together range")]
1859    PublicationSectionConflictsWithKeepTogether { index: usize },
1860    #[error("publication spacer {index} is invalid or cannot fit before one system")]
1861    InvalidPublicationSpacer { index: usize },
1862    #[error("publication frame {index} is invalid")]
1863    InvalidPublicationFrame { index: usize },
1864    #[error("unsupported print layout contract version {found}")]
1865    UnsupportedContractVersion { found: u16 },
1866    #[error("page {page_index} has an inconsistent stable address")]
1867    InvalidPageAddress { page_index: usize },
1868    #[error("page {page_index} has an invalid or non-monotonic page number")]
1869    InvalidPageNumber { page_index: usize },
1870    #[error("page {page_index} has inconsistent title-page metadata")]
1871    InvalidTitlePage { page_index: usize },
1872    #[error(
1873        "system {system_index} at page {page_index}, position {index_on_page} has an inconsistent stable address"
1874    )]
1875    InvalidSystemAddress {
1876        page_index: usize,
1877        index_on_page: usize,
1878        system_index: usize,
1879    },
1880    #[error("page {page_index} has invalid physical geometry")]
1881    InvalidPageGeometry { page_index: usize },
1882    #[error("page {page_index} has invalid publication metadata")]
1883    InvalidPublicationMetadata { page_index: usize },
1884    #[error("system at page {page_index}, position {index_on_page} has invalid physical geometry")]
1885    InvalidSystemGeometry {
1886        page_index: usize,
1887        index_on_page: usize,
1888    },
1889}
1890
1891fn apply_keep_together(
1892    score: &Score,
1893    mut rows: Vec<crate::RowLayout>,
1894    ranges: &[KeepTogetherRange],
1895    capacity: usize,
1896) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
1897    let measure_count = score
1898        .parts
1899        .first()
1900        .and_then(|part| part.staves.first())
1901        .map(|staff| staff.measures.len())
1902        .unwrap_or(0);
1903    for range in ranges {
1904        let length = range
1905            .last_measure
1906            .checked_sub(range.first_measure)
1907            .and_then(|length| length.checked_add(1));
1908        if range.first_measure > range.last_measure || range.last_measure >= measure_count {
1909            return Err(PrintLayoutError::InvalidKeepTogetherRange);
1910        }
1911        if length.is_none_or(|length| length > capacity) {
1912            return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1913        }
1914        for measure_index in range.first_measure..range.last_measure {
1915            let has_break = score
1916                .parts
1917                .iter()
1918                .flat_map(|part| part.staves.iter())
1919                .filter_map(|staff| staff.measures.get(measure_index))
1920                .any(|measure| measure.system_break || measure.page_break);
1921            if has_break {
1922                return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
1923            }
1924        }
1925
1926        // Split at the range boundaries before merging rows. This allows a range that
1927        // crosses an existing system boundary to be reflowed without pulling unrelated
1928        // measures into the merged system.
1929        let mut split_rows = Vec::with_capacity(rows.len() + 2);
1930        for row in rows {
1931            let mut cuts = vec![0, row.measure_indices.len()];
1932            if let Some(position) = row
1933                .measure_indices
1934                .iter()
1935                .position(|&index| index == range.first_measure)
1936            {
1937                cuts.push(position);
1938            }
1939            if let Some(position) = row
1940                .measure_indices
1941                .iter()
1942                .position(|&index| index == range.last_measure)
1943            {
1944                cuts.push(position + 1);
1945            }
1946            cuts.sort_unstable();
1947            cuts.dedup();
1948            for window in cuts.windows(2) {
1949                if window[0] < window[1] {
1950                    split_rows.push(crate::RowLayout {
1951                        measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
1952                    });
1953                }
1954            }
1955        }
1956        rows = split_rows;
1957
1958        let first_row = rows
1959            .iter()
1960            .position(|row| row.measure_indices.contains(&range.first_measure));
1961        let last_row = rows
1962            .iter()
1963            .position(|row| row.measure_indices.contains(&range.last_measure));
1964        let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
1965            return Err(PrintLayoutError::InvalidKeepTogetherRange);
1966        };
1967
1968        if first_row != last_row {
1969            let merged: Vec<usize> = rows[first_row..=last_row]
1970                .iter()
1971                .flat_map(|row| row.measure_indices.iter().copied())
1972                .collect();
1973            if merged.len() > capacity {
1974                return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1975            }
1976            rows.splice(
1977                first_row..=last_row,
1978                [crate::RowLayout {
1979                    measure_indices: merged,
1980                }],
1981            );
1982        }
1983
1984        let row_index = rows
1985            .iter()
1986            .position(|row| row.measure_indices.contains(&range.first_measure))
1987            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1988        let row = rows.remove(row_index);
1989        let start = row
1990            .measure_indices
1991            .iter()
1992            .position(|&index| index == range.first_measure)
1993            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1994        let end = row
1995            .measure_indices
1996            .iter()
1997            .position(|&index| index == range.last_measure)
1998            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1999        let mut replacement = Vec::new();
2000        if start > 0 {
2001            replacement.push(crate::RowLayout {
2002                measure_indices: row.measure_indices[..start].to_vec(),
2003            });
2004        }
2005        replacement.push(crate::RowLayout {
2006            measure_indices: row.measure_indices[start..=end].to_vec(),
2007        });
2008        if end + 1 < row.measure_indices.len() {
2009            replacement.push(crate::RowLayout {
2010                measure_indices: row.measure_indices[end + 1..].to_vec(),
2011            });
2012        }
2013        rows.splice(row_index..row_index, replacement);
2014    }
2015    Ok(rows)
2016}
2017
2018fn has_first_measure_pickup(score: &Score) -> bool {
2019    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2020        return false;
2021    };
2022    let Some(measure) = staff.measures.first() else {
2023        return false;
2024    };
2025    // An authored pickup length is explicit evidence; otherwise infer it from underfull content.
2026    if let Some(length) = measure.actual_length.and_then(|length| length.beats()) {
2027        let bar = measure
2028            .time_sig
2029            .as_ref()
2030            .unwrap_or(&score.settings.time_signature)
2031            .total_beats();
2032        return length < bar - 1e-9;
2033    }
2034    let expected = measure
2035        .time_sig
2036        .as_ref()
2037        .unwrap_or(&score.settings.time_signature)
2038        .total_beats();
2039    let actual = measure
2040        .voices
2041        .iter()
2042        .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
2043        .fold(0.0, f64::max);
2044    actual > 1e-9 && actual + 1e-9 < expected
2045}
2046
2047fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
2048    let measure_count = score
2049        .parts
2050        .first()
2051        .and_then(|part| part.staves.first())
2052        .map(|staff| staff.measures.len())
2053        .unwrap_or(0);
2054    measure_indices
2055        .iter()
2056        .filter_map(|&first_measure| {
2057            if first_measure >= measure_count {
2058                return None;
2059            }
2060            let count = score
2061                .parts
2062                .iter()
2063                .flat_map(|part| part.staves.iter())
2064                .filter_map(|staff| staff.measures.get(first_measure))
2065                .filter_map(|measure| measure.multi_rest_count)
2066                .map(usize::from)
2067                .max()
2068                .unwrap_or(1)
2069                .max(1);
2070            Some(MeasureSpan {
2071                first_measure,
2072                last_measure: first_measure
2073                    .saturating_add(count.saturating_sub(1))
2074                    .min(measure_count.saturating_sub(1)),
2075            })
2076        })
2077        .collect()
2078}
2079
2080fn span_bounds(span: &SpanMark) -> (usize, usize) {
2081    match span {
2082        SpanMark::Hairpin { start, end, .. }
2083        | SpanMark::Ottava { start, end, .. }
2084        | SpanMark::Pedal { start, end }
2085        | SpanMark::Slur { start, end }
2086        | SpanMark::TrillLine { start, end }
2087        | SpanMark::Glissando { start, end }
2088        | SpanMark::Harmony { start, end, .. } => (
2089            start.measure.min(end.measure),
2090            start.measure.max(end.measure),
2091        ),
2092    }
2093}
2094
2095fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
2096    let (Some(&first_measure), Some(&last_measure)) =
2097        (measure_indices.first(), measure_indices.last())
2098    else {
2099        return Vec::new();
2100    };
2101    spans
2102        .iter()
2103        .enumerate()
2104        .filter_map(|(span_index, span)| {
2105            let (start_measure, end_measure) = span_bounds(span);
2106            (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
2107                span_index,
2108                starts_here: (first_measure..=last_measure).contains(&start_measure),
2109                ends_here: (first_measure..=last_measure).contains(&end_measure),
2110            })
2111        })
2112        .collect()
2113}
2114
2115fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
2116    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2117        return Vec::new();
2118    };
2119    measure_indices
2120        .iter()
2121        .filter_map(|&measure_index| {
2122            let measure = staff.measures.get(measure_index)?;
2123            let repeat_start = matches!(
2124                measure.barline_left,
2125                Barline::RepeatStart | Barline::RepeatBoth
2126            );
2127            let repeat_end = matches!(
2128                measure.barline_right,
2129                Barline::RepeatEnd | Barline::RepeatBoth
2130            );
2131            let text_annotations = measure_text_entries(measure);
2132            let has_mark = repeat_start
2133                || repeat_end
2134                || measure.volta.is_some()
2135                || measure.navigation.is_some()
2136                || measure.rehearsal.is_some()
2137                || !text_annotations.is_empty();
2138            has_mark.then(|| MeasureMark {
2139                measure_index,
2140                repeat_start,
2141                repeat_end,
2142                volta_number: measure.volta.as_ref().map(|volta| volta.number),
2143                volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
2144                navigation: measure.navigation.clone(),
2145                rehearsal: measure.rehearsal.clone(),
2146                text_annotations,
2147            })
2148        })
2149        .collect()
2150}
2151
2152fn measure_text_entries(measure: &acorde_core::Measure) -> Vec<StyledText> {
2153    let mut entries = measure.texts.clone();
2154    for (style, text) in [
2155        (TextStyle::Generic, measure.tempo_text.as_deref()),
2156        (TextStyle::RehearsalMark, measure.rehearsal.as_deref()),
2157        (TextStyle::Generic, measure.navigation.as_deref()),
2158        (TextStyle::Expression, measure.expression_text.as_deref()),
2159    ] {
2160        let Some(text) = text else {
2161            continue;
2162        };
2163        if entries
2164            .iter()
2165            .any(|entry| entry.style == style && entry.text == text)
2166        {
2167            continue;
2168        }
2169        entries.push(StyledText {
2170            style,
2171            text: text.to_owned(),
2172            placement: None,
2173            offset_x: None,
2174            offset_y: None,
2175            relative_x: None,
2176            relative_y: None,
2177        });
2178    }
2179    entries
2180}
2181
2182fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2183    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2184        return Vec::new();
2185    };
2186    let mut ranges = Vec::new();
2187    let mut start = None;
2188    for (index, measure) in staff.measures.iter().enumerate() {
2189        let Some(volta) = measure.volta.as_ref() else {
2190            continue;
2191        };
2192        if matches!(volta.kind.as_str(), "begin" | "begin_end") {
2193            start = Some(index);
2194        }
2195        if matches!(volta.kind.as_str(), "end" | "begin_end")
2196            && let Some(first_measure) = start.take()
2197        {
2198            ranges.push(KeepTogetherRange {
2199                first_measure,
2200                last_measure: index,
2201            });
2202        }
2203    }
2204    ranges
2205}
2206
2207fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2208    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2209        return Vec::new();
2210    };
2211    let mut ranges = Vec::new();
2212    let mut start = None;
2213    for (index, measure) in staff.measures.iter().enumerate() {
2214        if matches!(
2215            measure.barline_left,
2216            Barline::RepeatStart | Barline::RepeatBoth
2217        ) {
2218            start = Some(index);
2219        }
2220        if matches!(
2221            measure.barline_right,
2222            Barline::RepeatEnd | Barline::RepeatBoth
2223        ) {
2224            ranges.push(KeepTogetherRange {
2225                first_measure: start.take().unwrap_or(0),
2226                last_measure: index,
2227            });
2228        }
2229    }
2230    ranges
2231}
2232
2233fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
2234    repeat_ranges(score)
2235        .into_iter()
2236        .filter_map(|range| {
2237            let first = rows
2238                .iter()
2239                .position(|row| row.measure_indices.contains(&range.first_measure))?;
2240            let last = rows
2241                .iter()
2242                .position(|row| row.measure_indices.contains(&range.last_measure))?;
2243            Some((first, last))
2244        })
2245        .collect()
2246}
2247
2248fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
2249    let mut segments = Vec::new();
2250    for system in systems {
2251        for segment in &system.span_segments {
2252            if let Some(existing) = segments
2253                .iter_mut()
2254                .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
2255            {
2256                existing.ends_here |= segment.ends_here;
2257            } else {
2258                segments.push(PageSpanSegment {
2259                    span_index: segment.span_index,
2260                    starts_here: segment.starts_here,
2261                    ends_here: segment.ends_here,
2262                });
2263            }
2264        }
2265    }
2266    segments
2267}
2268
2269fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
2270    systems
2271        .iter()
2272        .flat_map(|system| system.measure_marks.iter().cloned())
2273        .collect()
2274}
2275
2276fn page_publication(
2277    score: &Score,
2278    measure_score: &Score,
2279    config: &PrintConfig,
2280    systems: &[SystemLayout],
2281    is_title_page: bool,
2282    page_number: Option<usize>,
2283) -> PagePublication {
2284    let metadata = &score.metadata;
2285    let part_labels = if config.publication.show_part_names {
2286        let parts = match config.part_layout {
2287            PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
2288            PartLayoutPolicy::ExtractedPart { part_index } => score
2289                .parts
2290                .get(part_index)
2291                .into_iter()
2292                .enumerate()
2293                .map(|(index, part)| (part_index + index, part))
2294                .collect(),
2295        };
2296        parts
2297            .into_iter()
2298            .map(|(part_index, part)| PartLabel {
2299                part_index,
2300                name: part.name.clone(),
2301                short_name: part.short_name.clone(),
2302            })
2303            .collect()
2304    } else {
2305        Vec::new()
2306    };
2307    let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
2308        score
2309            .part_groups
2310            .iter()
2311            .map(|group| PartGroupMark {
2312                first_part: group.first_part,
2313                last_part: group.last_part,
2314                symbol: group.symbol.clone(),
2315                barlines_connect: group.barlines_connect,
2316            })
2317            .collect()
2318    } else {
2319        Vec::new()
2320    };
2321    let measure_numbers = if config.publication.show_measure_numbers {
2322        let staff = measure_score
2323            .parts
2324            .first()
2325            .and_then(|part| part.staves.first());
2326        systems
2327            .iter()
2328            .flat_map(|system| system.measure_indices.iter().copied())
2329            .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
2330            .map(|measure| measure.number)
2331            .collect()
2332    } else {
2333        Vec::new()
2334    };
2335    let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2336    let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
2337        (paper_height, paper_width)
2338    } else {
2339        (paper_width, paper_height)
2340    };
2341    let mut text_blocks = Vec::new();
2342    let header_text = if config.publication.header_template.is_configured() {
2343        config.publication.header_template.resolve(page_number)
2344    } else {
2345        config
2346            .publication
2347            .header_text
2348            .as_ref()
2349            .or(config.publication.running_title.as_ref())
2350    };
2351    if !is_title_page && let Some(text) = header_text {
2352        text_blocks.push(PublicationTextBlock {
2353            role: PublicationTextRole::Header,
2354            text: text.clone(),
2355            x_mm: config.margin_left_mm + config.safe_left_mm,
2356            y_mm: config.margin_top_mm,
2357            width_mm: page_width
2358                - config.margin_left_mm
2359                - config.margin_right_mm
2360                - config.safe_left_mm
2361                - config.safe_right_mm,
2362            height_mm: config.publication.line_height_mm,
2363            alignment: config.publication.header_alignment,
2364        });
2365    }
2366    let footer_text = if config.publication.footer_template.is_configured() {
2367        config.publication.footer_template.resolve(page_number)
2368    } else {
2369        config.publication.footer_text.as_ref()
2370    };
2371    if let Some(text) = footer_text {
2372        text_blocks.push(PublicationTextBlock {
2373            role: PublicationTextRole::Footer,
2374            text: text.clone(),
2375            x_mm: config.margin_left_mm + config.safe_left_mm,
2376            y_mm: page_height - config.margin_bottom_mm,
2377            width_mm: page_width
2378                - config.margin_left_mm
2379                - config.margin_right_mm
2380                - config.safe_left_mm
2381                - config.safe_right_mm,
2382            height_mm: config.publication.line_height_mm,
2383            alignment: config.publication.footer_alignment,
2384        });
2385    }
2386    if config.publication.page_number_in_footer
2387        && let Some(page_number) = page_number
2388    {
2389        let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2390        let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape)
2391        {
2392            (paper_height, paper_width)
2393        } else {
2394            (paper_width, paper_height)
2395        };
2396        text_blocks.push(PublicationTextBlock {
2397            role: PublicationTextRole::Footer,
2398            text: page_number.to_string(),
2399            x_mm: config.margin_left_mm + config.safe_left_mm,
2400            y_mm: page_height - config.margin_bottom_mm,
2401            width_mm: page_width
2402                - config.margin_left_mm
2403                - config.margin_right_mm
2404                - config.safe_left_mm
2405                - config.safe_right_mm,
2406            height_mm: config.publication.line_height_mm,
2407            alignment: config.publication.footer_alignment,
2408        });
2409    }
2410    if is_title_page {
2411        let content_height = page_height
2412            - config.margin_top_mm
2413            - config.margin_bottom_mm
2414            - config.safe_top_mm
2415            - config.safe_bottom_mm;
2416        let title_x = config.margin_left_mm + config.safe_left_mm;
2417        let title_width = page_width
2418            - config.margin_left_mm
2419            - config.margin_right_mm
2420            - config.safe_left_mm
2421            - config.safe_right_mm;
2422        let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
2423        if !metadata.title.trim().is_empty() {
2424            text_blocks.push(PublicationTextBlock {
2425                role: PublicationTextRole::Title,
2426                text: metadata.title.clone(),
2427                x_mm: title_x,
2428                y_mm: title_y,
2429                width_mm: title_width,
2430                height_mm: config.publication.line_height_mm,
2431                alignment: config.publication.title_alignment,
2432            });
2433        }
2434        if !metadata.movement_title.trim().is_empty() {
2435            text_blocks.push(PublicationTextBlock {
2436                role: PublicationTextRole::Subtitle,
2437                text: metadata.movement_title.clone(),
2438                x_mm: title_x,
2439                y_mm: title_y + config.publication.line_height_mm * 2.5,
2440                width_mm: title_width,
2441                height_mm: config.publication.line_height_mm,
2442                alignment: config.publication.title_alignment,
2443            });
2444        }
2445        let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
2446            (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
2447                format!("{composer} / {lyricist}")
2448            }
2449            (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
2450            (_, lyricist) => lyricist.to_string(),
2451        };
2452        if !credit.is_empty() {
2453            text_blocks.push(PublicationTextBlock {
2454                role: PublicationTextRole::Credit,
2455                text: credit,
2456                x_mm: title_x,
2457                y_mm: title_y + config.publication.line_height_mm * 5.0,
2458                width_mm: title_width,
2459                height_mm: config.publication.line_height_mm,
2460                alignment: config.publication.title_alignment,
2461            });
2462        }
2463        if !metadata.copyright.trim().is_empty() {
2464            text_blocks.push(PublicationTextBlock {
2465                role: PublicationTextRole::Copyright,
2466                text: metadata.copyright.clone(),
2467                x_mm: title_x,
2468                y_mm: page_height - config.margin_bottom_mm,
2469                width_mm: title_width,
2470                height_mm: config.publication.line_height_mm,
2471                alignment: config.publication.title_alignment,
2472            });
2473        }
2474    }
2475    let image_resources = config
2476        .publication
2477        .image_resources
2478        .iter()
2479        .filter(|image| {
2480            matches!(
2481                (is_title_page, image.placement),
2482                (
2483                    true,
2484                    PublicationImagePlacement::TitlePage | PublicationImagePlacement::EveryPage
2485                ) | (
2486                    false,
2487                    PublicationImagePlacement::MusicPages | PublicationImagePlacement::EveryPage
2488                )
2489            )
2490        })
2491        .cloned()
2492        .collect();
2493    let sections = if is_title_page {
2494        Vec::new()
2495    } else {
2496        config
2497            .publication
2498            .sections
2499            .iter()
2500            .filter(|section| {
2501                systems
2502                    .iter()
2503                    .any(|system| system.measure_indices.contains(&section.first_measure))
2504            })
2505            .cloned()
2506            .collect()
2507    };
2508    let spacers = if is_title_page {
2509        Vec::new()
2510    } else {
2511        config
2512            .publication
2513            .spacers
2514            .iter()
2515            .filter(|spacer| {
2516                systems
2517                    .iter()
2518                    .any(|system| system.measure_indices.contains(&spacer.before_measure))
2519            })
2520            .cloned()
2521            .collect()
2522    };
2523    let frames = config
2524        .publication
2525        .frames
2526        .iter()
2527        .filter(|frame| {
2528            matches!(
2529                (is_title_page, frame.placement),
2530                (
2531                    true,
2532                    PublicationFramePlacement::TitlePage | PublicationFramePlacement::EveryPage
2533                ) | (
2534                    false,
2535                    PublicationFramePlacement::MusicPages | PublicationFramePlacement::EveryPage
2536                )
2537            )
2538        })
2539        .cloned()
2540        .collect();
2541    PagePublication {
2542        is_title_page,
2543        title: metadata.title.clone(),
2544        movement_title: metadata.movement_title.clone(),
2545        composer: metadata.composer.clone(),
2546        lyricist: metadata.lyricist.clone(),
2547        copyright: metadata.copyright.clone(),
2548        running_title: config.publication.running_title.clone(),
2549        score_texts: score.texts.clone(),
2550        part_labels,
2551        part_groups,
2552        measure_numbers,
2553        text_blocks,
2554        image_resources,
2555        sections,
2556        spacers,
2557        frames,
2558    }
2559}
2560
2561#[allow(clippy::too_many_arguments)]
2562fn build_page_layout(
2563    score: &Score,
2564    layout_score: &Score,
2565    config: &PrintConfig,
2566    systems: Vec<SystemLayout>,
2567    page_index: usize,
2568    page_number: Option<usize>,
2569    width_mm: f32,
2570    height_mm: f32,
2571    content_width_mm: f32,
2572    content_height_mm: f32,
2573    break_reason: BreakReason,
2574    is_title_page: bool,
2575) -> PageLayout {
2576    let publication = page_publication(
2577        score,
2578        layout_score,
2579        config,
2580        &systems,
2581        is_title_page,
2582        page_number,
2583    );
2584    let span_segments = if is_title_page {
2585        Vec::new()
2586    } else {
2587        page_span_segments(&systems)
2588    };
2589    let measure_marks = if is_title_page {
2590        Vec::new()
2591    } else {
2592        page_measure_marks(&systems)
2593    };
2594    PageLayout {
2595        address: PageAddress { page_index },
2596        page_index,
2597        page_number,
2598        color_policy: config.color_policy,
2599        crop_mark_policy: config.crop_mark_policy,
2600        glyph_resources: config.glyph_resources.clone(),
2601        publication,
2602        width_mm,
2603        height_mm,
2604        content_width_mm,
2605        content_height_mm,
2606        bleed_top_mm: config.bleed_top_mm,
2607        bleed_right_mm: config.bleed_right_mm,
2608        bleed_bottom_mm: config.bleed_bottom_mm,
2609        bleed_left_mm: config.bleed_left_mm,
2610        span_segments,
2611        measure_marks,
2612        systems,
2613        break_reason,
2614    }
2615}
2616
2617fn score_for_part_layout(
2618    score: &Score,
2619    policy: PartLayoutPolicy,
2620) -> Result<Score, PrintLayoutError> {
2621    let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
2622        return Ok(score.clone());
2623    };
2624    let Some(part) = score.parts.get(part_index) else {
2625        return Err(PrintLayoutError::InvalidPartIndex);
2626    };
2627    let mut selected = score.clone();
2628    selected.parts = vec![part.clone()];
2629    selected.part_groups.clear();
2630    Ok(selected)
2631}
2632
2633fn validate_publication_sections(
2634    score: &Score,
2635    sections: &[PublicationSection],
2636    keep_together: &[KeepTogetherRange],
2637) -> Result<(), PrintLayoutError> {
2638    let measure_count = score
2639        .parts
2640        .first()
2641        .and_then(|part| part.staves.first())
2642        .map_or(0, |staff| staff.measures.len());
2643    let mut previous_start = None;
2644    for (index, section) in sections.iter().enumerate() {
2645        if section.first_measure >= measure_count
2646            || section.title.trim().is_empty()
2647            || section.title.len() > 1024
2648            || previous_start.is_some_and(|previous| previous >= section.first_measure)
2649        {
2650            return Err(PrintLayoutError::InvalidPublicationSection { index });
2651        }
2652        if keep_together.iter().any(|range| {
2653            range.first_measure < section.first_measure
2654                && section.first_measure <= range.last_measure
2655        }) {
2656            return Err(PrintLayoutError::PublicationSectionConflictsWithKeepTogether { index });
2657        }
2658        previous_start = Some(section.first_measure);
2659    }
2660    Ok(())
2661}
2662
2663fn validate_publication_spacers(
2664    score: &Score,
2665    spacers: &[PublicationSpacer],
2666    keep_together: &[KeepTogetherRange],
2667    content_height_mm: f32,
2668    scaled_system_height_mm: f32,
2669) -> Result<(), PrintLayoutError> {
2670    let measure_count = score
2671        .parts
2672        .first()
2673        .and_then(|part| part.staves.first())
2674        .map_or(0, |staff| staff.measures.len());
2675    let mut previous_measure = None;
2676    for (index, spacer) in spacers.iter().enumerate() {
2677        if spacer.before_measure >= measure_count
2678            || !spacer.height_mm.is_finite()
2679            || spacer.height_mm <= 0.0
2680            || spacer.height_mm + scaled_system_height_mm > content_height_mm
2681            || previous_measure.is_some_and(|previous| previous >= spacer.before_measure)
2682            || keep_together.iter().any(|range| {
2683                range.first_measure < spacer.before_measure
2684                    && spacer.before_measure <= range.last_measure
2685            })
2686        {
2687            return Err(PrintLayoutError::InvalidPublicationSpacer { index });
2688        }
2689        previous_measure = Some(spacer.before_measure);
2690    }
2691    Ok(())
2692}
2693
2694fn spacer_height_before_measure(spacers: &[PublicationSpacer], measure_index: usize) -> f32 {
2695    spacers
2696        .iter()
2697        .find(|spacer| spacer.before_measure == measure_index)
2698        .map_or(0.0, |spacer| spacer.height_mm)
2699}
2700
2701fn split_rows_at_measure_starts(
2702    rows: Vec<crate::RowLayout>,
2703    starts: &[usize],
2704) -> Vec<crate::RowLayout> {
2705    if starts.is_empty() {
2706        return rows;
2707    }
2708    let mut split_rows = Vec::with_capacity(rows.len() + starts.len());
2709    for row in rows {
2710        let mut cuts = vec![0, row.measure_indices.len()];
2711        for start in starts {
2712            if let Some(position) = row.measure_indices.iter().position(|index| index == start) {
2713                cuts.push(position);
2714            }
2715        }
2716        cuts.sort_unstable();
2717        cuts.dedup();
2718        for window in cuts.windows(2) {
2719            if window[0] < window[1] {
2720                split_rows.push(crate::RowLayout {
2721                    measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
2722                });
2723            }
2724        }
2725    }
2726    split_rows
2727}
2728
2729fn publication_image_resource_is_valid(
2730    image: &PublicationImageResource,
2731    width_mm: f32,
2732    height_mm: f32,
2733) -> bool {
2734    let safe_key = !image.resource_key.trim().is_empty()
2735        && image.resource_key.len() <= 256
2736        && !image.resource_key.contains("..")
2737        && !image.resource_key.contains(['/', '\\', ':']);
2738    let safe_alt_text = !image.alt_text.trim().is_empty() && image.alt_text.len() <= 4096;
2739    let finite_geometry = [image.x_mm, image.y_mm, image.width_mm, image.height_mm]
2740        .iter()
2741        .all(|value| value.is_finite());
2742    let fits_page = image.x_mm >= 0.0
2743        && image.y_mm >= 0.0
2744        && image.width_mm > 0.0
2745        && image.height_mm > 0.0
2746        && image.x_mm + image.width_mm <= width_mm
2747        && image.y_mm + image.height_mm <= height_mm;
2748    safe_key && safe_alt_text && finite_geometry && fits_page
2749}
2750
2751fn publication_frame_is_valid(frame: &PublicationFrame, width_mm: f32, height_mm: f32) -> bool {
2752    let finite_geometry = [
2753        frame.x_mm,
2754        frame.y_mm,
2755        frame.width_mm,
2756        frame.height_mm,
2757        frame.stroke_width_mm,
2758    ]
2759    .iter()
2760    .all(|value| value.is_finite());
2761    let fits_page = frame.x_mm >= 0.0
2762        && frame.y_mm >= 0.0
2763        && frame.width_mm > 0.0
2764        && frame.height_mm > 0.0
2765        && frame.stroke_width_mm > 0.0
2766        && frame.x_mm + frame.width_mm <= width_mm
2767        && frame.y_mm + frame.height_mm <= height_mm;
2768    finite_geometry && fits_page
2769}
2770
2771fn publication_page_sections_are_valid(sections: &[PublicationSection]) -> bool {
2772    sections.iter().enumerate().all(|(index, section)| {
2773        !section.title.trim().is_empty()
2774            && section.title.len() <= 1024
2775            && (index == 0 || sections[index - 1].first_measure < section.first_measure)
2776    })
2777}
2778
2779fn publication_page_spacers_are_valid(spacers: &[PublicationSpacer]) -> bool {
2780    spacers.iter().enumerate().all(|(index, spacer)| {
2781        spacer.height_mm.is_finite()
2782            && spacer.height_mm > 0.0
2783            && (index == 0 || spacers[index - 1].before_measure < spacer.before_measure)
2784    })
2785}
2786
2787fn validate_print_config(config: &PrintConfig) -> Result<(f32, f32, f32), PrintLayoutError> {
2788    let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
2789    if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
2790        return Err(PrintLayoutError::InvalidPaperDimensions);
2791    }
2792    if matches!(config.orientation, PageOrientation::Landscape) {
2793        std::mem::swap(&mut width_mm, &mut height_mm);
2794    }
2795
2796    let margins = [
2797        config.margin_top_mm,
2798        config.margin_right_mm,
2799        config.margin_bottom_mm,
2800        config.margin_left_mm,
2801        config.bleed_top_mm,
2802        config.bleed_right_mm,
2803        config.bleed_bottom_mm,
2804        config.bleed_left_mm,
2805        config.safe_top_mm,
2806        config.safe_right_mm,
2807        config.safe_bottom_mm,
2808        config.safe_left_mm,
2809    ];
2810    if margins
2811        .iter()
2812        .any(|value| !value.is_finite() || *value < 0.0)
2813    {
2814        return Err(PrintLayoutError::InvalidMargins);
2815    }
2816    if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
2817        return Err(PrintLayoutError::InvalidSystemHeight);
2818    }
2819    if !config.scale.is_finite() || config.scale <= 0.0 {
2820        return Err(PrintLayoutError::InvalidScale);
2821    }
2822    let scaled_system_height_mm = config.system_height_mm * config.scale;
2823    if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
2824        return Err(PrintLayoutError::InvalidScale);
2825    }
2826    if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
2827        return Err(PrintLayoutError::InvalidPublicationLineHeight);
2828    }
2829    for (index, image) in config.publication.image_resources.iter().enumerate() {
2830        if !publication_image_resource_is_valid(image, width_mm, height_mm) {
2831            return Err(PrintLayoutError::InvalidPublicationImageResource { index });
2832        }
2833    }
2834    for (index, frame) in config.publication.frames.iter().enumerate() {
2835        if !publication_frame_is_valid(frame, width_mm, height_mm) {
2836            return Err(PrintLayoutError::InvalidPublicationFrame { index });
2837        }
2838    }
2839    if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
2840    {
2841        return Err(PrintLayoutError::InvalidGlyphResourceKey);
2842    }
2843    Ok((width_mm, height_mm, scaled_system_height_mm))
2844}
2845
2846/// Compute physical page and system placement without rendering or host integration.
2847pub fn compute_print_layout(
2848    score: &Score,
2849    config: &PrintConfig,
2850) -> Result<PrintLayoutResult, PrintLayoutError> {
2851    let layout_score = score_for_part_layout(score, config.part_layout)?;
2852    let (width_mm, height_mm, scaled_system_height_mm) = validate_print_config(config)?;
2853
2854    let content_width_mm = width_mm
2855        - config.margin_left_mm
2856        - config.margin_right_mm
2857        - config.safe_left_mm
2858        - config.safe_right_mm;
2859    let content_height_mm = height_mm
2860        - config.margin_top_mm
2861        - config.margin_bottom_mm
2862        - config.safe_top_mm
2863        - config.safe_bottom_mm;
2864    if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
2865        return Err(PrintLayoutError::NoUsablePageArea);
2866    }
2867
2868    if scaled_system_height_mm > content_height_mm {
2869        return Err(PrintLayoutError::InvalidSystemHeight);
2870    }
2871    let geometric_capacity = (content_height_mm / scaled_system_height_mm).floor() as usize;
2872    if config
2873        .systems_per_page
2874        .is_some_and(|requested| requested.max(1) > geometric_capacity)
2875    {
2876        return Err(PrintLayoutError::InvalidSystemHeight);
2877    }
2878    let systems_per_page = config.systems_per_page.unwrap_or(geometric_capacity).max(1);
2879    let layout = compute_layout(
2880        &layout_score,
2881        &LayoutConfig {
2882            measures_per_row: config.measures_per_system.max(1),
2883            first_row_measures: config.first_system_measures.or_else(|| {
2884                (matches!(
2885                    config.pickup_policy,
2886                    PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
2887                ) && has_first_measure_pickup(&layout_score))
2888                .then_some(1)
2889            }),
2890            ..LayoutConfig::default()
2891        },
2892    );
2893
2894    let mut keep_together = config.keep_together.clone();
2895    if matches!(
2896        config.notation_break_policy,
2897        NotationBreakPolicy::KeepVoltaTogether
2898    ) {
2899        keep_together.extend(volta_ranges(&layout_score));
2900    }
2901    let rows = apply_keep_together(
2902        &layout_score,
2903        layout.rows,
2904        &keep_together,
2905        config.measures_per_system.max(1),
2906    )?;
2907    validate_publication_sections(&layout_score, &config.publication.sections, &keep_together)?;
2908    validate_publication_spacers(
2909        &layout_score,
2910        &config.publication.spacers,
2911        &keep_together,
2912        content_height_mm,
2913        scaled_system_height_mm,
2914    )?;
2915    let rows = split_rows_at_measure_starts(
2916        rows,
2917        &config
2918            .publication
2919            .sections
2920            .iter()
2921            .map(|section| section.first_measure)
2922            .chain(
2923                config
2924                    .publication
2925                    .spacers
2926                    .iter()
2927                    .map(|spacer| spacer.before_measure),
2928            )
2929            .collect::<Vec<_>>(),
2930    );
2931
2932    let has_explicit_page_break = rows.iter().any(|row| {
2933        row.measure_indices.last().is_some_and(|&measure_index| {
2934            layout_score
2935                .parts
2936                .iter()
2937                .flat_map(|part| part.staves.iter())
2938                .filter_map(|staff| staff.measures.get(measure_index))
2939                .any(|measure| measure.page_break)
2940        })
2941    });
2942    let repeat_system_ranges = if matches!(
2943        config.notation_break_policy,
2944        NotationBreakPolicy::KeepRepeatsTogether
2945    ) {
2946        repeat_system_ranges(&layout_score, &rows)
2947    } else {
2948        Vec::new()
2949    };
2950    if repeat_system_ranges
2951        .iter()
2952        .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
2953    {
2954        return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
2955    }
2956    let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
2957        && !has_explicit_page_break
2958        && systems_per_page > 1
2959        && rows.len() > systems_per_page
2960        && repeat_system_ranges.is_empty()
2961        && !config
2962            .publication
2963            .sections
2964            .iter()
2965            .any(|section| section.start_on_new_page)
2966        && config.publication.spacers.is_empty()
2967    {
2968        let page_count = rows.len().div_ceil(systems_per_page);
2969        let base = rows.len() / page_count;
2970        let remainder = rows.len() % page_count;
2971        (0..page_count)
2972            .map(|index| base + usize::from(index < remainder))
2973            .collect::<Vec<_>>()
2974    } else {
2975        Vec::new()
2976    };
2977
2978    let mut pages = Vec::new();
2979    let mut page_systems = Vec::new();
2980    let mut page_used_height_mm = 0.0;
2981    let mut page_index = 0;
2982    for (system_index, row) in rows.iter().enumerate() {
2983        let repeat_starts_here = repeat_system_ranges
2984            .iter()
2985            .any(|(first, _)| *first == system_index);
2986        let section_starts_on_new_page =
2987            row.measure_indices.first().is_some_and(|measure_index| {
2988                config.publication.sections.iter().any(|section| {
2989                    section.first_measure == *measure_index && section.start_on_new_page
2990                })
2991            });
2992        let spacer_height_mm = row.measure_indices.first().map_or(0.0, |measure_index| {
2993            spacer_height_before_measure(&config.publication.spacers, *measure_index)
2994        });
2995        let spacer_requires_new_page = !page_systems.is_empty()
2996            && page_used_height_mm + spacer_height_mm + scaled_system_height_mm > content_height_mm;
2997        if (repeat_starts_here || section_starts_on_new_page || spacer_requires_new_page)
2998            && !page_systems.is_empty()
2999        {
3000            let page_number = match config.page_numbering {
3001                PageNumbering::None => None,
3002                PageNumbering::OneBased => Some(page_index + 1),
3003            };
3004            pages.push(build_page_layout(
3005                score,
3006                &layout_score,
3007                config,
3008                std::mem::take(&mut page_systems),
3009                page_index,
3010                page_number,
3011                width_mm,
3012                height_mm,
3013                content_width_mm,
3014                content_height_mm,
3015                if section_starts_on_new_page {
3016                    BreakReason::SectionBreak
3017                } else {
3018                    BreakReason::PageCapacity
3019                },
3020                false,
3021            ));
3022            page_index += 1;
3023            page_used_height_mm = 0.0;
3024        }
3025        let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
3026            layout_score
3027                .parts
3028                .iter()
3029                .flat_map(|part| part.staves.iter())
3030                .filter_map(|staff| staff.measures.get(measure_index))
3031                .any(|measure| measure.page_break)
3032        });
3033        let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
3034            layout_score
3035                .parts
3036                .iter()
3037                .flat_map(|part| part.staves.iter())
3038                .filter_map(|staff| staff.measures.get(measure_index))
3039                .any(|measure| measure.system_break)
3040        });
3041        let is_last_system = system_index + 1 == rows.len();
3042        let break_reason = if explicit_page_break {
3043            BreakReason::ExplicitPageBreak
3044        } else if explicit_system_break {
3045            BreakReason::ExplicitSystemBreak
3046        } else if is_last_system {
3047            BreakReason::EndOfScore
3048        } else {
3049            BreakReason::MeasureCapacity
3050        };
3051        let system = SystemLayout {
3052            address: SystemAddress {
3053                system_index,
3054                page_index,
3055                index_on_page: page_systems.len(),
3056            },
3057            system_index,
3058            page_index,
3059            measure_indices: row.measure_indices.clone(),
3060            measure_spans: measure_spans(&layout_score, &row.measure_indices),
3061            span_segments: span_segments(&layout.spans, &row.measure_indices),
3062            measure_marks: measure_marks(&layout_score, &row.measure_indices),
3063            top_mm: config.margin_top_mm
3064                + config.safe_top_mm
3065                + page_used_height_mm
3066                + spacer_height_mm,
3067            height_mm: scaled_system_height_mm,
3068            break_reason,
3069        };
3070        page_systems.push(system);
3071        page_used_height_mm += spacer_height_mm + scaled_system_height_mm;
3072
3073        let page_capacity = page_capacities
3074            .get(page_index)
3075            .copied()
3076            .unwrap_or(systems_per_page);
3077        let page_is_full = page_systems.len() >= page_capacity;
3078        if page_is_full || explicit_page_break {
3079            let page_break_reason = if explicit_page_break {
3080                BreakReason::ExplicitPageBreak
3081            } else if is_last_system {
3082                BreakReason::EndOfScore
3083            } else {
3084                BreakReason::PageCapacity
3085            };
3086            let page_number = match config.page_numbering {
3087                PageNumbering::None => None,
3088                PageNumbering::OneBased => Some(page_index + 1),
3089            };
3090            pages.push(build_page_layout(
3091                score,
3092                &layout_score,
3093                config,
3094                std::mem::take(&mut page_systems),
3095                page_index,
3096                page_number,
3097                width_mm,
3098                height_mm,
3099                content_width_mm,
3100                content_height_mm,
3101                page_break_reason,
3102                false,
3103            ));
3104            page_index += 1;
3105            page_used_height_mm = 0.0;
3106        }
3107    }
3108    if !page_systems.is_empty() || pages.is_empty() {
3109        let page_number = match config.page_numbering {
3110            PageNumbering::None => None,
3111            PageNumbering::OneBased => Some(page_index + 1),
3112        };
3113        pages.push(build_page_layout(
3114            score,
3115            &layout_score,
3116            config,
3117            page_systems,
3118            page_index,
3119            page_number,
3120            width_mm,
3121            height_mm,
3122            content_width_mm,
3123            content_height_mm,
3124            BreakReason::EndOfScore,
3125            false,
3126        ));
3127    }
3128
3129    if config.publication.title_page {
3130        for page in &mut pages {
3131            page.page_index += 1;
3132            page.address.page_index = page.page_index;
3133            page.page_number = match config.page_numbering {
3134                PageNumbering::None => None,
3135                PageNumbering::OneBased => Some(page.page_index + 1),
3136            };
3137            for system in &mut page.systems {
3138                system.page_index += 1;
3139                system.address.page_index = system.page_index;
3140            }
3141            page.publication = page_publication(
3142                score,
3143                &layout_score,
3144                config,
3145                &page.systems,
3146                false,
3147                page.page_number,
3148            );
3149        }
3150        let page_number = match config.page_numbering {
3151            PageNumbering::None => None,
3152            PageNumbering::OneBased => Some(1),
3153        };
3154        pages.insert(
3155            0,
3156            build_page_layout(
3157                score,
3158                &layout_score,
3159                config,
3160                Vec::new(),
3161                0,
3162                page_number,
3163                width_mm,
3164                height_mm,
3165                content_width_mm,
3166                content_height_mm,
3167                BreakReason::TitlePage,
3168                true,
3169            ),
3170        );
3171    }
3172
3173    let result = PrintLayoutResult {
3174        contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
3175        pages,
3176    };
3177    result.validate()?;
3178    Ok(result)
3179}
3180
3181/// Compute print layout for one linked score view without mutating the canonical score.
3182///
3183/// The view selects its source parts and may override measures per system plus explicit
3184/// system/page boundaries. The result describes the projected view; callers that need
3185/// canonical note addresses can pass it to
3186/// [`PrintLayoutResult::export_page_render_trees_for_view`] together with the source score.
3187pub fn compute_print_layout_for_view(
3188    score: &Score,
3189    config: &PrintConfig,
3190    view_id: &str,
3191) -> Result<PrintLayoutResult, PrintLayoutError> {
3192    let view = score
3193        .views
3194        .iter()
3195        .find(|view| view.id == view_id)
3196        .ok_or(PrintLayoutError::InvalidView)?;
3197    let mut view_config = config.clone();
3198    if let Some(measures_per_row) = view.layout.measures_per_row {
3199        view_config.measures_per_system = measures_per_row;
3200    }
3201
3202    let mut projected = score
3203        .resolve_view(view_id)
3204        .map_err(|_| PrintLayoutError::InvalidView)?;
3205    for (indices, is_page_break) in [
3206        (&view.layout.system_breaks, false),
3207        (&view.layout.page_breaks, true),
3208    ] {
3209        for &measure_index in indices {
3210            let mut found = false;
3211            for part in &mut projected.parts {
3212                for staff in &mut part.staves {
3213                    if let Some(measure) = staff.measures.get_mut(measure_index) {
3214                        found = true;
3215                        if is_page_break {
3216                            measure.page_break = true;
3217                        } else {
3218                            measure.system_break = true;
3219                        }
3220                    }
3221                }
3222            }
3223            if !found {
3224                return Err(PrintLayoutError::InvalidView);
3225            }
3226        }
3227    }
3228    compute_print_layout(&projected, &view_config)
3229}
3230
3231#[cfg(test)]
3232mod tests {
3233    use super::*;
3234    use acorde_core::{
3235        Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score,
3236        ScoreTemplate, ScoreView, Staff, Step, ViewStaffRef,
3237    };
3238
3239    fn score_with_measures(count: usize) -> Score {
3240        let mut score = Score::default();
3241        let mut part = Part::new("Piano", "Pno.");
3242        let mut staff = Staff::new(Clef::Treble);
3243        staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
3244        part.staves = vec![staff];
3245        score.parts = vec![part];
3246        score
3247    }
3248
3249    #[test]
3250    fn publication_metadata_accepts_legacy_partial_json() {
3251        let publication: PagePublication =
3252            serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
3253                .expect("legacy publication metadata should deserialize");
3254
3255        assert!(publication.is_title_page);
3256        assert_eq!(publication.title, "Legacy score");
3257        assert!(publication.movement_title.is_empty());
3258        assert!(publication.part_labels.is_empty());
3259        assert!(publication.part_groups.is_empty());
3260        assert!(publication.text_blocks.is_empty());
3261    }
3262
3263    #[test]
3264    fn layout_validation_rejects_unsupported_contract_version() {
3265        let score = score_with_measures(1);
3266        let mut result =
3267            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3268        result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
3269
3270        assert_eq!(
3271            result.validate(),
3272            Err(PrintLayoutError::UnsupportedContractVersion {
3273                found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
3274            })
3275        );
3276    }
3277
3278    #[test]
3279    fn paginates_rows_and_preserves_measure_indices() {
3280        let score = score_with_measures(5);
3281        let result = compute_print_layout(
3282            &score,
3283            &PrintConfig {
3284                measures_per_system: 2,
3285                systems_per_page: Some(2),
3286                ..PrintConfig::default()
3287            },
3288        )
3289        .expect("valid print config");
3290        assert_eq!(result.pages.len(), 2);
3291        assert_eq!(
3292            result.pages[0]
3293                .systems
3294                .iter()
3295                .map(|s| s.measure_indices.clone())
3296                .collect::<Vec<_>>(),
3297            vec![vec![0, 1], vec![2, 3]]
3298        );
3299        assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
3300        assert_eq!(result.pages[1].systems[0].page_index, 1);
3301        assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
3302        assert_eq!(
3303            result.pages[1].systems[0].break_reason,
3304            BreakReason::EndOfScore
3305        );
3306        assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
3307    }
3308
3309    #[test]
3310    fn forced_page_break_starts_next_system_on_next_page() {
3311        let mut score = score_with_measures(3);
3312        score.parts[0].staves[0].measures[0].page_break = true;
3313        let result = compute_print_layout(
3314            &score,
3315            &PrintConfig {
3316                measures_per_system: 3,
3317                systems_per_page: Some(8),
3318                ..PrintConfig::default()
3319            },
3320        )
3321        .expect("valid print config");
3322        assert_eq!(result.pages.len(), 2);
3323        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3324        assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
3325        assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
3326        assert_eq!(
3327            result.pages[0].systems[0].break_reason,
3328            BreakReason::ExplicitPageBreak
3329        );
3330    }
3331
3332    #[test]
3333    fn keep_together_range_is_not_split_across_systems() {
3334        let score = score_with_measures(5);
3335        let result = compute_print_layout(
3336            &score,
3337            &PrintConfig {
3338                measures_per_system: 3,
3339                systems_per_page: Some(8),
3340                keep_together: vec![KeepTogetherRange {
3341                    first_measure: 1,
3342                    last_measure: 2,
3343                }],
3344                ..PrintConfig::default()
3345            },
3346        )
3347        .expect("valid keep-together range");
3348        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3349        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
3350        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
3351    }
3352
3353    #[test]
3354    fn first_system_measure_capacity_is_preserved_in_print_layout() {
3355        let score = score_with_measures(5);
3356        let result = compute_print_layout(
3357            &score,
3358            &PrintConfig {
3359                measures_per_system: 3,
3360                first_system_measures: Some(1),
3361                systems_per_page: Some(8),
3362                ..PrintConfig::default()
3363            },
3364        )
3365        .expect("valid first-system capacity");
3366        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3367        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3368        assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
3369    }
3370
3371    #[test]
3372    fn pickup_policy_isolates_a_partial_first_measure() {
3373        let mut score = score_with_measures(4);
3374        score.parts[0].staves[0].measures[0].voices[0] =
3375            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3376        let result = compute_print_layout(
3377            &score,
3378            &PrintConfig {
3379                measures_per_system: 3,
3380                pickup_policy: PickupPolicy::DetectFirstMeasure,
3381                systems_per_page: Some(8),
3382                ..PrintConfig::default()
3383            },
3384        )
3385        .expect("valid pickup policy");
3386        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3387        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3388    }
3389
3390    #[test]
3391    fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
3392        let mut score = score_with_measures(4);
3393        score.parts[0].staves[0].measures[0].voices[0] =
3394            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3395        let result = compute_print_layout(
3396            &score,
3397            &PrintConfig {
3398                measures_per_system: 3,
3399                systems_per_page: Some(8),
3400                ..PrintConfig::default()
3401            },
3402        )
3403        .expect("valid automatic pickup policy");
3404        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3405        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3406    }
3407
3408    #[test]
3409    fn system_exposes_physical_span_for_multi_rest_slot() {
3410        let mut score = score_with_measures(6);
3411        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3412        let result = compute_print_layout(&score, &PrintConfig::default())
3413            .expect("valid multi-rest print layout");
3414        assert_eq!(
3415            result.pages[0].systems[0].measure_spans[1],
3416            MeasureSpan {
3417                first_measure: 1,
3418                last_measure: 3,
3419            }
3420        );
3421    }
3422
3423    #[test]
3424    fn multirest_width_drives_system_breaking_without_splitting() {
3425        let mut score = score_with_measures(5);
3426        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3427        let result = compute_print_layout(
3428            &score,
3429            &PrintConfig {
3430                measures_per_system: 2,
3431                pickup_policy: PickupPolicy::Preserve,
3432                systems_per_page: Some(8),
3433                ..PrintConfig::default()
3434            },
3435        )
3436        .expect("valid multi-rest pagination");
3437        assert_eq!(
3438            result.pages[0]
3439                .systems
3440                .iter()
3441                .map(|system| system.measure_indices.clone())
3442                .collect::<Vec<_>>(),
3443            vec![vec![0], vec![1], vec![2, 3], vec![4]]
3444        );
3445        assert_eq!(
3446            result.pages[0].systems[1].measure_spans[0],
3447            MeasureSpan {
3448                first_measure: 1,
3449                last_measure: 3,
3450            }
3451        );
3452    }
3453
3454    #[test]
3455    fn system_exposes_cross_system_span_segments() {
3456        let mut score = score_with_measures(4);
3457        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3458        start.slur_start = true;
3459        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3460        end.slur_end = true;
3461        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3462        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3463        let result = compute_print_layout(
3464            &score,
3465            &PrintConfig {
3466                measures_per_system: 2,
3467                pickup_policy: PickupPolicy::Preserve,
3468                systems_per_page: Some(8),
3469                ..PrintConfig::default()
3470            },
3471        )
3472        .expect("valid cross-system span layout");
3473        assert_eq!(
3474            result.pages[0].systems[0].span_segments,
3475            vec![SpanSegment {
3476                span_index: 0,
3477                starts_here: true,
3478                ends_here: false,
3479            }]
3480        );
3481        assert_eq!(
3482            result.pages[0].systems[1].span_segments,
3483            vec![SpanSegment {
3484                span_index: 0,
3485                starts_here: false,
3486                ends_here: true,
3487            }]
3488        );
3489    }
3490
3491    #[test]
3492    fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
3493        let mut score = score_with_measures(4);
3494        let measures = &mut score.parts[0].staves[0].measures;
3495        measures[0].barline_right = Barline::RepeatEnd;
3496        measures[1].barline_left = Barline::RepeatStart;
3497        measures[2].volta = Some(acorde_core::VoltaBracket {
3498            number: 1,
3499            kind: "begin".to_string(),
3500            numbers: Vec::new(),
3501        });
3502        measures[2].navigation = Some("ToCoda".to_string());
3503        measures[2].rehearsal = Some("B".to_string());
3504        let result = compute_print_layout(
3505            &score,
3506            &PrintConfig {
3507                measures_per_system: 2,
3508                systems_per_page: Some(8),
3509                ..PrintConfig::default()
3510            },
3511        )
3512        .expect("valid measure mark layout");
3513        assert_eq!(
3514            result.pages[0].systems[0].measure_marks,
3515            vec![
3516                MeasureMark {
3517                    measure_index: 0,
3518                    repeat_start: false,
3519                    repeat_end: true,
3520                    volta_number: None,
3521                    volta_kind: None,
3522                    navigation: None,
3523                    rehearsal: None,
3524                    text_annotations: vec![],
3525                },
3526                MeasureMark {
3527                    measure_index: 1,
3528                    repeat_start: true,
3529                    repeat_end: false,
3530                    volta_number: None,
3531                    volta_kind: None,
3532                    navigation: None,
3533                    rehearsal: None,
3534                    text_annotations: vec![],
3535                },
3536            ]
3537        );
3538        assert_eq!(
3539            result.pages[0].systems[1].measure_marks,
3540            vec![MeasureMark {
3541                measure_index: 2,
3542                repeat_start: false,
3543                repeat_end: false,
3544                volta_number: Some(1),
3545                volta_kind: Some("begin".to_string()),
3546                navigation: Some("ToCoda".to_string()),
3547                rehearsal: Some("B".to_string()),
3548                text_annotations: vec![
3549                    acorde_core::StyledText {
3550                        style: acorde_core::TextStyle::RehearsalMark,
3551                        text: "B".to_string(),
3552                        placement: None,
3553                        offset_x: None,
3554                        offset_y: None,
3555                        relative_x: None,
3556                        relative_y: None,
3557                    },
3558                    acorde_core::StyledText {
3559                        style: acorde_core::TextStyle::Generic,
3560                        text: "ToCoda".to_string(),
3561                        placement: None,
3562                        offset_x: None,
3563                        offset_y: None,
3564                        relative_x: None,
3565                        relative_y: None,
3566                    },
3567                ],
3568            }]
3569        );
3570    }
3571
3572    #[test]
3573    fn system_exposes_explicit_measure_text_without_legacy_fields() {
3574        let mut score = score_with_measures(1);
3575        score.parts[0].staves[0].measures[0]
3576            .texts
3577            .push(acorde_core::StyledText {
3578                style: acorde_core::TextStyle::Expression,
3579                text: "dolce".to_string(),
3580                placement: Some("above".to_string()),
3581                offset_x: Some(2.0),
3582                offset_y: Some(-1.0),
3583                relative_x: None,
3584                relative_y: None,
3585            });
3586        let result = compute_print_layout(&score, &PrintConfig::default())
3587            .expect("valid explicit measure text layout");
3588        let annotations = &result.pages[0].systems[0].measure_marks[0].text_annotations;
3589        assert_eq!(annotations.len(), 1);
3590        assert_eq!(annotations[0].style, acorde_core::TextStyle::Expression);
3591        assert_eq!(annotations[0].text, "dolce");
3592        assert_eq!(annotations[0].placement.as_deref(), Some("above"));
3593        assert_eq!(annotations[0].offset_x, Some(2.0));
3594        assert_eq!(annotations[0].offset_y, Some(-1.0));
3595    }
3596
3597    #[test]
3598    fn page_aggregates_cross_system_span_ownership() {
3599        let mut score = score_with_measures(4);
3600        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3601        start.slur_start = true;
3602        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3603        end.slur_end = true;
3604        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3605        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3606        let result = compute_print_layout(
3607            &score,
3608            &PrintConfig {
3609                measures_per_system: 2,
3610                pickup_policy: PickupPolicy::Preserve,
3611                systems_per_page: Some(1),
3612                ..PrintConfig::default()
3613            },
3614        )
3615        .expect("valid page span layout");
3616        assert_eq!(
3617            result.pages[0].span_segments,
3618            vec![PageSpanSegment {
3619                span_index: 0,
3620                starts_here: true,
3621                ends_here: false,
3622            }]
3623        );
3624        assert_eq!(
3625            result.pages[1].span_segments,
3626            vec![PageSpanSegment {
3627                span_index: 0,
3628                starts_here: false,
3629                ends_here: true,
3630            }]
3631        );
3632    }
3633
3634    #[test]
3635    fn page_artifact_measure_span_borrows_system_spans() {
3636        let mut score = score_with_measures(4);
3637        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3638        start.slur_start = true;
3639        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3640        end.slur_end = true;
3641        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3642        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3643        let result = compute_print_layout(
3644            &score,
3645            &PrintConfig {
3646                measures_per_system: 2,
3647                pickup_policy: PickupPolicy::Preserve,
3648                systems_per_page: Some(1),
3649                ..PrintConfig::default()
3650            },
3651        )
3652        .expect("valid page artifact");
3653        let first = result
3654            .page(PageAddress { page_index: 0 })
3655            .expect("first page");
3656        assert_eq!(
3657            first.measure_span(),
3658            Some(MeasureSpan {
3659                first_measure: 0,
3660                last_measure: 1,
3661            })
3662        );
3663        assert!(first.has_span_continuation());
3664        assert!(result.page(PageAddress { page_index: 99 }).is_none());
3665        assert!(result.validate().is_ok());
3666    }
3667
3668    #[test]
3669    fn export_page_artifacts_reports_host_glyph_resource_requirement() {
3670        let result = compute_print_layout(
3671            &score_with_measures(1),
3672            &PrintConfig {
3673                glyph_resources: GlyphResourcePolicy::HostProvided("licensed-font-v1".into()),
3674                ..PrintConfig::default()
3675            },
3676        )
3677        .expect("valid host resource policy");
3678
3679        let artifacts = result
3680            .export_page_artifacts()
3681            .expect("host resource requirement is a diagnostic");
3682        assert_eq!(
3683            artifacts[0].diagnostics,
3684            vec![PageArtifactDiagnostic::GlyphResourceRequired]
3685        );
3686        assert_eq!(
3687            artifacts[0].layout.glyph_resources,
3688            GlyphResourcePolicy::HostProvided("licensed-font-v1".into())
3689        );
3690    }
3691
3692    #[test]
3693    fn page_artifact_diagnostics_report_glyph_overflow_sides() {
3694        let result = compute_print_layout(&score_with_measures(1), &PrintConfig::default())
3695            .expect("valid print layout");
3696        let page = &result.pages[0];
3697        assert_eq!(
3698            page.artifact_diagnostics(Some(GlyphExtents {
3699                left_mm: -1.0,
3700                top_mm: -2.0,
3701                right_mm: page.content_width_mm + 3.0,
3702                bottom_mm: page.content_height_mm + 4.0,
3703            })),
3704            vec![PageArtifactDiagnostic::GlyphOverflow {
3705                left: true,
3706                top: true,
3707                right: true,
3708                bottom: true,
3709            }]
3710        );
3711        assert!(page.artifact_diagnostics(None).is_empty());
3712    }
3713
3714    #[test]
3715    fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
3716        let mut score = score_with_measures(4);
3717        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3718        start.slur_start = true;
3719        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3720        end.slur_end = true;
3721        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3722        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3723        let result = compute_print_layout(
3724            &score,
3725            &PrintConfig {
3726                measures_per_system: 2,
3727                pickup_policy: PickupPolicy::Preserve,
3728                systems_per_page: Some(1),
3729                ..PrintConfig::default()
3730            },
3731        )
3732        .expect("valid print config");
3733
3734        let artifacts = result
3735            .export_page_artifacts()
3736            .expect("valid page artifacts");
3737        assert_eq!(artifacts.len(), 2);
3738        assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
3739        assert_eq!(artifacts[1].page_index, 1);
3740        assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
3741        assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
3742        assert_eq!(
3743            artifacts[0].measure_span,
3744            Some(MeasureSpan {
3745                first_measure: 0,
3746                last_measure: 1,
3747            })
3748        );
3749        assert_eq!(
3750            artifacts[0].diagnostics,
3751            vec![PageArtifactDiagnostic::SpanContinuation {
3752                span_index: 0,
3753                starts_here: true,
3754                ends_here: false,
3755            }]
3756        );
3757        assert_eq!(
3758            artifacts[1].diagnostics,
3759            vec![PageArtifactDiagnostic::SpanContinuation {
3760                span_index: 0,
3761                starts_here: false,
3762                ends_here: true,
3763            }]
3764        );
3765    }
3766
3767    #[test]
3768    fn export_page_artifacts_rejects_invalid_serialized_layout() {
3769        let score = score_with_measures(1);
3770        let mut result =
3771            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3772        result.pages[0].width_mm = f32::NAN;
3773
3774        assert!(matches!(
3775            result.export_page_artifacts(),
3776            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3777        ));
3778    }
3779
3780    #[test]
3781    fn page_lookup_rejects_mismatched_serialized_address() {
3782        let score = score_with_measures(1);
3783        let mut result =
3784            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3785        result.pages[0].address = PageAddress { page_index: 7 };
3786
3787        assert!(result.page(PageAddress { page_index: 0 }).is_none());
3788        assert_eq!(
3789            result.validate(),
3790            Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
3791        );
3792    }
3793
3794    #[test]
3795    fn layout_validation_rejects_mismatched_system_address() {
3796        let score = score_with_measures(2);
3797        let mut result = compute_print_layout(
3798            &score,
3799            &PrintConfig {
3800                measures_per_system: 1,
3801                ..PrintConfig::default()
3802            },
3803        )
3804        .expect("valid print config");
3805        result.pages[0].systems[0].address.index_on_page = 4;
3806
3807        assert_eq!(
3808            result.validate(),
3809            Err(PrintLayoutError::InvalidSystemAddress {
3810                page_index: 0,
3811                index_on_page: 0,
3812                system_index: 0,
3813            })
3814        );
3815    }
3816
3817    #[test]
3818    fn layout_validation_rejects_non_monotonic_page_number() {
3819        let score = score_with_measures(2);
3820        let mut result = compute_print_layout(
3821            &score,
3822            &PrintConfig {
3823                measures_per_system: 1,
3824                page_numbering: PageNumbering::OneBased,
3825                systems_per_page: Some(1),
3826                ..PrintConfig::default()
3827            },
3828        )
3829        .expect("valid print config");
3830        result.pages[1].page_number = Some(1);
3831
3832        assert_eq!(
3833            result.validate(),
3834            Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
3835        );
3836    }
3837
3838    #[test]
3839    fn layout_validation_rejects_inconsistent_title_page_metadata() {
3840        let score = score_with_measures(1);
3841        let mut result =
3842            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3843        result.pages[0].publication.is_title_page = true;
3844
3845        assert_eq!(
3846            result.validate(),
3847            Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
3848        );
3849    }
3850
3851    #[test]
3852    fn layout_validation_rejects_non_finite_page_geometry() {
3853        let score = score_with_measures(1);
3854        let mut result =
3855            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3856        result.pages[0].width_mm = f32::NAN;
3857
3858        assert_eq!(
3859            result.validate(),
3860            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3861        );
3862    }
3863
3864    #[test]
3865    fn layout_validation_rejects_non_positive_system_geometry() {
3866        let score = score_with_measures(1);
3867        let mut result =
3868            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3869        result.pages[0].systems[0].height_mm = 0.0;
3870
3871        assert_eq!(
3872            result.validate(),
3873            Err(PrintLayoutError::InvalidSystemGeometry {
3874                page_index: 0,
3875                index_on_page: 0,
3876            })
3877        );
3878    }
3879
3880    #[test]
3881    fn layout_validation_rejects_systems_past_the_page_or_on_top_of_each_other() {
3882        let score = score_with_measures(2);
3883        let config = PrintConfig {
3884            measures_per_system: 1,
3885            ..PrintConfig::default()
3886        };
3887        let mut result = compute_print_layout(&score, &config).expect("two valid systems");
3888        result.pages[0].systems[1].top_mm = 290.0;
3889        assert_eq!(
3890            result.validate(),
3891            Err(PrintLayoutError::InvalidSystemGeometry {
3892                page_index: 0,
3893                index_on_page: 1,
3894            })
3895        );
3896
3897        result.pages[0].systems[1].top_mm = result.pages[0].systems[0].top_mm + 1.0;
3898        assert_eq!(
3899            result.validate(),
3900            Err(PrintLayoutError::InvalidSystemGeometry {
3901                page_index: 0,
3902                index_on_page: 1,
3903            })
3904        );
3905    }
3906
3907    #[test]
3908    fn layout_validation_rejects_content_larger_than_page() {
3909        let score = score_with_measures(1);
3910        let mut result =
3911            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3912        result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
3913
3914        assert_eq!(
3915            result.validate(),
3916            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3917        );
3918    }
3919
3920    #[test]
3921    fn layout_validation_rejects_invalid_persisted_publication_metadata() {
3922        let score = score_with_measures(1);
3923        let mut result =
3924            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3925        result.pages[0]
3926            .publication
3927            .image_resources
3928            .push(PublicationImageResource {
3929                resource_key: "../unsafe".into(),
3930                alt_text: "Unsafe resource".into(),
3931                placement: PublicationImagePlacement::EveryPage,
3932                x_mm: 0.0,
3933                y_mm: 0.0,
3934                width_mm: 1.0,
3935                height_mm: 1.0,
3936            });
3937
3938        assert_eq!(
3939            result.validate(),
3940            Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3941        );
3942
3943        result.pages[0].publication.image_resources.clear();
3944        result.pages[0].publication.frames.push(PublicationFrame {
3945            placement: PublicationFramePlacement::EveryPage,
3946            x_mm: 0.0,
3947            y_mm: 0.0,
3948            width_mm: 1.0,
3949            height_mm: 1.0,
3950            stroke_width_mm: f32::NAN,
3951        });
3952        assert_eq!(
3953            result.validate(),
3954            Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3955        );
3956
3957        result.pages[0].publication.frames.clear();
3958        result.pages[0]
3959            .publication
3960            .sections
3961            .push(PublicationSection {
3962                first_measure: 0,
3963                title: " ".into(),
3964                start_on_new_page: false,
3965            });
3966        assert_eq!(
3967            result.validate(),
3968            Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3969        );
3970    }
3971
3972    #[test]
3973    fn notation_policy_keeps_volta_range_in_one_system() {
3974        let mut score = score_with_measures(4);
3975        score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
3976            number: 1,
3977            kind: "begin".to_string(),
3978            numbers: Vec::new(),
3979        });
3980        score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
3981            number: 1,
3982            kind: "end".to_string(),
3983            numbers: Vec::new(),
3984        });
3985        let result = compute_print_layout(
3986            &score,
3987            &PrintConfig {
3988                measures_per_system: 2,
3989                systems_per_page: Some(8),
3990                notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
3991                ..PrintConfig::default()
3992            },
3993        )
3994        .expect("valid volta-preserving layout");
3995        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3996        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
3997        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
3998    }
3999
4000    #[test]
4001    fn notation_policy_keeps_repeat_section_on_one_page() {
4002        let mut score = score_with_measures(5);
4003        score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
4004        score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
4005        let result = compute_print_layout(
4006            &score,
4007            &PrintConfig {
4008                measures_per_system: 2,
4009                systems_per_page: Some(2),
4010                notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
4011                ..PrintConfig::default()
4012            },
4013        )
4014        .expect("valid repeat-preserving layout");
4015        assert_eq!(result.pages[0].systems.len(), 1);
4016        assert_eq!(result.pages[1].systems.len(), 2);
4017        assert_eq!(
4018            result.pages[1]
4019                .systems
4020                .iter()
4021                .flat_map(|system| system.measure_indices.iter().copied())
4022                .collect::<Vec<_>>(),
4023            vec![2, 3, 4]
4024        );
4025    }
4026
4027    #[test]
4028    fn balance_policy_avoids_single_system_final_page() {
4029        let score = score_with_measures(5);
4030        let result = compute_print_layout(
4031            &score,
4032            &PrintConfig {
4033                measures_per_system: 1,
4034                systems_per_page: Some(4),
4035                final_page_policy: FinalPagePolicy::Balance,
4036                ..PrintConfig::default()
4037            },
4038        )
4039        .expect("valid balanced print config");
4040        assert_eq!(result.pages.len(), 2);
4041        assert_eq!(result.pages[0].systems.len(), 3);
4042        assert_eq!(result.pages[1].systems.len(), 2);
4043    }
4044
4045    #[test]
4046    fn balance_policy_preserves_explicit_page_breaks() {
4047        let mut score = score_with_measures(5);
4048        score.parts[0].staves[0].measures[1].page_break = true;
4049        let result = compute_print_layout(
4050            &score,
4051            &PrintConfig {
4052                measures_per_system: 1,
4053                systems_per_page: Some(4),
4054                final_page_policy: FinalPagePolicy::Balance,
4055                ..PrintConfig::default()
4056            },
4057        )
4058        .expect("valid explicit-break print config");
4059        assert_eq!(result.pages[0].systems.len(), 2);
4060        assert_eq!(result.pages[1].systems.len(), 3);
4061    }
4062
4063    #[test]
4064    fn keep_together_rejects_ranges_larger_than_system_capacity() {
4065        let score = score_with_measures(4);
4066        let error = compute_print_layout(
4067            &score,
4068            &PrintConfig {
4069                measures_per_system: 2,
4070                keep_together: vec![KeepTogetherRange {
4071                    first_measure: 0,
4072                    last_measure: 2,
4073                }],
4074                ..PrintConfig::default()
4075            },
4076        )
4077        .expect_err("range must fit in one system");
4078        assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
4079    }
4080
4081    #[test]
4082    fn keep_together_rejects_explicit_break_inside_range() {
4083        let mut score = score_with_measures(4);
4084        score.parts[0].staves[0].measures[1].system_break = true;
4085        let error = compute_print_layout(
4086            &score,
4087            &PrintConfig {
4088                measures_per_system: 3,
4089                keep_together: vec![KeepTogetherRange {
4090                    first_measure: 0,
4091                    last_measure: 2,
4092                }],
4093                ..PrintConfig::default()
4094            },
4095        )
4096        .expect_err("explicit break must win");
4097        assert_eq!(
4098            error,
4099            PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
4100        );
4101    }
4102
4103    #[test]
4104    fn rejects_margins_that_leave_no_page_area() {
4105        let score = score_with_measures(1);
4106        let error = compute_print_layout(
4107            &score,
4108            &PrintConfig {
4109                margin_left_mm: 200.0,
4110                ..PrintConfig::default()
4111            },
4112        )
4113        .expect_err("invalid page area");
4114        assert_eq!(error, PrintLayoutError::NoUsablePageArea);
4115    }
4116
4117    #[test]
4118    fn rejects_system_height_or_requested_capacity_that_clips_the_page() {
4119        let score = score_with_measures(2);
4120        for config in [
4121            PrintConfig {
4122                system_height_mm: 266.0,
4123                ..PrintConfig::default()
4124            },
4125            PrintConfig {
4126                system_height_mm: 140.0,
4127                systems_per_page: Some(2),
4128                ..PrintConfig::default()
4129            },
4130        ] {
4131            assert_eq!(
4132                compute_print_layout(&score, &config),
4133                Err(PrintLayoutError::InvalidSystemHeight)
4134            );
4135        }
4136    }
4137
4138    #[test]
4139    fn safe_area_reduces_content_and_bleed_is_exposed() {
4140        let score = score_with_measures(1);
4141        let result = compute_print_layout(
4142            &score,
4143            &PrintConfig {
4144                bleed_top_mm: 3.0,
4145                bleed_right_mm: 3.0,
4146                bleed_bottom_mm: 3.0,
4147                bleed_left_mm: 3.0,
4148                safe_top_mm: 5.0,
4149                safe_right_mm: 6.0,
4150                safe_bottom_mm: 7.0,
4151                safe_left_mm: 8.0,
4152                ..PrintConfig::default()
4153            },
4154        )
4155        .expect("valid print config");
4156        let page = &result.pages[0];
4157        assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4158        assert_eq!(page.bleed_left_mm, 3.0);
4159        assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
4160        assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
4161        assert_eq!(page.systems[0].top_mm, 21.0);
4162    }
4163
4164    #[test]
4165    fn scale_changes_system_height_and_page_capacity() {
4166        let score = score_with_measures(10);
4167        let result = compute_print_layout(
4168            &score,
4169            &PrintConfig {
4170                scale: 2.0,
4171                measures_per_system: 1,
4172                systems_per_page: None,
4173                ..PrintConfig::default()
4174            },
4175        )
4176        .expect("valid print config");
4177        assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
4178        assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
4179        assert_eq!(result.pages.len(), 2);
4180    }
4181
4182    #[test]
4183    fn rejects_non_positive_scale() {
4184        let score = score_with_measures(1);
4185        let error = compute_print_layout(
4186            &score,
4187            &PrintConfig {
4188                scale: 0.0,
4189                ..PrintConfig::default()
4190            },
4191        )
4192        .expect_err("invalid scale");
4193        assert_eq!(error, PrintLayoutError::InvalidScale);
4194    }
4195
4196    #[test]
4197    fn page_numbering_is_configurable() {
4198        let score = score_with_measures(5);
4199        let numbered = compute_print_layout(
4200            &score,
4201            &PrintConfig {
4202                measures_per_system: 1,
4203                systems_per_page: Some(2),
4204                ..PrintConfig::default()
4205            },
4206        )
4207        .expect("valid print config");
4208        assert_eq!(numbered.pages[0].page_number, Some(1));
4209        assert_eq!(numbered.pages[1].page_number, Some(2));
4210
4211        let unnumbered = compute_print_layout(
4212            &score,
4213            &PrintConfig {
4214                page_numbering: PageNumbering::None,
4215                measures_per_system: 1,
4216                systems_per_page: Some(2),
4217                ..PrintConfig::default()
4218            },
4219        )
4220        .expect("valid print config");
4221        assert!(
4222            unnumbered
4223                .pages
4224                .iter()
4225                .all(|page| page.page_number.is_none())
4226        );
4227    }
4228
4229    #[test]
4230    fn rejects_invalid_publication_line_height() {
4231        let score = score_with_measures(1);
4232        let error = compute_print_layout(
4233            &score,
4234            &PrintConfig {
4235                publication: PublicationConfig {
4236                    line_height_mm: 0.0,
4237                    ..PublicationConfig::default()
4238                },
4239                ..PrintConfig::default()
4240            },
4241        )
4242        .expect_err("invalid publication line height");
4243        assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
4244    }
4245
4246    #[test]
4247    fn publication_image_resources_are_safe_and_page_scoped() {
4248        let score = score_with_measures(1);
4249        let config = PrintConfig {
4250            publication: PublicationConfig {
4251                title_page: true,
4252                image_resources: vec![
4253                    PublicationImageResource {
4254                        resource_key: "cover-art-v1".into(),
4255                        alt_text: "Cover art".into(),
4256                        placement: PublicationImagePlacement::TitlePage,
4257                        x_mm: 10.0,
4258                        y_mm: 10.0,
4259                        width_mm: 30.0,
4260                        height_mm: 20.0,
4261                    },
4262                    PublicationImageResource {
4263                        resource_key: "publisher-mark".into(),
4264                        alt_text: "Publisher mark".into(),
4265                        placement: PublicationImagePlacement::MusicPages,
4266                        x_mm: 160.0,
4267                        y_mm: 10.0,
4268                        width_mm: 20.0,
4269                        height_mm: 10.0,
4270                    },
4271                ],
4272                ..PublicationConfig::default()
4273            },
4274            ..PrintConfig::default()
4275        };
4276        let result = compute_print_layout(&score, &config).expect("valid image resources");
4277        assert_eq!(result.pages[0].publication.image_resources.len(), 1);
4278        assert_eq!(
4279            result.pages[0].publication.image_resources[0].resource_key,
4280            "cover-art-v1"
4281        );
4282        assert_eq!(result.pages[1].publication.image_resources.len(), 1);
4283        assert_eq!(
4284            result.pages[1].publication.image_resources[0].resource_key,
4285            "publisher-mark"
4286        );
4287
4288        let invalid = PrintConfig {
4289            publication: PublicationConfig {
4290                image_resources: vec![PublicationImageResource {
4291                    resource_key: "../secret.png".into(),
4292                    alt_text: "Unsafe path".into(),
4293                    placement: PublicationImagePlacement::EveryPage,
4294                    x_mm: 0.0,
4295                    y_mm: 0.0,
4296                    width_mm: 1.0,
4297                    height_mm: 1.0,
4298                }],
4299                ..PublicationConfig::default()
4300            },
4301            ..PrintConfig::default()
4302        };
4303        assert_eq!(
4304            compute_print_layout(&score, &invalid),
4305            Err(PrintLayoutError::InvalidPublicationImageResource { index: 0 })
4306        );
4307    }
4308
4309    #[test]
4310    fn publication_frames_are_validated_and_page_scoped() {
4311        let score = score_with_measures(1);
4312        let config = PrintConfig {
4313            publication: PublicationConfig {
4314                title_page: true,
4315                frames: vec![
4316                    PublicationFrame {
4317                        placement: PublicationFramePlacement::TitlePage,
4318                        x_mm: 8.0,
4319                        y_mm: 8.0,
4320                        width_mm: 194.0,
4321                        height_mm: 281.0,
4322                        stroke_width_mm: 0.4,
4323                    },
4324                    PublicationFrame {
4325                        placement: PublicationFramePlacement::MusicPages,
4326                        x_mm: 12.0,
4327                        y_mm: 12.0,
4328                        width_mm: 186.0,
4329                        height_mm: 273.0,
4330                        stroke_width_mm: 0.3,
4331                    },
4332                ],
4333                ..PublicationConfig::default()
4334            },
4335            ..PrintConfig::default()
4336        };
4337        let result = compute_print_layout(&score, &config).expect("valid frames");
4338        assert_eq!(result.pages[0].publication.frames.len(), 1);
4339        assert_eq!(result.pages[1].publication.frames.len(), 1);
4340        assert_eq!(result.pages[0].publication.frames[0].stroke_width_mm, 0.4);
4341
4342        let invalid = PrintConfig {
4343            publication: PublicationConfig {
4344                frames: vec![PublicationFrame {
4345                    placement: PublicationFramePlacement::EveryPage,
4346                    x_mm: 0.0,
4347                    y_mm: 0.0,
4348                    width_mm: 211.0,
4349                    height_mm: 297.0,
4350                    stroke_width_mm: 0.0,
4351                }],
4352                ..PublicationConfig::default()
4353            },
4354            ..PrintConfig::default()
4355        };
4356        assert_eq!(
4357            compute_print_layout(&score, &invalid),
4358            Err(PrintLayoutError::InvalidPublicationFrame { index: 0 })
4359        );
4360    }
4361
4362    #[test]
4363    fn publication_spacers_consume_page_height_and_follow_systems() {
4364        let score = score_with_measures(4);
4365        let config = PrintConfig {
4366            measures_per_system: 4,
4367            systems_per_page: Some(2),
4368            system_height_mm: 130.0,
4369            publication: PublicationConfig {
4370                spacers: vec![PublicationSpacer {
4371                    before_measure: 2,
4372                    height_mm: 20.0,
4373                }],
4374                ..PublicationConfig::default()
4375            },
4376            ..PrintConfig::default()
4377        };
4378        let result = compute_print_layout(&score, &config).expect("valid publication spacer");
4379        assert_eq!(result.pages.len(), 2);
4380        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4381        assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4382        assert_eq!(result.pages[1].systems[0].top_mm, 36.0);
4383        assert_eq!(result.pages[1].publication.spacers.len(), 1);
4384        assert_eq!(result.pages[1].publication.spacers[0].before_measure, 2);
4385        result
4386            .validate()
4387            .expect("persisted spacer metadata is valid");
4388
4389        let invalid = PrintConfig {
4390            system_height_mm: 260.0,
4391            publication: PublicationConfig {
4392                spacers: vec![PublicationSpacer {
4393                    before_measure: 0,
4394                    height_mm: 10.0,
4395                }],
4396                ..PublicationConfig::default()
4397            },
4398            ..PrintConfig::default()
4399        };
4400        assert_eq!(
4401            compute_print_layout(&score, &invalid),
4402            Err(PrintLayoutError::InvalidPublicationSpacer { index: 0 })
4403        );
4404    }
4405
4406    #[test]
4407    fn publication_sections_split_systems_and_can_start_a_page() {
4408        let score = score_with_measures(5);
4409        let config = PrintConfig {
4410            measures_per_system: 4,
4411            systems_per_page: Some(2),
4412            publication: PublicationConfig {
4413                sections: vec![PublicationSection {
4414                    first_measure: 2,
4415                    title: "Second movement".into(),
4416                    start_on_new_page: true,
4417                }],
4418                ..PublicationConfig::default()
4419            },
4420            ..PrintConfig::default()
4421        };
4422        let result = compute_print_layout(&score, &config).expect("valid publication section");
4423        assert_eq!(result.pages.len(), 2);
4424        assert_eq!(result.pages[0].break_reason, BreakReason::SectionBreak);
4425        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4426        assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4427        assert_eq!(result.pages[1].publication.sections.len(), 1);
4428        assert_eq!(
4429            result.pages[1].publication.sections[0].title,
4430            "Second movement"
4431        );
4432
4433        let invalid = PrintConfig {
4434            publication: PublicationConfig {
4435                sections: vec![PublicationSection {
4436                    first_measure: 5,
4437                    title: "Outside score".into(),
4438                    start_on_new_page: false,
4439                }],
4440                ..PublicationConfig::default()
4441            },
4442            ..PrintConfig::default()
4443        };
4444        assert_eq!(
4445            compute_print_layout(&score, &invalid),
4446            Err(PrintLayoutError::InvalidPublicationSection { index: 0 })
4447        );
4448    }
4449
4450    #[test]
4451    fn rejects_empty_host_glyph_resource_key() {
4452        let score = score_with_measures(1);
4453        let error = compute_print_layout(
4454            &score,
4455            &PrintConfig {
4456                glyph_resources: GlyphResourcePolicy::HostProvided("  ".into()),
4457                ..PrintConfig::default()
4458            },
4459        )
4460        .expect_err("empty host resource key");
4461        assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
4462    }
4463
4464    #[test]
4465    fn glyph_resource_descriptor_requires_reproducible_metadata() {
4466        let descriptor = GlyphResourceDescriptor {
4467            contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4468            resource_key: "publisher-font-v2".into(),
4469            metrics_contract_version: 1,
4470            license_notice: "licensed by publisher".into(),
4471            fallback: GlyphFallbackPolicy::UseResource("acorde-vector-glyphs-v1".into()),
4472        };
4473        assert_eq!(descriptor.validate(), Ok(()));
4474    }
4475
4476    #[test]
4477    fn glyph_resource_descriptor_rejects_missing_license_and_self_fallback() {
4478        let missing_license = GlyphResourceDescriptor {
4479            contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4480            resource_key: "font".into(),
4481            metrics_contract_version: 1,
4482            license_notice: " ".into(),
4483            fallback: GlyphFallbackPolicy::Reject,
4484        };
4485        assert_eq!(
4486            missing_license.validate(),
4487            Err(GlyphResourceDescriptorError::EmptyLicenseNotice)
4488        );
4489
4490        let self_fallback = GlyphResourceDescriptor {
4491            contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4492            resource_key: "font".into(),
4493            metrics_contract_version: 1,
4494            license_notice: "licensed".into(),
4495            fallback: GlyphFallbackPolicy::UseResource("font".into()),
4496        };
4497        assert_eq!(
4498            self_fallback.validate(),
4499            Err(GlyphResourceDescriptorError::FallbackMatchesPrimary)
4500        );
4501    }
4502
4503    #[test]
4504    fn print_color_and_crop_policies_are_exposed_per_page() {
4505        let score = score_with_measures(1);
4506        let result = compute_print_layout(
4507            &score,
4508            &PrintConfig {
4509                color_policy: PrintColorPolicy::Preserve,
4510                crop_mark_policy: CropMarkPolicy::BleedEdges,
4511                ..PrintConfig::default()
4512            },
4513        )
4514        .expect("valid print config");
4515        let page = &result.pages[0];
4516        assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4517        assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
4518        assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
4519    }
4520
4521    #[test]
4522    fn glyph_resource_policy_is_exposed_per_page() {
4523        let score = score_with_measures(1);
4524        let result = compute_print_layout(
4525            &score,
4526            &PrintConfig {
4527                glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
4528                ..PrintConfig::default()
4529            },
4530        )
4531        .expect("valid print config");
4532        assert_eq!(
4533            result.pages[0].glyph_resources,
4534            GlyphResourcePolicy::HostProvided("music-font-v1".into())
4535        );
4536    }
4537
4538    #[test]
4539    fn publication_metadata_is_deterministic_and_page_scoped() {
4540        let mut score = score_with_measures(3);
4541        score.metadata.title = "Suite".into();
4542        score.metadata.movement_title = "I. Prelude".into();
4543        score.metadata.composer = "Composer".into();
4544        score.metadata.copyright = "© 2026 Composer".into();
4545        score.metadata.lyricist = "Lyricist".into();
4546        score.metadata.copyright = "Copyright".into();
4547        score.parts.push(Part::new("Strings", "Str."));
4548        score.part_groups.push(PartGroup {
4549            first_part: 0,
4550            last_part: 1,
4551            symbol: PartGroupSymbol::Bracket,
4552            barlines_connect: true,
4553        });
4554        for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
4555            measure.number = (index + 1) as u32;
4556        }
4557        let result = compute_print_layout(
4558            &score,
4559            &PrintConfig {
4560                measures_per_system: 2,
4561                systems_per_page: Some(1),
4562                publication: PublicationConfig {
4563                    running_title: Some("Suite — Composer".into()),
4564                    header_text: Some("Suite".into()),
4565                    footer_text: Some("Copyright".into()),
4566                    page_number_in_footer: true,
4567                    header_alignment: PublicationTextAlignment::Center,
4568                    footer_alignment: PublicationTextAlignment::Right,
4569                    ..PublicationConfig::default()
4570                },
4571                ..PrintConfig::default()
4572            },
4573        )
4574        .expect("valid print config");
4575        assert_eq!(result.pages[0].publication.title, "Suite");
4576        assert_eq!(
4577            result.pages[0].publication.running_title.as_deref(),
4578            Some("Suite — Composer")
4579        );
4580        assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
4581        assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
4582        assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
4583        assert_eq!(result.pages[0].publication.part_groups.len(), 1);
4584        assert_eq!(
4585            result.pages[0].publication.part_groups[0].symbol,
4586            PartGroupSymbol::Bracket
4587        );
4588        assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
4589        assert_eq!(
4590            result.pages[0].publication.text_blocks[0].role,
4591            PublicationTextRole::Header
4592        );
4593        assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
4594        assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
4595        assert_eq!(
4596            result.pages[0].publication.text_blocks[1].role,
4597            PublicationTextRole::Footer
4598        );
4599        assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
4600        assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
4601        assert_eq!(
4602            result.pages[0].publication.text_blocks[0].alignment,
4603            PublicationTextAlignment::Center
4604        );
4605        assert_eq!(
4606            result.pages[0].publication.text_blocks[1].alignment,
4607            PublicationTextAlignment::Right
4608        );
4609        let artifacts = result
4610            .export_page_artifacts()
4611            .expect("publication pages export without host resources");
4612        assert_eq!(artifacts.len(), result.pages.len());
4613        assert_eq!(artifacts[0].layout.publication, result.pages[0].publication);
4614        assert!(
4615            artifacts
4616                .iter()
4617                .all(|artifact| artifact.diagnostics.is_empty())
4618        );
4619    }
4620
4621    #[test]
4622    fn publication_templates_select_odd_even_text_and_can_omit_a_side() {
4623        let score = score_with_measures(3);
4624        let result = compute_print_layout(
4625            &score,
4626            &PrintConfig {
4627                measures_per_system: 1,
4628                systems_per_page: Some(1),
4629                publication: PublicationConfig {
4630                    header_text: Some("legacy header".into()),
4631                    footer_text: Some("legacy footer".into()),
4632                    header_template: PublicationPageTemplate {
4633                        odd: Some("Odd header".into()),
4634                        even: Some("Even header".into()),
4635                    },
4636                    footer_template: PublicationPageTemplate {
4637                        odd: Some("Odd footer".into()),
4638                        even: None,
4639                    },
4640                    ..PublicationConfig::default()
4641                },
4642                ..PrintConfig::default()
4643            },
4644        )
4645        .expect("valid print layout");
4646        let first_texts: Vec<_> = result.pages[0]
4647            .publication
4648            .text_blocks
4649            .iter()
4650            .map(|block| block.text.as_str())
4651            .collect();
4652        let second_texts: Vec<_> = result.pages[1]
4653            .publication
4654            .text_blocks
4655            .iter()
4656            .map(|block| block.text.as_str())
4657            .collect();
4658        assert_eq!(first_texts, vec!["Odd header", "Odd footer"]);
4659        assert_eq!(second_texts, vec!["Even header"]);
4660    }
4661
4662    #[test]
4663    fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
4664        let mut score = score_with_measures(2);
4665        let mut part = Part::new("Flute", "Fl.");
4666        let mut staff = Staff::new(Clef::Treble);
4667        staff.measures = vec![Measure::empty(4, 4); 5];
4668        part.staves = vec![staff];
4669        score.parts.push(part);
4670
4671        let extracted = compute_print_layout(
4672            &score,
4673            &PrintConfig {
4674                part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
4675                measures_per_system: 2,
4676                systems_per_page: Some(1),
4677                ..PrintConfig::default()
4678            },
4679        )
4680        .expect("valid extracted part");
4681        assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
4682        assert_eq!(extracted.pages.len(), 3);
4683        assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
4684
4685        let error = compute_print_layout(
4686            &score,
4687            &PrintConfig {
4688                part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
4689                ..PrintConfig::default()
4690            },
4691        )
4692        .expect_err("missing extracted part");
4693        assert_eq!(error, PrintLayoutError::InvalidPartIndex);
4694    }
4695
4696    #[test]
4697    fn title_page_is_inserted_without_consuming_music_page_capacity() {
4698        let mut score = score_with_measures(3);
4699        score.texts.push(StyledText {
4700            style: TextStyle::Expression,
4701            text: "Dedication".into(),
4702            placement: None,
4703            offset_x: None,
4704            offset_y: None,
4705            relative_x: None,
4706            relative_y: None,
4707        });
4708        score.metadata.title = "Suite".into();
4709        score.metadata.movement_title = "I. Prelude".into();
4710        score.metadata.composer = "Composer".into();
4711        score.metadata.copyright = "© 2026 Composer".into();
4712        let result = compute_print_layout(
4713            &score,
4714            &PrintConfig {
4715                systems_per_page: Some(1),
4716                measures_per_system: 2,
4717                publication: PublicationConfig {
4718                    title_page: true,
4719                    ..PublicationConfig::default()
4720                },
4721                ..PrintConfig::default()
4722            },
4723        )
4724        .expect("valid title page config");
4725        assert_eq!(result.pages.len(), 3);
4726        assert!(result.pages[0].systems.is_empty());
4727        assert!(result.pages[0].publication.is_title_page);
4728        assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
4729        assert_eq!(result.pages[0].page_number, Some(1));
4730        assert_eq!(result.pages[1].page_number, Some(2));
4731        assert_eq!(result.pages[1].systems[0].page_index, 1);
4732        assert!(!result.pages[1].publication.is_title_page);
4733        assert_eq!(result.pages[0].publication.score_texts, score.texts);
4734        assert!(result.validate().is_ok());
4735        assert_eq!(
4736            result.pages[0]
4737                .publication
4738                .text_blocks
4739                .iter()
4740                .map(|block| block.role)
4741                .collect::<Vec<_>>(),
4742            vec![
4743                PublicationTextRole::Title,
4744                PublicationTextRole::Subtitle,
4745                PublicationTextRole::Credit,
4746                PublicationTextRole::Copyright
4747            ]
4748        );
4749    }
4750
4751    #[test]
4752    fn print_presets_are_versioned_and_select_the_expected_scope() {
4753        assert_eq!(PrintPreset::A4Score.schema_version(), 1);
4754        assert_eq!(
4755            PrintPreset::A4Score.config().part_layout,
4756            PartLayoutPolicy::FullScore
4757        );
4758        assert_eq!(
4759            PrintPreset::LetterPart { part_index: 2 }
4760                .config()
4761                .part_layout,
4762            PartLayoutPolicy::ExtractedPart { part_index: 2 }
4763        );
4764        assert_eq!(
4765            PrintPreset::LetterScore.config().paper_size,
4766            PaperSize::Letter
4767        );
4768        assert!(
4769            PrintPreset::A4Score
4770                .config_with_title_page(true)
4771                .publication
4772                .title_page
4773        );
4774        assert!(!PrintPreset::A4Score.config().publication.title_page);
4775        assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 1);
4776    }
4777
4778    #[test]
4779    fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
4780        let metrics = GlyphMetrics {
4781            advance_mm: 4.0,
4782            left_mm: -1.0,
4783            top_mm: -2.0,
4784            width_mm: 2.0,
4785            height_mm: 4.0,
4786        };
4787        let mut placements = vec![
4788            GlyphPlacement {
4789                resource_key: "high".into(),
4790                metrics,
4791                x_mm: 10.0,
4792                y_mm: 20.0,
4793                priority: 10,
4794            },
4795            GlyphPlacement {
4796                resource_key: "low".into(),
4797                metrics,
4798                x_mm: 10.0,
4799                y_mm: 20.0,
4800                priority: 1,
4801            },
4802        ];
4803        let moved = resolve_glyph_collisions(&mut placements, 1.0);
4804        assert_eq!(moved, 1);
4805        assert_eq!(placements[0].y_mm, 20.0);
4806        assert_eq!(placements[1].y_mm, 25.0);
4807    }
4808
4809    #[test]
4810    fn class_aware_collision_resolution_uses_stable_semantic_tie_breakers() {
4811        let metrics = GlyphMetrics {
4812            advance_mm: 4.0,
4813            left_mm: -1.0,
4814            top_mm: -2.0,
4815            width_mm: 2.0,
4816            height_mm: 4.0,
4817        };
4818        let mut placements = vec![
4819            GlyphPlacement {
4820                resource_key: "annotation".into(),
4821                metrics,
4822                x_mm: 10.0,
4823                y_mm: 20.0,
4824                priority: 1,
4825            },
4826            GlyphPlacement {
4827                resource_key: "critical".into(),
4828                metrics,
4829                x_mm: 10.0,
4830                y_mm: 20.0,
4831                priority: 1,
4832            },
4833        ];
4834        let classes = [
4835            GlyphCollisionClass::Annotation,
4836            GlyphCollisionClass::Critical,
4837        ];
4838        assert_eq!(
4839            resolve_glyph_collisions_with_classes(&mut placements, &classes, 1.0),
4840            Ok(1)
4841        );
4842        assert_eq!(placements[0].y_mm, 25.0);
4843        assert_eq!(placements[1].y_mm, 20.0);
4844        assert_eq!(
4845            resolve_glyph_horizontal_collisions_with_classes(
4846                &mut placements,
4847                &[GlyphCollisionClass::Annotation],
4848                1.0,
4849            ),
4850            Err(GlyphPlacementError::CollisionClassCount {
4851                placements: 2,
4852                classes: 1,
4853            })
4854        );
4855    }
4856
4857    #[test]
4858    fn constrained_collision_pass_honors_annotation_escape_lanes() {
4859        let metrics = GlyphMetrics {
4860            advance_mm: 4.0,
4861            left_mm: -1.0,
4862            top_mm: -2.0,
4863            width_mm: 2.0,
4864            height_mm: 4.0,
4865        };
4866        let mut placements = vec![
4867            GlyphPlacement {
4868                resource_key: "notation".into(),
4869                metrics,
4870                x_mm: 10.0,
4871                y_mm: 20.0,
4872                priority: 1,
4873            },
4874            GlyphPlacement {
4875                resource_key: "lyric".into(),
4876                metrics,
4877                x_mm: 10.0,
4878                y_mm: 20.0,
4879                priority: 1,
4880            },
4881            GlyphPlacement {
4882                resource_key: "rehearsal".into(),
4883                metrics,
4884                x_mm: 10.0,
4885                y_mm: 20.0,
4886                priority: 1,
4887            },
4888            GlyphPlacement {
4889                resource_key: "tab".into(),
4890                metrics,
4891                x_mm: 10.0,
4892                y_mm: 20.0,
4893                priority: 1,
4894            },
4895        ];
4896        let classes = [
4897            GlyphCollisionClass::Critical,
4898            GlyphCollisionClass::Annotation,
4899            GlyphCollisionClass::Annotation,
4900            GlyphCollisionClass::Annotation,
4901        ];
4902        let directions = [
4903            GlyphCollisionDirection::Down,
4904            GlyphCollisionDirection::Down,
4905            GlyphCollisionDirection::Up,
4906            GlyphCollisionDirection::Right,
4907        ];
4908
4909        assert_eq!(
4910            resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
4911            Ok(3)
4912        );
4913        assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
4914        assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
4915        assert_eq!((placements[2].x_mm, placements[2].y_mm), (10.0, 15.0));
4916        assert_eq!((placements[3].x_mm, placements[3].y_mm), (13.0, 20.0));
4917
4918        let before = placements.clone();
4919        assert_eq!(
4920            resolve_glyph_collisions_constrained(
4921                &mut placements,
4922                &classes,
4923                &[GlyphCollisionDirection::Down],
4924                1.0
4925            ),
4926            Err(GlyphPlacementError::CollisionDirectionCount {
4927                placements: 4,
4928                directions: 1,
4929            })
4930        );
4931        assert_eq!(placements, before);
4932    }
4933
4934    #[test]
4935    fn constrained_collision_pass_keeps_fixed_obstacles_in_place() {
4936        let metrics = GlyphMetrics {
4937            advance_mm: 4.0,
4938            left_mm: -1.0,
4939            top_mm: -2.0,
4940            width_mm: 2.0,
4941            height_mm: 4.0,
4942        };
4943        let mut placements = vec![
4944            GlyphPlacement {
4945                resource_key: "resolved-annotation".into(),
4946                metrics,
4947                x_mm: 10.0,
4948                y_mm: 20.0,
4949                priority: 2,
4950            },
4951            GlyphPlacement {
4952                resource_key: "measure-text".into(),
4953                metrics,
4954                x_mm: 10.0,
4955                y_mm: 20.0,
4956                priority: 1,
4957            },
4958        ];
4959        let classes = [
4960            GlyphCollisionClass::Critical,
4961            GlyphCollisionClass::Annotation,
4962        ];
4963        let directions = [
4964            GlyphCollisionDirection::Fixed,
4965            GlyphCollisionDirection::Down,
4966        ];
4967
4968        assert_eq!(
4969            resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
4970            Ok(1)
4971        );
4972        assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
4973        assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
4974    }
4975
4976    #[test]
4977    fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
4978        let metrics = GlyphMetrics {
4979            advance_mm: 4.0,
4980            left_mm: -1.0,
4981            top_mm: -1.0,
4982            width_mm: 2.0,
4983            height_mm: 2.0,
4984        };
4985        let mut placements = vec![
4986            GlyphPlacement {
4987                resource_key: "high".into(),
4988                metrics,
4989                x_mm: 10.0,
4990                y_mm: 20.0,
4991                priority: 10,
4992            },
4993            GlyphPlacement {
4994                resource_key: "low".into(),
4995                metrics,
4996                x_mm: 10.0,
4997                y_mm: 0.0,
4998                priority: 1,
4999            },
5000        ];
5001        assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
5002        assert_eq!(placements[1].y_mm, 0.0);
5003    }
5004
5005    #[test]
5006    fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
5007        let mut placements = vec![GlyphPlacement {
5008            resource_key: "test".into(),
5009            metrics: GlyphMetrics {
5010                advance_mm: 1.0,
5011                left_mm: 0.0,
5012                top_mm: 0.0,
5013                width_mm: 1.0,
5014                height_mm: 1.0,
5015            },
5016            x_mm: 0.0,
5017            y_mm: 0.0,
5018            priority: 0,
5019        }];
5020        assert_eq!(validate_glyph_placements(&placements), Ok(()));
5021        placements[0].x_mm = f32::NAN;
5022        assert_eq!(
5023            validate_glyph_placements(&placements),
5024            Err(GlyphPlacementError::NonFinite { index: 0 })
5025        );
5026        placements[0].x_mm = 0.0;
5027        placements[0].metrics.width_mm = -1.0;
5028        assert_eq!(
5029            validate_glyph_placements(&placements),
5030            Err(GlyphPlacementError::NegativeExtent { index: 0 })
5031        );
5032    }
5033
5034    #[test]
5035    fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
5036        let metrics = GlyphMetrics {
5037            advance_mm: 4.0,
5038            left_mm: -1.0,
5039            top_mm: -1.0,
5040            width_mm: 2.0,
5041            height_mm: 2.0,
5042        };
5043        let mut placements = vec![
5044            GlyphPlacement {
5045                resource_key: "high".into(),
5046                metrics,
5047                x_mm: 10.0,
5048                y_mm: 20.0,
5049                priority: 10,
5050            },
5051            GlyphPlacement {
5052                resource_key: "low".into(),
5053                metrics,
5054                x_mm: 10.0,
5055                y_mm: 20.0,
5056                priority: 1,
5057            },
5058            GlyphPlacement {
5059                resource_key: "far".into(),
5060                metrics,
5061                x_mm: 10.0,
5062                y_mm: 30.0,
5063                priority: 1,
5064            },
5065        ];
5066        assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
5067        assert_eq!(placements[0].x_mm, 10.0);
5068        assert_eq!(placements[1].x_mm, 13.0);
5069        assert_eq!(placements[2].x_mm, 10.0);
5070    }
5071
5072    #[test]
5073    fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
5074        let metrics = GlyphMetrics {
5075            advance_mm: 1.0,
5076            left_mm: 0.0,
5077            top_mm: 0.0,
5078            width_mm: 1.0,
5079            height_mm: 1.0,
5080        };
5081        let mut placements = vec![
5082            GlyphPlacement {
5083                resource_key: "second".into(),
5084                metrics,
5085                x_mm: 20.0,
5086                y_mm: 0.0,
5087                priority: 0,
5088            },
5089            GlyphPlacement {
5090                resource_key: "first".into(),
5091                metrics,
5092                x_mm: 10.0,
5093                y_mm: 0.0,
5094                priority: 0,
5095            },
5096            GlyphPlacement {
5097                resource_key: "third".into(),
5098                metrics,
5099                x_mm: 30.0,
5100                y_mm: 0.0,
5101                priority: 0,
5102            },
5103        ];
5104        assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
5105        assert_eq!(placements[0].x_mm, 23.0);
5106        assert_eq!(placements[1].x_mm, 10.0);
5107        assert_eq!(placements[2].x_mm, 36.0);
5108        assert_eq!(
5109            distribute_glyph_spacing(&mut placements, f32::NAN),
5110            Err(GlyphPlacementError::NonFiniteSpacing)
5111        );
5112        let before = placements.clone();
5113        assert_eq!(
5114            distribute_glyph_spacing(&mut placements, f32::MAX),
5115            Err(GlyphPlacementError::NonFiniteSpacing)
5116        );
5117        assert_eq!(placements, before);
5118    }
5119
5120    #[test]
5121    fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
5122        let mut placement = GlyphPlacement {
5123            resource_key: " ".into(),
5124            metrics: GlyphMetrics {
5125                advance_mm: 1.0,
5126                left_mm: 0.0,
5127                top_mm: 0.0,
5128                width_mm: 1.0,
5129                height_mm: 1.0,
5130            },
5131            x_mm: 0.0,
5132            y_mm: 0.0,
5133            priority: 0,
5134        };
5135        assert_eq!(
5136            validate_glyph_placements(&[placement.clone()]),
5137            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5138        );
5139        placement.resource_key = "glyph".into();
5140        placement.metrics.advance_mm = -1.0;
5141        assert_eq!(
5142            validate_glyph_placements(&[placement]),
5143            Err(GlyphPlacementError::NegativeAdvance { index: 0 })
5144        );
5145    }
5146
5147    #[test]
5148    fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
5149        let mut placements = vec![GlyphPlacement {
5150            resource_key: String::new(),
5151            metrics: GlyphMetrics {
5152                advance_mm: 1.0,
5153                left_mm: 0.0,
5154                top_mm: 0.0,
5155                width_mm: 1.0,
5156                height_mm: 1.0,
5157            },
5158            x_mm: 0.0,
5159            y_mm: 0.0,
5160            priority: 0,
5161        }];
5162        assert_eq!(
5163            resolve_glyph_collisions_checked(&mut placements, 1.0),
5164            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5165        );
5166        assert_eq!(
5167            resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
5168            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5169        );
5170        assert_eq!(placements[0].x_mm, 0.0);
5171        assert_eq!(placements[0].y_mm, 0.0);
5172    }
5173
5174    #[test]
5175    fn checked_collision_resolvers_reject_non_finite_gap() {
5176        let metrics = GlyphMetrics {
5177            advance_mm: 1.0,
5178            left_mm: 0.0,
5179            top_mm: 0.0,
5180            width_mm: 1.0,
5181            height_mm: 1.0,
5182        };
5183        let original = vec![GlyphPlacement {
5184            resource_key: "glyph".into(),
5185            metrics,
5186            x_mm: 0.0,
5187            y_mm: 0.0,
5188            priority: 0,
5189        }];
5190        let mut vertical = original.clone();
5191        assert_eq!(
5192            resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
5193            Err(GlyphPlacementError::NonFiniteSpacing)
5194        );
5195        assert_eq!(vertical, original);
5196
5197        let mut horizontal = original.clone();
5198        assert_eq!(
5199            resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
5200            Err(GlyphPlacementError::NonFiniteSpacing)
5201        );
5202        assert_eq!(horizontal, original);
5203    }
5204
5205    #[test]
5206    fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
5207        let metrics = GlyphMetrics {
5208            advance_mm: 1.0,
5209            left_mm: 0.0,
5210            top_mm: 0.0,
5211            width_mm: f32::MAX / 2.0,
5212            height_mm: f32::MAX / 2.0,
5213        };
5214        let original = vec![
5215            GlyphPlacement {
5216                resource_key: "high".into(),
5217                metrics,
5218                x_mm: 0.0,
5219                y_mm: 0.0,
5220                priority: 1,
5221            },
5222            GlyphPlacement {
5223                resource_key: "low".into(),
5224                metrics,
5225                x_mm: 0.0,
5226                y_mm: 0.0,
5227                priority: 0,
5228            },
5229        ];
5230        let mut vertical = original.clone();
5231        assert_eq!(
5232            resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
5233            Err(GlyphPlacementError::NonFinite { index: 1 })
5234        );
5235        assert_eq!(vertical, original);
5236
5237        let mut horizontal = original.clone();
5238        assert_eq!(
5239            resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
5240            Err(GlyphPlacementError::NonFinite { index: 1 })
5241        );
5242        assert_eq!(horizontal, original);
5243    }
5244
5245    #[test]
5246    fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
5247        let metrics = GlyphMetrics {
5248            advance_mm: 1.0,
5249            left_mm: -1.0,
5250            top_mm: -2.0,
5251            width_mm: 3.0,
5252            height_mm: 4.0,
5253        };
5254        let placements = vec![
5255            GlyphPlacement {
5256                resource_key: "a".into(),
5257                metrics,
5258                x_mm: 10.0,
5259                y_mm: 20.0,
5260                priority: 0,
5261            },
5262            GlyphPlacement {
5263                resource_key: "b".into(),
5264                metrics,
5265                x_mm: 30.0,
5266                y_mm: 5.0,
5267                priority: 0,
5268            },
5269        ];
5270        assert_eq!(
5271            glyph_extents(&placements),
5272            Ok(Some(GlyphExtents {
5273                left_mm: 9.0,
5274                top_mm: 3.0,
5275                right_mm: 32.0,
5276                bottom_mm: 22.0,
5277            }))
5278        );
5279        let extents = glyph_extents(&placements).unwrap().unwrap();
5280        assert_eq!(extents.width_mm(), 23.0);
5281        assert_eq!(extents.height_mm(), 19.0);
5282        assert_eq!(glyph_extents(&[]), Ok(None));
5283    }
5284
5285    #[test]
5286    fn glyph_extents_reject_derived_bound_overflow() {
5287        let placements = [GlyphPlacement {
5288            resource_key: "edge".into(),
5289            metrics: GlyphMetrics {
5290                advance_mm: 1.0,
5291                left_mm: 0.0,
5292                top_mm: 0.0,
5293                width_mm: f32::MAX,
5294                height_mm: 1.0,
5295            },
5296            x_mm: f32::MAX,
5297            y_mm: 0.0,
5298            priority: 0,
5299        }];
5300        assert_eq!(
5301            glyph_extents(&placements),
5302            Err(GlyphPlacementError::NonFinite { index: 0 })
5303        );
5304    }
5305
5306    #[test]
5307    fn page_render_tree_preserves_canonical_note_and_rest_addresses() {
5308        let mut score = score_with_measures(1);
5309        score.parts[0].staves[0].measures[0].voices[0] = vec![
5310            Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5311            Note::rest(Duration::Quarter),
5312        ];
5313        let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5314        let trees = layout
5315            .export_page_render_trees(&score)
5316            .expect("render trees");
5317        assert_eq!(trees.len(), 1);
5318        assert_eq!(trees[0].contract_version, PAGE_RENDER_TREE_CONTRACT_VERSION);
5319        assert_eq!(trees[0].validate(&score), Ok(()));
5320        assert!(trees[0].nodes.iter().any(|node| matches!(
5321            (&node.address, &node.kind),
5322            (PageRenderAddress::Note(address), PageRenderNodeKind::Note)
5323                if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 0
5324        )));
5325        assert!(trees[0].nodes.iter().any(|node| matches!(
5326            (&node.address, &node.kind),
5327            (PageRenderAddress::Note(address), PageRenderNodeKind::Rest)
5328                if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 1
5329        )));
5330        let restored: Vec<PageRenderTree> =
5331            serde_json::from_str(&serde_json::to_string(&trees).expect("trees serialize"))
5332                .expect("trees deserialize");
5333        assert_eq!(restored, trees);
5334        let mut invalid = restored[0].clone();
5335        invalid.nodes[0]
5336            .system
5337            .as_mut()
5338            .expect("system node")
5339            .page_index = 99;
5340        assert!(matches!(
5341            invalid.validate(&score),
5342            Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5343        ));
5344
5345        let mut wrong_note_kind = restored[0].clone();
5346        wrong_note_kind.nodes[0].kind = PageRenderNodeKind::Rest;
5347        assert!(matches!(
5348            wrong_note_kind.validate(&score),
5349            Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5350        ));
5351
5352        let mut missing_system = restored[0].clone();
5353        missing_system.nodes[0].system = None;
5354        assert!(matches!(
5355            missing_system.validate(&score),
5356            Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5357        ));
5358    }
5359
5360    #[test]
5361    fn page_render_tree_for_linked_view_keeps_source_part_addresses() {
5362        let mut score = score_with_measures(1);
5363        score.parts[0].staves[0].measures[0].voices[0] =
5364            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5365        score
5366            .views
5367            .push(ScoreView::linked_part("piano", "Piano", 0));
5368        let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5369        let trees = layout
5370            .export_page_render_trees_for_view(&score, "piano")
5371            .expect("view trees");
5372        assert_eq!(trees[0].view_id.as_deref(), Some("piano"));
5373        assert!(trees[0].nodes.iter().all(|node| match &node.address {
5374            PageRenderAddress::Note(address) => address.part == 0,
5375            _ => true,
5376        }));
5377        assert!(
5378            layout
5379                .export_page_render_trees_for_view(&score, "missing")
5380                .is_err()
5381        );
5382    }
5383
5384    #[test]
5385    fn page_render_tree_for_linked_view_omits_hidden_staff_nodes() {
5386        let mut score = Score::template(ScoreTemplate::Piano);
5387        score.parts[0].staves[0].measures[0].voices[0] =
5388            vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5389        score.parts[0].staves[1].measures[0].voices[0] =
5390            vec![Note::new(Pitch::new(Step::C, 3), Duration::Quarter)];
5391        let mut view = ScoreView::linked_part("piano", "Piano", 0);
5392        view.layout
5393            .hidden_staves
5394            .push(ViewStaffRef { part: 0, staff: 1 });
5395        score.views.push(view);
5396
5397        let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5398        let trees = layout
5399            .export_page_render_trees_for_view(&score, "piano")
5400            .expect("view trees");
5401
5402        let note_staves = trees
5403            .iter()
5404            .flat_map(|tree| tree.nodes.iter())
5405            .filter_map(|node| match &node.address {
5406                PageRenderAddress::Note(address) => Some(address.staff),
5407                _ => None,
5408            })
5409            .collect::<Vec<_>>();
5410        assert!(!note_staves.is_empty());
5411        assert!(note_staves.iter().all(|&staff| staff == 0));
5412        assert!(trees.iter().all(|tree| tree.validate(&score).is_ok()));
5413
5414        let mut invalid = trees[0].clone();
5415        invalid.nodes.push(PageRenderNode {
5416            address: PageRenderAddress::Note(NoteAddr {
5417                part: 0,
5418                staff: 1,
5419                measure: 0,
5420                voice: 0,
5421                note: 0,
5422            }),
5423            kind: PageRenderNodeKind::Note,
5424            system: None,
5425        });
5426        assert!(matches!(
5427            invalid.validate(&score),
5428            Err(PrintLayoutError::InvalidRenderTreeNode { .. })
5429        ));
5430    }
5431
5432    #[test]
5433    fn print_layout_for_linked_view_applies_local_breaks_without_mutating_score() {
5434        let mut score = score_with_measures(4);
5435        let mut view = ScoreView::linked_part("part", "Part", 0);
5436        view.layout.measures_per_row = Some(3);
5437        view.layout.system_breaks.push(1);
5438        score.views.push(view);
5439
5440        let layout = compute_print_layout_for_view(&score, &PrintConfig::default(), "part")
5441            .expect("view layout");
5442        let systems = layout
5443            .pages
5444            .iter()
5445            .flat_map(|page| page.systems.iter())
5446            .collect::<Vec<_>>();
5447        assert_eq!(
5448            systems
5449                .iter()
5450                .map(|system| system.measure_indices.clone())
5451                .collect::<Vec<_>>(),
5452            vec![vec![0, 1], vec![2, 3]]
5453        );
5454        assert!(!score.parts[0].staves[0].measures[1].system_break);
5455    }
5456
5457    #[test]
5458    fn page_render_tree_keeps_page_scoped_resource_addresses() {
5459        let score = score_with_measures(1);
5460        let config = PrintConfig {
5461            publication: PublicationConfig {
5462                title_page: true,
5463                image_resources: vec![
5464                    PublicationImageResource {
5465                        resource_key: "cover-art-v1".into(),
5466                        alt_text: "Cover".into(),
5467                        placement: PublicationImagePlacement::TitlePage,
5468                        x_mm: 10.0,
5469                        y_mm: 10.0,
5470                        width_mm: 30.0,
5471                        height_mm: 20.0,
5472                    },
5473                    PublicationImageResource {
5474                        resource_key: "publisher-mark".into(),
5475                        alt_text: "Mark".into(),
5476                        placement: PublicationImagePlacement::MusicPages,
5477                        x_mm: 160.0,
5478                        y_mm: 10.0,
5479                        width_mm: 20.0,
5480                        height_mm: 10.0,
5481                    },
5482                ],
5483                frames: vec![PublicationFrame {
5484                    placement: PublicationFramePlacement::EveryPage,
5485                    x_mm: 5.0,
5486                    y_mm: 5.0,
5487                    width_mm: 200.0,
5488                    height_mm: 287.0,
5489                    stroke_width_mm: 0.5,
5490                }],
5491                ..PublicationConfig::default()
5492            },
5493            ..PrintConfig::default()
5494        };
5495        let layout = compute_print_layout(&score, &config).expect("layout");
5496        let trees = layout
5497            .export_page_render_trees(&score)
5498            .expect("render trees");
5499        assert!(trees[0].nodes.iter().any(|node| matches!(
5500            (&node.address, &node.kind),
5501            (
5502                PageRenderAddress::Resource { page_index: 0, resource_key },
5503                PageRenderNodeKind::Resource,
5504            ) if resource_key == "cover-art-v1"
5505        )));
5506        assert!(trees[1].nodes.iter().any(|node| matches!(
5507            (&node.address, &node.kind),
5508            (
5509                PageRenderAddress::Resource { page_index: 1, resource_key },
5510                PageRenderNodeKind::Resource,
5511            ) if resource_key == "publisher-mark"
5512        )));
5513        assert!(
5514            trees
5515                .iter()
5516                .enumerate()
5517                .all(|(page_index, tree)| tree.nodes.iter().any(|node| matches!(
5518                    (&node.address, &node.kind),
5519                    (
5520                        PageRenderAddress::Frame { page_index: address_page, frame_index: 0 },
5521                        PageRenderNodeKind::Frame,
5522                    ) if *address_page == page_index
5523                )))
5524        );
5525    }
5526}