Skip to main content

acorde_layout/
print.rs

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