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