Skip to main content

acorde_layout/
print.rs

1use crate::{LayoutConfig, SpanMark, compute_layout};
2use acorde_core::{Barline, PartGroupSymbol, Score, StyledText, TextStyle};
3use serde::{Deserialize, Serialize};
4
5/// Font-independent metrics for one print glyph, expressed in millimetres.
6///
7/// Hosts may resolve a resource key to a real font, but layout can use these metrics without
8/// loading fonts or depending on an operating system. `advance_mm` is the cursor advance;
9/// the bounding box is relative to the glyph origin and is used for collision checks.
10#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
11pub struct GlyphMetrics {
12    pub advance_mm: f32,
13    pub left_mm: f32,
14    pub top_mm: f32,
15    pub width_mm: f32,
16    pub height_mm: f32,
17}
18
19/// Version of the host-neutral glyph resource descriptor contract.
20pub const GLYPH_RESOURCE_CONTRACT_VERSION: u16 = 1;
21
22/// How a print host should behave when the primary glyph resource is unavailable.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
24pub enum GlyphFallbackPolicy {
25    /// Fail preflight rather than silently changing notation appearance.
26    #[default]
27    Reject,
28    /// Use another explicitly declared resource key.
29    UseResource(String),
30}
31
32/// Reproducible metadata for a host-resolved font or notation glyph resource.
33///
34/// `acorde` does not load, embed, or license-check the resource. It does require the host to
35/// identify the resource, its metrics contract, license notice, and fallback behavior before a
36/// publication export can claim reproducibility.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
38pub struct GlyphResourceDescriptor {
39    pub contract_version: u16,
40    pub resource_key: String,
41    pub metrics_contract_version: u16,
42    pub license_notice: String,
43    #[serde(default)]
44    pub fallback: GlyphFallbackPolicy,
45}
46
47/// Validation failures for a host-provided glyph resource descriptor.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
49pub enum GlyphResourceDescriptorError {
50    #[error("unsupported glyph resource contract version")]
51    UnsupportedContractVersion,
52    #[error("glyph resource key is empty")]
53    EmptyResourceKey,
54    #[error("glyph resource metrics contract version is invalid")]
55    InvalidMetricsContractVersion,
56    #[error("glyph resource license notice is empty")]
57    EmptyLicenseNotice,
58    #[error("glyph fallback resource key is empty")]
59    EmptyFallbackResourceKey,
60    #[error("glyph fallback resource must differ from the primary resource")]
61    FallbackMatchesPrimary,
62}
63
64impl GlyphResourceDescriptor {
65    /// Validate the metadata needed to resolve a reproducible host resource.
66    pub fn validate(&self) -> Result<(), GlyphResourceDescriptorError> {
67        if self.contract_version != GLYPH_RESOURCE_CONTRACT_VERSION {
68            return Err(GlyphResourceDescriptorError::UnsupportedContractVersion);
69        }
70        if self.resource_key.trim().is_empty() {
71            return Err(GlyphResourceDescriptorError::EmptyResourceKey);
72        }
73        if self.metrics_contract_version == 0 {
74            return Err(GlyphResourceDescriptorError::InvalidMetricsContractVersion);
75        }
76        if self.license_notice.trim().is_empty() {
77            return Err(GlyphResourceDescriptorError::EmptyLicenseNotice);
78        }
79        if let GlyphFallbackPolicy::UseResource(key) = &self.fallback {
80            if key.trim().is_empty() {
81                return Err(GlyphResourceDescriptorError::EmptyFallbackResourceKey);
82            }
83            if key == &self.resource_key {
84                return Err(GlyphResourceDescriptorError::FallbackMatchesPrimary);
85            }
86        }
87        Ok(())
88    }
89}
90
91/// A positioned print glyph with a deterministic collision priority.
92#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
93pub struct GlyphPlacement {
94    pub resource_key: String,
95    pub metrics: GlyphMetrics,
96    pub x_mm: f32,
97    pub y_mm: f32,
98    /// Higher-priority glyphs keep their requested position when possible.
99    pub priority: u8,
100}
101
102/// Semantic collision classes used to make dense print placement deterministic.
103///
104/// The class is supplied alongside placements so the existing [`GlyphPlacement`] JSON shape
105/// remains backwards-compatible. Higher-priority placements still win; the class is the stable
106/// tie-breaker for placements with equal priority.
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
108pub enum GlyphCollisionClass {
109    /// Primary notation that must retain its requested position when possible.
110    #[default]
111    Critical,
112    /// Spacing-bearing symbols such as accidentals and noteheads.
113    Spacing,
114    /// Text and other semantic annotations.
115    Annotation,
116    /// Optional visual decoration.
117    Decorative,
118}
119
120/// The content bounds of a validated glyph placement collection, in millimetres.
121#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
122pub struct GlyphExtents {
123    pub left_mm: f32,
124    pub top_mm: f32,
125    pub right_mm: f32,
126    pub bottom_mm: f32,
127}
128
129impl GlyphExtents {
130    /// Return the horizontal content span in millimetres.
131    pub fn width_mm(self) -> f32 {
132        self.right_mm - self.left_mm
133    }
134
135    /// Return the vertical content span in millimetres.
136    pub fn height_mm(self) -> f32 {
137        self.bottom_mm - self.top_mm
138    }
139}
140
141/// Validation failures for host-provided print glyph geometry.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
143pub enum GlyphPlacementError {
144    #[error("glyph placement {index} contains non-finite geometry")]
145    NonFinite { index: usize },
146    #[error("glyph placement {index} has a negative bounding-box extent")]
147    NegativeExtent { index: usize },
148    #[error("glyph spacing is non-finite or overflows")]
149    NonFiniteSpacing,
150    #[error("glyph placement {index} has an empty resource key")]
151    EmptyResourceKey { index: usize },
152    #[error("glyph placement {index} has a negative advance")]
153    NegativeAdvance { index: usize },
154    #[error("collision class count {classes} does not match placement count {placements}")]
155    CollisionClassCount { placements: usize, classes: usize },
156}
157
158/// Validate font-independent glyph geometry before collision resolution.
159pub fn validate_glyph_placements(placements: &[GlyphPlacement]) -> Result<(), GlyphPlacementError> {
160    for (index, placement) in placements.iter().enumerate() {
161        if placement.resource_key.trim().is_empty() {
162            return Err(GlyphPlacementError::EmptyResourceKey { index });
163        }
164        let values = [
165            placement.metrics.advance_mm,
166            placement.metrics.left_mm,
167            placement.metrics.top_mm,
168            placement.metrics.width_mm,
169            placement.metrics.height_mm,
170            placement.x_mm,
171            placement.y_mm,
172        ];
173        if values.iter().any(|value| !value.is_finite()) {
174            return Err(GlyphPlacementError::NonFinite { index });
175        }
176        if placement.metrics.width_mm < 0.0 || placement.metrics.height_mm < 0.0 {
177            return Err(GlyphPlacementError::NegativeExtent { index });
178        }
179        if placement.metrics.advance_mm < 0.0 {
180            return Err(GlyphPlacementError::NegativeAdvance { index });
181        }
182    }
183    Ok(())
184}
185
186/// Compute content-aware bounds for glyph placements without loading a font resource.
187pub fn glyph_extents(
188    placements: &[GlyphPlacement],
189) -> Result<Option<GlyphExtents>, GlyphPlacementError> {
190    validate_glyph_placements(placements)?;
191    let Some(first) = placements.first() else {
192        return Ok(None);
193    };
194    let (first_left, first_right) = horizontal_bounds(first);
195    let (first_top, first_bottom) = vertical_bounds(first);
196    if [first_left, first_right, first_top, first_bottom]
197        .iter()
198        .any(|value| !value.is_finite())
199    {
200        return Err(GlyphPlacementError::NonFinite { index: 0 });
201    }
202    let mut extents = GlyphExtents {
203        left_mm: first_left,
204        top_mm: first_top,
205        right_mm: first_right,
206        bottom_mm: first_bottom,
207    };
208    for (index, placement) in placements.iter().enumerate().skip(1) {
209        let (left, right) = horizontal_bounds(placement);
210        let (top, bottom) = vertical_bounds(placement);
211        if [left, right, top, bottom]
212            .iter()
213            .any(|value| !value.is_finite())
214        {
215            return Err(GlyphPlacementError::NonFinite { index });
216        }
217        extents.left_mm = extents.left_mm.min(left);
218        extents.top_mm = extents.top_mm.min(top);
219        extents.right_mm = extents.right_mm.max(right);
220        extents.bottom_mm = extents.bottom_mm.max(bottom);
221    }
222    Ok(Some(extents))
223}
224
225/// Distribute additional horizontal space evenly between glyph placements.
226pub fn distribute_glyph_spacing(
227    placements: &mut [GlyphPlacement],
228    extra_mm: f32,
229) -> Result<usize, GlyphPlacementError> {
230    validate_glyph_placements(placements)?;
231    if !extra_mm.is_finite() {
232        return Err(GlyphPlacementError::NonFiniteSpacing);
233    }
234    if extra_mm <= 0.0 || placements.len() < 2 {
235        return Ok(0);
236    }
237    let mut order: Vec<usize> = (0..placements.len()).collect();
238    order.sort_by(|&left, &right| {
239        placements[left]
240            .x_mm
241            .total_cmp(&placements[right].x_mm)
242            .then(left.cmp(&right))
243    });
244    let denominator = (order.len() - 1) as f32;
245    let mut shifts = Vec::with_capacity(order.len().saturating_sub(1));
246    for (rank, &index) in order.iter().enumerate().skip(1) {
247        let shift = extra_mm * rank as f32 / denominator;
248        if !shift.is_finite() || !(placements[index].x_mm + shift).is_finite() {
249            return Err(GlyphPlacementError::NonFiniteSpacing);
250        }
251        shifts.push((index, shift));
252    }
253    let mut moved = 0;
254    for (index, shift) in shifts {
255        placements[index].x_mm += shift;
256        if shift > f32::EPSILON {
257            moved += 1;
258        }
259    }
260    Ok(moved)
261}
262
263/// Move lower-priority glyphs vertically until their bounding boxes no longer overlap.
264///
265/// This is intentionally a small, backend-neutral primitive: it does not choose fonts or
266/// draw anything. The stable input order breaks ties, and the return value reports how many
267/// placements were moved so a host can expose a preflight diagnostic.
268pub fn resolve_glyph_collisions(placements: &mut [GlyphPlacement], gap_mm: f32) -> usize {
269    let order = collision_order(placements, None);
270    resolve_glyph_collisions_ordered(placements, gap_mm, &order)
271}
272
273/// Resolve vertical collisions using explicit semantic classes.
274///
275/// This is the class-aware counterpart to [`resolve_glyph_collisions`]. It validates the class
276/// vector before mutating placements, then uses priority followed by class and source order as
277/// the deterministic ownership rule.
278pub fn resolve_glyph_collisions_with_classes(
279    placements: &mut [GlyphPlacement],
280    classes: &[GlyphCollisionClass],
281    gap_mm: f32,
282) -> Result<usize, GlyphPlacementError> {
283    validate_glyph_placements(placements)?;
284    if classes.len() != placements.len() {
285        return Err(GlyphPlacementError::CollisionClassCount {
286            placements: placements.len(),
287            classes: classes.len(),
288        });
289    }
290    if !gap_mm.is_finite() {
291        return Err(GlyphPlacementError::NonFiniteSpacing);
292    }
293    let mut candidate = placements.to_vec();
294    let order = collision_order(&candidate, Some(classes));
295    let moved = resolve_glyph_collisions_ordered(&mut candidate, gap_mm, &order);
296    glyph_extents(&candidate)?;
297    placements.clone_from_slice(&candidate);
298    Ok(moved)
299}
300
301fn resolve_glyph_collisions_ordered(
302    placements: &mut [GlyphPlacement],
303    gap_mm: f32,
304    order: &[usize],
305) -> usize {
306    let gap_mm = if gap_mm.is_finite() {
307        gap_mm.max(0.0)
308    } else {
309        0.0
310    };
311    let mut moved = 0;
312    for position in 0..order.len() {
313        let index = order[position];
314        let (left, right) = horizontal_bounds(&placements[index]);
315        let mut next_y = placements[index].y_mm;
316        for &previous in &order[..position] {
317            let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
318            if right <= previous_left || previous_right <= left {
319                continue;
320            }
321            let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
322            let current_top = next_y + placements[index].metrics.top_mm;
323            let current_bottom = current_top + placements[index].metrics.height_mm;
324            if current_bottom <= previous_top || previous_bottom <= current_top {
325                continue;
326            }
327            if current_top < previous_bottom + gap_mm {
328                next_y = previous_bottom + gap_mm - placements[index].metrics.top_mm;
329            }
330        }
331        if (next_y - placements[index].y_mm).abs() > f32::EPSILON {
332            placements[index].y_mm = next_y;
333            moved += 1;
334        }
335    }
336    moved
337}
338
339/// Validate glyph geometry, then apply deterministic vertical collision resolution.
340pub fn resolve_glyph_collisions_checked(
341    placements: &mut [GlyphPlacement],
342    gap_mm: f32,
343) -> Result<usize, GlyphPlacementError> {
344    validate_glyph_placements(placements)?;
345    if !gap_mm.is_finite() {
346        return Err(GlyphPlacementError::NonFiniteSpacing);
347    }
348    let mut candidate = placements.to_vec();
349    let moved = resolve_glyph_collisions(&mut candidate, gap_mm);
350    glyph_extents(&candidate)?;
351    placements.clone_from_slice(&candidate);
352    Ok(moved)
353}
354
355/// Move lower-priority glyphs horizontally until their bounding boxes no longer overlap.
356///
357/// Higher-priority placements retain their requested coordinates. When several placements
358/// overlap, stable input order breaks ties and the return value reports how many placements moved.
359pub fn resolve_glyph_horizontal_collisions(
360    placements: &mut [GlyphPlacement],
361    gap_mm: f32,
362) -> usize {
363    let order = collision_order(placements, None);
364    resolve_glyph_horizontal_collisions_ordered(placements, gap_mm, &order)
365}
366
367/// Resolve horizontal collisions using explicit semantic classes.
368pub fn resolve_glyph_horizontal_collisions_with_classes(
369    placements: &mut [GlyphPlacement],
370    classes: &[GlyphCollisionClass],
371    gap_mm: f32,
372) -> Result<usize, GlyphPlacementError> {
373    validate_glyph_placements(placements)?;
374    if classes.len() != placements.len() {
375        return Err(GlyphPlacementError::CollisionClassCount {
376            placements: placements.len(),
377            classes: classes.len(),
378        });
379    }
380    if !gap_mm.is_finite() {
381        return Err(GlyphPlacementError::NonFiniteSpacing);
382    }
383    let mut candidate = placements.to_vec();
384    let order = collision_order(&candidate, Some(classes));
385    let moved = resolve_glyph_horizontal_collisions_ordered(&mut candidate, gap_mm, &order);
386    glyph_extents(&candidate)?;
387    placements.clone_from_slice(&candidate);
388    Ok(moved)
389}
390
391fn resolve_glyph_horizontal_collisions_ordered(
392    placements: &mut [GlyphPlacement],
393    gap_mm: f32,
394    order: &[usize],
395) -> usize {
396    let gap_mm = if gap_mm.is_finite() {
397        gap_mm.max(0.0)
398    } else {
399        0.0
400    };
401    let mut moved = 0;
402    for position in 0..order.len() {
403        let index = order[position];
404        let original_x = placements[index].x_mm;
405        let mut next_x = original_x;
406        for &previous in &order[..position] {
407            let current = GlyphPlacement {
408                x_mm: next_x,
409                ..placements[index].clone()
410            };
411            let (left, right) = horizontal_bounds(&current);
412            let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
413            let (top, bottom) = vertical_bounds(&current);
414            let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
415            if right <= previous_left
416                || previous_right <= left
417                || bottom <= previous_top
418                || previous_bottom <= top
419            {
420                continue;
421            }
422            next_x = previous_right + gap_mm - placements[index].metrics.left_mm;
423        }
424        if (next_x - original_x).abs() > f32::EPSILON {
425            placements[index].x_mm = next_x;
426            moved += 1;
427        }
428    }
429    moved
430}
431
432fn collision_order(
433    placements: &[GlyphPlacement],
434    classes: Option<&[GlyphCollisionClass]>,
435) -> Vec<usize> {
436    let class_rank = |index: usize| {
437        classes
438            .and_then(|values| values.get(index))
439            .map_or(0, |class| match class {
440                GlyphCollisionClass::Critical => 0,
441                GlyphCollisionClass::Spacing => 1,
442                GlyphCollisionClass::Annotation => 2,
443                GlyphCollisionClass::Decorative => 3,
444            })
445    };
446    let mut order: Vec<usize> = (0..placements.len()).collect();
447    order.sort_by_key(|&index| {
448        (
449            std::cmp::Reverse(placements[index].priority),
450            class_rank(index),
451            index,
452        )
453    });
454    order
455}
456
457/// Validate glyph geometry, then apply deterministic horizontal collision resolution.
458pub fn resolve_glyph_horizontal_collisions_checked(
459    placements: &mut [GlyphPlacement],
460    gap_mm: f32,
461) -> Result<usize, GlyphPlacementError> {
462    validate_glyph_placements(placements)?;
463    if !gap_mm.is_finite() {
464        return Err(GlyphPlacementError::NonFiniteSpacing);
465    }
466    let mut candidate = placements.to_vec();
467    let moved = resolve_glyph_horizontal_collisions(&mut candidate, gap_mm);
468    glyph_extents(&candidate)?;
469    placements.clone_from_slice(&candidate);
470    Ok(moved)
471}
472
473fn horizontal_bounds(placement: &GlyphPlacement) -> (f32, f32) {
474    (
475        placement.x_mm + placement.metrics.left_mm,
476        placement.x_mm + placement.metrics.left_mm + placement.metrics.width_mm,
477    )
478}
479
480fn vertical_bottom(placement: &GlyphPlacement) -> f32 {
481    placement.y_mm + placement.metrics.top_mm + placement.metrics.height_mm
482}
483
484fn vertical_bounds(placement: &GlyphPlacement) -> (f32, f32) {
485    (
486        placement.y_mm + placement.metrics.top_mm,
487        vertical_bottom(placement),
488    )
489}
490
491/// A paper size expressed in physical millimetres.
492#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
493pub enum PaperSize {
494    A4,
495    Letter,
496    Legal,
497    Custom { width_mm: f32, height_mm: f32 },
498}
499
500impl PaperSize {
501    fn dimensions_mm(self) -> (f32, f32) {
502        match self {
503            Self::A4 => (210.0, 297.0),
504            Self::Letter => (215.9, 279.4),
505            Self::Legal => (215.9, 355.6),
506            Self::Custom {
507                width_mm,
508                height_mm,
509            } => (width_mm, height_mm),
510        }
511    }
512}
513
514/// Page orientation for a logical print layout.
515#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
516pub enum PageOrientation {
517    Portrait,
518    Landscape,
519}
520
521/// Policy for the page number exposed in logical page metadata.
522#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
523pub enum PageNumbering {
524    None,
525    OneBased,
526}
527
528/// Policy for distributing systems when automatic pagination would leave a one-system final page.
529#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
530pub enum FinalPagePolicy {
531    /// Preserve the configured page capacity, even when the final page is short.
532    #[default]
533    AllowSingleSystem,
534    /// Redistribute automatically paginated systems as evenly as possible across pages.
535    Balance,
536}
537
538/// Policy for reserving the first system for a partial pickup measure.
539#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
540pub enum PickupPolicy {
541    /// Detect a non-empty partial first measure automatically (the default).
542    #[default]
543    Auto,
544    /// Do not infer pickup measures from score content.
545    Preserve,
546    /// Detect a non-empty first measure shorter than its time signature and isolate it.
547    DetectFirstMeasure,
548}
549
550/// Policy for preserving repeat-ending notation while systems are reflowed.
551#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
552pub enum NotationBreakPolicy {
553    /// Keep the score's normal automatic system breaks.
554    #[default]
555    Preserve,
556    /// Keep each contiguous volta ending in one system when it fits.
557    KeepVoltaTogether,
558    /// Keep each repeat section on one page when it fits the page capacity.
559    KeepRepeatsTogether,
560}
561
562/// Color intent for a print-capable host.
563#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
564pub enum PrintColorPolicy {
565    #[default]
566    Monochrome,
567    Preserve,
568}
569
570/// Whether a host should expose crop marks at the configured bleed boundary.
571#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
572pub enum CropMarkPolicy {
573    #[default]
574    None,
575    BleedEdges,
576}
577
578/// How a host resolves fonts and notation glyph resources for print output.
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
580pub enum GlyphResourcePolicy {
581    /// Use the renderer's deterministic built-in vector glyphs where available.
582    #[default]
583    BuiltInVector,
584    /// Resolve a host-owned resource identified by this stable application key.
585    HostProvided(String),
586}
587
588/// Selects the score scope used by print pagination and notation metadata.
589#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
590pub enum PartLayoutPolicy {
591    /// Keep all parts in the score-level layout contract.
592    #[default]
593    FullScore,
594    /// Produce an extracted-part layout for the zero-based part index.
595    ExtractedPart { part_index: usize },
596}
597
598/// Host-neutral publication metadata policy carried into each page artifact.
599#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
600#[serde(default)]
601pub struct PublicationConfig {
602    /// Insert a metadata-only title page before the music pages.
603    pub title_page: bool,
604    /// Optional running title shown by a host on non-title pages.
605    pub running_title: Option<String>,
606    pub show_part_names: bool,
607    pub show_measure_numbers: bool,
608    /// Optional text placed in the logical page header.
609    pub header_text: Option<String>,
610    /// Optional text placed in the logical page footer.
611    pub footer_text: Option<String>,
612    /// Add the logical page number as a footer text block when numbering is enabled.
613    pub page_number_in_footer: bool,
614    pub header_alignment: PublicationTextAlignment,
615    pub footer_alignment: PublicationTextAlignment,
616    pub title_alignment: PublicationTextAlignment,
617    /// Logical line-box height for publication text blocks, in millimetres.
618    pub line_height_mm: f32,
619}
620
621impl Default for PublicationConfig {
622    fn default() -> Self {
623        Self {
624            title_page: false,
625            running_title: None,
626            show_part_names: true,
627            show_measure_numbers: true,
628            header_text: None,
629            footer_text: None,
630            page_number_in_footer: false,
631            header_alignment: PublicationTextAlignment::Left,
632            footer_alignment: PublicationTextAlignment::Left,
633            title_alignment: PublicationTextAlignment::Center,
634            line_height_mm: 4.0,
635        }
636    }
637}
638
639/// Semantic role for a host-rendered publication text block.
640#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
641pub enum PublicationTextRole {
642    Header,
643    Footer,
644    Title,
645    Subtitle,
646    Credit,
647    Copyright,
648}
649
650/// Horizontal alignment within a publication text block's physical width.
651#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
652pub enum PublicationTextAlignment {
653    #[default]
654    Left,
655    Center,
656    Right,
657}
658
659/// A page text block with deterministic physical placement.
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
661pub struct PublicationTextBlock {
662    pub role: PublicationTextRole,
663    pub text: String,
664    pub x_mm: f32,
665    pub y_mm: f32,
666    pub width_mm: f32,
667    pub height_mm: f32,
668    #[serde(default)]
669    pub alignment: PublicationTextAlignment,
670}
671
672/// A part label suitable for a score header or extracted-part host renderer.
673#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
674pub struct PartLabel {
675    pub part_index: usize,
676    pub name: String,
677    pub short_name: String,
678}
679
680/// A score-level part connector for a publication host.
681#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
682pub struct PartGroupMark {
683    pub first_part: usize,
684    pub last_part: usize,
685    pub symbol: PartGroupSymbol,
686    pub barlines_connect: bool,
687}
688
689/// Publication information for one logical page.
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
691#[serde(default)]
692pub struct PagePublication {
693    #[serde(default)]
694    pub is_title_page: bool,
695    pub title: String,
696    pub movement_title: String,
697    pub composer: String,
698    pub lyricist: String,
699    pub copyright: String,
700    pub running_title: Option<String>,
701    /// Score-level styled text retained for title-page and host publication rendering.
702    #[serde(default)]
703    pub score_texts: Vec<StyledText>,
704    pub part_labels: Vec<PartLabel>,
705    #[serde(default)]
706    pub part_groups: Vec<PartGroupMark>,
707    pub measure_numbers: Vec<u32>,
708    #[serde(default)]
709    pub text_blocks: Vec<PublicationTextBlock>,
710}
711
712/// A contiguous range of physical measures that must remain in one printed system.
713///
714/// Both endpoints are zero-based and inclusive. This is intentionally a layout request,
715/// not a score-model mutation, so hosts can apply publication presets without changing the
716/// editable score.
717#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
718pub struct KeepTogetherRange {
719    pub first_measure: usize,
720    pub last_measure: usize,
721}
722
723/// Host-neutral inputs for deterministic page and system layout.
724///
725/// This contract describes physical page geometry only. It intentionally does not select
726/// fonts, emit PDF, access printers, or perform filesystem I/O.
727#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
728#[serde(default)]
729pub struct PrintConfig {
730    pub paper_size: PaperSize,
731    pub orientation: PageOrientation,
732    pub margin_top_mm: f32,
733    pub margin_right_mm: f32,
734    pub margin_bottom_mm: f32,
735    pub margin_left_mm: f32,
736    pub bleed_top_mm: f32,
737    pub bleed_right_mm: f32,
738    pub bleed_bottom_mm: f32,
739    pub bleed_left_mm: f32,
740    pub safe_top_mm: f32,
741    pub safe_right_mm: f32,
742    pub safe_bottom_mm: f32,
743    pub safe_left_mm: f32,
744    pub system_height_mm: f32,
745    /// Content scale factor. `1.0` preserves the configured system height.
746    pub scale: f32,
747    pub measures_per_system: usize,
748    /// Optional measure capacity for the first system, useful for pickup/title systems.
749    #[serde(default)]
750    pub first_system_measures: Option<usize>,
751    #[serde(default)]
752    pub pickup_policy: PickupPolicy,
753    #[serde(default)]
754    pub notation_break_policy: NotationBreakPolicy,
755    /// Override the number of systems per page. When omitted it is derived from the usable
756    /// page height and `system_height_mm`.
757    pub systems_per_page: Option<usize>,
758    pub page_numbering: PageNumbering,
759    #[serde(default)]
760    pub final_page_policy: FinalPagePolicy,
761    #[serde(default)]
762    pub color_policy: PrintColorPolicy,
763    #[serde(default)]
764    pub crop_mark_policy: CropMarkPolicy,
765    #[serde(default)]
766    pub glyph_resources: GlyphResourcePolicy,
767    #[serde(default)]
768    pub publication: PublicationConfig,
769    #[serde(default)]
770    pub part_layout: PartLayoutPolicy,
771    /// Physical measure ranges that must not be split across systems.
772    #[serde(default)]
773    pub keep_together: Vec<KeepTogetherRange>,
774}
775
776impl Default for PrintConfig {
777    fn default() -> Self {
778        Self {
779            paper_size: PaperSize::A4,
780            orientation: PageOrientation::Portrait,
781            margin_top_mm: 16.0,
782            margin_right_mm: 14.0,
783            margin_bottom_mm: 16.0,
784            margin_left_mm: 14.0,
785            bleed_top_mm: 0.0,
786            bleed_right_mm: 0.0,
787            bleed_bottom_mm: 0.0,
788            bleed_left_mm: 0.0,
789            safe_top_mm: 0.0,
790            safe_right_mm: 0.0,
791            safe_bottom_mm: 0.0,
792            safe_left_mm: 0.0,
793            system_height_mm: 24.0,
794            scale: 1.0,
795            measures_per_system: 4,
796            first_system_measures: None,
797            pickup_policy: PickupPolicy::Auto,
798            notation_break_policy: NotationBreakPolicy::Preserve,
799            systems_per_page: None,
800            page_numbering: PageNumbering::OneBased,
801            final_page_policy: FinalPagePolicy::AllowSingleSystem,
802            color_policy: PrintColorPolicy::Monochrome,
803            crop_mark_policy: CropMarkPolicy::None,
804            glyph_resources: GlyphResourcePolicy::BuiltInVector,
805            publication: PublicationConfig::default(),
806            part_layout: PartLayoutPolicy::FullScore,
807            keep_together: Vec::new(),
808        }
809    }
810}
811
812/// Version of the built-in host-neutral print preset data.
813pub const PRINT_PRESET_SCHEMA_VERSION: u16 = 1;
814/// Version of the serialized host-neutral print layout contract.
815pub const PRINT_LAYOUT_CONTRACT_VERSION: u16 = 27;
816
817/// Reproducible starting configurations for common publication workflows.
818#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
819pub enum PrintPreset {
820    A4Score,
821    LetterScore,
822    A4Part { part_index: usize },
823    LetterPart { part_index: usize },
824}
825
826impl PrintPreset {
827    /// Build a configuration without consulting host defaults or installed resources.
828    pub fn config(self) -> PrintConfig {
829        let (paper_size, part_layout) = match self {
830            Self::A4Score => (PaperSize::A4, PartLayoutPolicy::FullScore),
831            Self::LetterScore => (PaperSize::Letter, PartLayoutPolicy::FullScore),
832            Self::A4Part { part_index } => (
833                PaperSize::A4,
834                PartLayoutPolicy::ExtractedPart { part_index },
835            ),
836            Self::LetterPart { part_index } => (
837                PaperSize::Letter,
838                PartLayoutPolicy::ExtractedPart { part_index },
839            ),
840        };
841        PrintConfig {
842            paper_size,
843            part_layout,
844            ..PrintConfig::default()
845        }
846    }
847
848    /// Build this preset with the publication title-page policy explicitly selected.
849    pub fn config_with_title_page(self, title_page: bool) -> PrintConfig {
850        let mut config = self.config();
851        config.publication.title_page = title_page;
852        config
853    }
854
855    /// Return the schema version for this preset data.
856    pub const fn schema_version(self) -> u16 {
857        PRINT_PRESET_SCHEMA_VERSION
858    }
859}
860
861/// A logical system placed on a page.
862#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
863pub struct SystemLayout {
864    pub address: SystemAddress,
865    pub system_index: usize,
866    pub page_index: usize,
867    pub measure_indices: Vec<usize>,
868    /// Physical intervals represented by the system, including multi-rest spans.
869    #[serde(default)]
870    pub measure_spans: Vec<MeasureSpan>,
871    /// Span segments touching this system, with start/end ownership for host continuation marks.
872    #[serde(default)]
873    pub span_segments: Vec<SpanSegment>,
874    /// Repeat, ending, navigation, and rehearsal marks belonging to this system.
875    #[serde(default)]
876    pub measure_marks: Vec<MeasureMark>,
877    pub top_mm: f32,
878    pub height_mm: f32,
879    pub break_reason: BreakReason,
880}
881
882/// Stable address of a page within one print-layout result.
883#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
884pub struct PageAddress {
885    pub page_index: usize,
886}
887
888/// Stable address of a system, including global and page-local positions.
889#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
890pub struct SystemAddress {
891    pub system_index: usize,
892    pub page_index: usize,
893    pub index_on_page: usize,
894}
895
896/// Physical measure interval represented by one visual measure slot.
897#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
898pub struct MeasureSpan {
899    pub first_measure: usize,
900    pub last_measure: usize,
901}
902
903/// A span's intersection with one printed system.
904#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
905pub struct SpanSegment {
906    pub span_index: usize,
907    pub starts_here: bool,
908    pub ends_here: bool,
909}
910
911/// A cross-system span's intersection with one printed page.
912#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
913pub struct PageSpanSegment {
914    pub span_index: usize,
915    pub starts_here: bool,
916    pub ends_here: bool,
917}
918
919/// Host-neutral notation marks attached to one physical measure in a print system.
920///
921/// This is presentation metadata only: playback order remains the responsibility of
922/// [`acorde_core::measure_sequence`].
923#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
924pub struct MeasureMark {
925    pub measure_index: usize,
926    pub repeat_start: bool,
927    pub repeat_end: bool,
928    pub volta_number: Option<u8>,
929    pub volta_kind: Option<String>,
930    pub navigation: Option<String>,
931    pub rehearsal: Option<String>,
932    /// Explicit and legacy measure-level text in deterministic source order.
933    #[serde(default)]
934    pub text_annotations: Vec<StyledText>,
935}
936
937/// Explains why a system or page ended at its final measure.
938#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
939pub enum BreakReason {
940    MeasureCapacity,
941    ExplicitSystemBreak,
942    ExplicitPageBreak,
943    PageCapacity,
944    EndOfScore,
945    TitlePage,
946}
947
948/// One page in a [`PrintLayoutResult`].
949#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
950pub struct PageLayout {
951    pub address: PageAddress,
952    pub page_index: usize,
953    pub page_number: Option<usize>,
954    #[serde(default)]
955    pub color_policy: PrintColorPolicy,
956    #[serde(default)]
957    pub crop_mark_policy: CropMarkPolicy,
958    #[serde(default)]
959    pub glyph_resources: GlyphResourcePolicy,
960    #[serde(default)]
961    pub publication: PagePublication,
962    pub width_mm: f32,
963    pub height_mm: f32,
964    pub content_width_mm: f32,
965    pub content_height_mm: f32,
966    pub bleed_top_mm: f32,
967    pub bleed_right_mm: f32,
968    pub bleed_bottom_mm: f32,
969    pub bleed_left_mm: f32,
970    pub systems: Vec<SystemLayout>,
971    /// Span intersections on this page, aggregated from its systems.
972    #[serde(default)]
973    pub span_segments: Vec<PageSpanSegment>,
974    /// Repeat and navigation marks on this page, in physical measure order.
975    #[serde(default)]
976    pub measure_marks: Vec<MeasureMark>,
977    pub break_reason: BreakReason,
978}
979
980/// A host-neutral page export descriptor.
981///
982/// This is intentionally geometry and metadata only. Hosts may turn each descriptor into
983/// SVG, PDF, or another artifact without making this crate depend on a file format or UI API.
984#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
985pub struct PageArtifact {
986    pub address: PageAddress,
987    pub page_index: usize,
988    pub page_number: Option<usize>,
989    pub width_mm: f32,
990    pub height_mm: f32,
991    pub content_width_mm: f32,
992    pub content_height_mm: f32,
993    pub measure_span: Option<MeasureSpan>,
994    pub diagnostics: Vec<PageArtifactDiagnostic>,
995    pub layout: PageLayout,
996}
997
998/// Typed, host-neutral diagnostics attached to a page export descriptor.
999#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1000pub enum PageArtifactDiagnostic {
1001    /// The page uses a host-owned glyph resource and must be resolved by the exporter.
1002    GlyphResourceRequired,
1003    /// Host-provided glyph extents exceed the page content area on one or more sides.
1004    GlyphOverflow {
1005        left: bool,
1006        top: bool,
1007        right: bool,
1008        bottom: bool,
1009    },
1010    /// A span continues across a page boundary and needs a continuation mark in the host.
1011    SpanContinuation {
1012        span_index: usize,
1013        starts_here: bool,
1014        ends_here: bool,
1015    },
1016}
1017
1018impl PageLayout {
1019    /// Return the inclusive physical measure range represented on this page.
1020    pub fn measure_span(&self) -> Option<MeasureSpan> {
1021        let mut spans = self
1022            .systems
1023            .iter()
1024            .flat_map(|system| system.measure_spans.iter().copied());
1025        let first = spans.next()?;
1026        Some(spans.fold(first, |range, span| MeasureSpan {
1027            first_measure: range.first_measure.min(span.first_measure),
1028            last_measure: range.last_measure.max(span.last_measure),
1029        }))
1030    }
1031
1032    /// Whether a span continues into or out of another printed page.
1033    pub fn has_span_continuation(&self) -> bool {
1034        self.span_segments
1035            .iter()
1036            .any(|segment| !segment.starts_here || !segment.ends_here)
1037    }
1038
1039    /// Build deterministic page diagnostics from optional host-computed glyph extents.
1040    ///
1041    /// Extents are expressed relative to the page content origin. This keeps overflow
1042    /// detection independent of fonts and renderers while allowing a host to report a
1043    /// clipping risk before producing an SVG, PDF, or print artifact.
1044    pub fn artifact_diagnostics(
1045        &self,
1046        glyph_extents: Option<GlyphExtents>,
1047    ) -> Vec<PageArtifactDiagnostic> {
1048        let mut diagnostics = Vec::new();
1049        if matches!(self.glyph_resources, GlyphResourcePolicy::HostProvided(_)) {
1050            diagnostics.push(PageArtifactDiagnostic::GlyphResourceRequired);
1051        }
1052        if let Some(extents) = glyph_extents {
1053            let overflow = PageArtifactDiagnostic::GlyphOverflow {
1054                left: extents.left_mm < 0.0,
1055                top: extents.top_mm < 0.0,
1056                right: extents.right_mm > self.content_width_mm,
1057                bottom: extents.bottom_mm > self.content_height_mm,
1058            };
1059            if let PageArtifactDiagnostic::GlyphOverflow {
1060                left,
1061                top,
1062                right,
1063                bottom,
1064            } = overflow
1065                && (left || top || right || bottom)
1066            {
1067                diagnostics.push(overflow);
1068            }
1069        }
1070        diagnostics.extend(
1071            self.span_segments
1072                .iter()
1073                .filter(|segment| !segment.starts_here || !segment.ends_here)
1074                .map(|segment| PageArtifactDiagnostic::SpanContinuation {
1075                    span_index: segment.span_index,
1076                    starts_here: segment.starts_here,
1077                    ends_here: segment.ends_here,
1078                }),
1079        );
1080        diagnostics
1081    }
1082}
1083
1084/// Deterministic page/system geometry for a score.
1085#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1086pub struct PrintLayoutResult {
1087    pub contract_version: u16,
1088    pub pages: Vec<PageLayout>,
1089}
1090
1091impl PrintLayoutResult {
1092    /// Validate page and system addresses before consuming a serialized layout.
1093    ///
1094    /// Layouts produced by [`compute_print_layout`] satisfy this contract. The explicit
1095    /// validation is useful for hosts that persist or transport `PrintLayoutResult` values.
1096    pub fn validate(&self) -> Result<(), PrintLayoutError> {
1097        if self.contract_version != PRINT_LAYOUT_CONTRACT_VERSION {
1098            return Err(PrintLayoutError::UnsupportedContractVersion {
1099                found: self.contract_version,
1100            });
1101        }
1102        let mut expected_system_index = 0;
1103        let mut previous_page_number = None;
1104        let mut numbered_pages = None;
1105        for (page_index, page) in self.pages.iter().enumerate() {
1106            if page.page_index != page_index || page.address.page_index != page_index {
1107                return Err(PrintLayoutError::InvalidPageAddress { page_index });
1108            }
1109            if !page.width_mm.is_finite()
1110                || !page.height_mm.is_finite()
1111                || page.width_mm <= 0.0
1112                || page.height_mm <= 0.0
1113                || !page.content_width_mm.is_finite()
1114                || !page.content_height_mm.is_finite()
1115                || page.content_width_mm <= 0.0
1116                || page.content_height_mm <= 0.0
1117                || page.content_width_mm > page.width_mm
1118                || page.content_height_mm > page.height_mm
1119                || !page.bleed_top_mm.is_finite()
1120                || !page.bleed_right_mm.is_finite()
1121                || !page.bleed_bottom_mm.is_finite()
1122                || !page.bleed_left_mm.is_finite()
1123                || page.bleed_top_mm < 0.0
1124                || page.bleed_right_mm < 0.0
1125                || page.bleed_bottom_mm < 0.0
1126                || page.bleed_left_mm < 0.0
1127            {
1128                return Err(PrintLayoutError::InvalidPageGeometry { page_index });
1129            }
1130            let is_title_break = page.break_reason == BreakReason::TitlePage;
1131            if is_title_break != page.publication.is_title_page
1132                || (is_title_break && (page_index != 0 || !page.systems.is_empty()))
1133            {
1134                return Err(PrintLayoutError::InvalidTitlePage { page_index });
1135            }
1136            match page.page_number {
1137                Some(page_number)
1138                    if page_number == 0
1139                        || numbered_pages == Some(false)
1140                        || page_index.checked_add(1) != Some(page_number)
1141                        || previous_page_number.is_some_and(|previous| page_number <= previous) =>
1142                {
1143                    return Err(PrintLayoutError::InvalidPageNumber { page_index });
1144                }
1145                Some(page_number) => {
1146                    numbered_pages = Some(true);
1147                    previous_page_number = Some(page_number);
1148                }
1149                None if numbered_pages == Some(true) => {
1150                    return Err(PrintLayoutError::InvalidPageNumber { page_index });
1151                }
1152                None => numbered_pages = Some(false),
1153            }
1154            for (index_on_page, system) in page.systems.iter().enumerate() {
1155                if system.page_index != page_index
1156                    || system.address.page_index != page_index
1157                    || system.address.index_on_page != index_on_page
1158                    || system.system_index != expected_system_index
1159                    || system.address.system_index != expected_system_index
1160                {
1161                    return Err(PrintLayoutError::InvalidSystemAddress {
1162                        page_index,
1163                        index_on_page,
1164                        system_index: expected_system_index,
1165                    });
1166                }
1167                if !system.top_mm.is_finite()
1168                    || system.top_mm < 0.0
1169                    || !system.height_mm.is_finite()
1170                    || system.height_mm <= 0.0
1171                {
1172                    return Err(PrintLayoutError::InvalidSystemGeometry {
1173                        page_index,
1174                        index_on_page,
1175                    });
1176                }
1177                expected_system_index += 1;
1178            }
1179        }
1180        Ok(())
1181    }
1182
1183    /// Retrieve one page artifact by its stable address without recomputing layout.
1184    pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
1185        self.pages
1186            .get(address.page_index)
1187            .filter(|page| page.address == address)
1188    }
1189
1190    /// Export validated page descriptors for host renderers and archival backends.
1191    ///
1192    /// The returned vector preserves physical page order. No filesystem, PDF backend, font
1193    /// loader, or renderer-specific object is involved; hosts can serialize or render each
1194    /// descriptor independently. Validation happens before any descriptor is returned.
1195    pub fn export_page_artifacts(&self) -> Result<Vec<PageArtifact>, PrintLayoutError> {
1196        self.validate()?;
1197        Ok(self
1198            .pages
1199            .iter()
1200            .map(|page| PageArtifact {
1201                address: page.address,
1202                page_index: page.page_index,
1203                page_number: page.page_number,
1204                width_mm: page.width_mm,
1205                height_mm: page.height_mm,
1206                content_width_mm: page.content_width_mm,
1207                content_height_mm: page.content_height_mm,
1208                measure_span: page.measure_span(),
1209                diagnostics: page.artifact_diagnostics(None),
1210                layout: page.clone(),
1211            })
1212            .collect())
1213    }
1214}
1215
1216#[derive(Debug, thiserror::Error, PartialEq)]
1217pub enum PrintLayoutError {
1218    #[error("paper dimensions must be finite and greater than zero")]
1219    InvalidPaperDimensions,
1220    #[error("margins must be finite and non-negative")]
1221    InvalidMargins,
1222    #[error("system height must be finite and greater than zero")]
1223    InvalidSystemHeight,
1224    #[error("print scale must be finite and greater than zero")]
1225    InvalidScale,
1226    #[error("margins leave no usable page area")]
1227    NoUsablePageArea,
1228    #[error("keep-together range is outside the score or reversed")]
1229    InvalidKeepTogetherRange,
1230    #[error("keep-together range exceeds the measures-per-system capacity")]
1231    KeepTogetherExceedsSystemCapacity,
1232    #[error("keep-together range conflicts with an explicit system or page break")]
1233    KeepTogetherConflictsWithExplicitBreak,
1234    #[error("repeat section exceeds the systems-per-page capacity")]
1235    RepeatRangeExceedsPageCapacity,
1236    #[error("extracted part index is outside the score")]
1237    InvalidPartIndex,
1238    #[error("publication line height must be finite and greater than zero")]
1239    InvalidPublicationLineHeight,
1240    #[error("host-provided glyph resource key must not be empty")]
1241    InvalidGlyphResourceKey,
1242    #[error("unsupported print layout contract version {found}")]
1243    UnsupportedContractVersion { found: u16 },
1244    #[error("page {page_index} has an inconsistent stable address")]
1245    InvalidPageAddress { page_index: usize },
1246    #[error("page {page_index} has an invalid or non-monotonic page number")]
1247    InvalidPageNumber { page_index: usize },
1248    #[error("page {page_index} has inconsistent title-page metadata")]
1249    InvalidTitlePage { page_index: usize },
1250    #[error(
1251        "system {system_index} at page {page_index}, position {index_on_page} has an inconsistent stable address"
1252    )]
1253    InvalidSystemAddress {
1254        page_index: usize,
1255        index_on_page: usize,
1256        system_index: usize,
1257    },
1258    #[error("page {page_index} has invalid physical geometry")]
1259    InvalidPageGeometry { page_index: usize },
1260    #[error("system at page {page_index}, position {index_on_page} has invalid physical geometry")]
1261    InvalidSystemGeometry {
1262        page_index: usize,
1263        index_on_page: usize,
1264    },
1265}
1266
1267fn apply_keep_together(
1268    score: &Score,
1269    mut rows: Vec<crate::RowLayout>,
1270    ranges: &[KeepTogetherRange],
1271    capacity: usize,
1272) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
1273    let measure_count = score
1274        .parts
1275        .first()
1276        .and_then(|part| part.staves.first())
1277        .map(|staff| staff.measures.len())
1278        .unwrap_or(0);
1279    for range in ranges {
1280        let length = range
1281            .last_measure
1282            .checked_sub(range.first_measure)
1283            .and_then(|length| length.checked_add(1));
1284        if range.first_measure > range.last_measure || range.last_measure >= measure_count {
1285            return Err(PrintLayoutError::InvalidKeepTogetherRange);
1286        }
1287        if length.is_none_or(|length| length > capacity) {
1288            return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1289        }
1290        for measure_index in range.first_measure..range.last_measure {
1291            let has_break = score
1292                .parts
1293                .iter()
1294                .flat_map(|part| part.staves.iter())
1295                .filter_map(|staff| staff.measures.get(measure_index))
1296                .any(|measure| measure.system_break || measure.page_break);
1297            if has_break {
1298                return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
1299            }
1300        }
1301
1302        // Split at the range boundaries before merging rows. This allows a range that
1303        // crosses an existing system boundary to be reflowed without pulling unrelated
1304        // measures into the merged system.
1305        let mut split_rows = Vec::with_capacity(rows.len() + 2);
1306        for row in rows {
1307            let mut cuts = vec![0, row.measure_indices.len()];
1308            if let Some(position) = row
1309                .measure_indices
1310                .iter()
1311                .position(|&index| index == range.first_measure)
1312            {
1313                cuts.push(position);
1314            }
1315            if let Some(position) = row
1316                .measure_indices
1317                .iter()
1318                .position(|&index| index == range.last_measure)
1319            {
1320                cuts.push(position + 1);
1321            }
1322            cuts.sort_unstable();
1323            cuts.dedup();
1324            for window in cuts.windows(2) {
1325                if window[0] < window[1] {
1326                    split_rows.push(crate::RowLayout {
1327                        measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
1328                    });
1329                }
1330            }
1331        }
1332        rows = split_rows;
1333
1334        let first_row = rows
1335            .iter()
1336            .position(|row| row.measure_indices.contains(&range.first_measure));
1337        let last_row = rows
1338            .iter()
1339            .position(|row| row.measure_indices.contains(&range.last_measure));
1340        let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
1341            return Err(PrintLayoutError::InvalidKeepTogetherRange);
1342        };
1343
1344        if first_row != last_row {
1345            let merged: Vec<usize> = rows[first_row..=last_row]
1346                .iter()
1347                .flat_map(|row| row.measure_indices.iter().copied())
1348                .collect();
1349            if merged.len() > capacity {
1350                return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1351            }
1352            rows.splice(
1353                first_row..=last_row,
1354                [crate::RowLayout {
1355                    measure_indices: merged,
1356                }],
1357            );
1358        }
1359
1360        let row_index = rows
1361            .iter()
1362            .position(|row| row.measure_indices.contains(&range.first_measure))
1363            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1364        let row = rows.remove(row_index);
1365        let start = row
1366            .measure_indices
1367            .iter()
1368            .position(|&index| index == range.first_measure)
1369            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1370        let end = row
1371            .measure_indices
1372            .iter()
1373            .position(|&index| index == range.last_measure)
1374            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1375        let mut replacement = Vec::new();
1376        if start > 0 {
1377            replacement.push(crate::RowLayout {
1378                measure_indices: row.measure_indices[..start].to_vec(),
1379            });
1380        }
1381        replacement.push(crate::RowLayout {
1382            measure_indices: row.measure_indices[start..=end].to_vec(),
1383        });
1384        if end + 1 < row.measure_indices.len() {
1385            replacement.push(crate::RowLayout {
1386                measure_indices: row.measure_indices[end + 1..].to_vec(),
1387            });
1388        }
1389        rows.splice(row_index..row_index, replacement);
1390    }
1391    Ok(rows)
1392}
1393
1394fn has_first_measure_pickup(score: &Score) -> bool {
1395    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1396        return false;
1397    };
1398    let Some(measure) = staff.measures.first() else {
1399        return false;
1400    };
1401    let expected = measure
1402        .time_sig
1403        .as_ref()
1404        .unwrap_or(&score.settings.time_signature)
1405        .total_beats();
1406    let actual = measure
1407        .voices
1408        .iter()
1409        .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
1410        .fold(0.0, f64::max);
1411    actual > 1e-9 && actual + 1e-9 < expected
1412}
1413
1414fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
1415    let measure_count = score
1416        .parts
1417        .first()
1418        .and_then(|part| part.staves.first())
1419        .map(|staff| staff.measures.len())
1420        .unwrap_or(0);
1421    measure_indices
1422        .iter()
1423        .filter_map(|&first_measure| {
1424            if first_measure >= measure_count {
1425                return None;
1426            }
1427            let count = score
1428                .parts
1429                .iter()
1430                .flat_map(|part| part.staves.iter())
1431                .filter_map(|staff| staff.measures.get(first_measure))
1432                .filter_map(|measure| measure.multi_rest_count)
1433                .map(usize::from)
1434                .max()
1435                .unwrap_or(1)
1436                .max(1);
1437            Some(MeasureSpan {
1438                first_measure,
1439                last_measure: first_measure
1440                    .saturating_add(count.saturating_sub(1))
1441                    .min(measure_count.saturating_sub(1)),
1442            })
1443        })
1444        .collect()
1445}
1446
1447fn span_bounds(span: &SpanMark) -> (usize, usize) {
1448    match span {
1449        SpanMark::Hairpin { start, end, .. }
1450        | SpanMark::Ottava { start, end, .. }
1451        | SpanMark::Pedal { start, end }
1452        | SpanMark::Slur { start, end }
1453        | SpanMark::TrillLine { start, end }
1454        | SpanMark::Glissando { start, end }
1455        | SpanMark::Harmony { start, end, .. } => (
1456            start.measure.min(end.measure),
1457            start.measure.max(end.measure),
1458        ),
1459    }
1460}
1461
1462fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
1463    let (Some(&first_measure), Some(&last_measure)) =
1464        (measure_indices.first(), measure_indices.last())
1465    else {
1466        return Vec::new();
1467    };
1468    spans
1469        .iter()
1470        .enumerate()
1471        .filter_map(|(span_index, span)| {
1472            let (start_measure, end_measure) = span_bounds(span);
1473            (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
1474                span_index,
1475                starts_here: (first_measure..=last_measure).contains(&start_measure),
1476                ends_here: (first_measure..=last_measure).contains(&end_measure),
1477            })
1478        })
1479        .collect()
1480}
1481
1482fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
1483    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1484        return Vec::new();
1485    };
1486    measure_indices
1487        .iter()
1488        .filter_map(|&measure_index| {
1489            let measure = staff.measures.get(measure_index)?;
1490            let repeat_start = matches!(
1491                measure.barline_left,
1492                Barline::RepeatStart | Barline::RepeatBoth
1493            );
1494            let repeat_end = matches!(
1495                measure.barline_right,
1496                Barline::RepeatEnd | Barline::RepeatBoth
1497            );
1498            let text_annotations = measure_text_entries(measure);
1499            let has_mark = repeat_start
1500                || repeat_end
1501                || measure.volta.is_some()
1502                || measure.navigation.is_some()
1503                || measure.rehearsal.is_some()
1504                || !text_annotations.is_empty();
1505            has_mark.then(|| MeasureMark {
1506                measure_index,
1507                repeat_start,
1508                repeat_end,
1509                volta_number: measure.volta.as_ref().map(|volta| volta.number),
1510                volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
1511                navigation: measure.navigation.clone(),
1512                rehearsal: measure.rehearsal.clone(),
1513                text_annotations,
1514            })
1515        })
1516        .collect()
1517}
1518
1519fn measure_text_entries(measure: &acorde_core::Measure) -> Vec<StyledText> {
1520    let mut entries = measure.texts.clone();
1521    for (style, text) in [
1522        (TextStyle::Generic, measure.tempo_text.as_deref()),
1523        (TextStyle::RehearsalMark, measure.rehearsal.as_deref()),
1524        (TextStyle::Generic, measure.navigation.as_deref()),
1525        (TextStyle::Expression, measure.expression_text.as_deref()),
1526    ] {
1527        let Some(text) = text else {
1528            continue;
1529        };
1530        if entries
1531            .iter()
1532            .any(|entry| entry.style == style && entry.text == text)
1533        {
1534            continue;
1535        }
1536        entries.push(StyledText {
1537            style,
1538            text: text.to_owned(),
1539            placement: None,
1540            offset_x: None,
1541            offset_y: None,
1542            relative_x: None,
1543            relative_y: None,
1544        });
1545    }
1546    entries
1547}
1548
1549fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1550    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1551        return Vec::new();
1552    };
1553    let mut ranges = Vec::new();
1554    let mut start = None;
1555    for (index, measure) in staff.measures.iter().enumerate() {
1556        let Some(volta) = measure.volta.as_ref() else {
1557            continue;
1558        };
1559        if matches!(volta.kind.as_str(), "begin" | "begin_end") {
1560            start = Some(index);
1561        }
1562        if matches!(volta.kind.as_str(), "end" | "begin_end")
1563            && let Some(first_measure) = start.take()
1564        {
1565            ranges.push(KeepTogetherRange {
1566                first_measure,
1567                last_measure: index,
1568            });
1569        }
1570    }
1571    ranges
1572}
1573
1574fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1575    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1576        return Vec::new();
1577    };
1578    let mut ranges = Vec::new();
1579    let mut start = None;
1580    for (index, measure) in staff.measures.iter().enumerate() {
1581        if matches!(
1582            measure.barline_left,
1583            Barline::RepeatStart | Barline::RepeatBoth
1584        ) {
1585            start = Some(index);
1586        }
1587        if matches!(
1588            measure.barline_right,
1589            Barline::RepeatEnd | Barline::RepeatBoth
1590        ) {
1591            ranges.push(KeepTogetherRange {
1592                first_measure: start.take().unwrap_or(0),
1593                last_measure: index,
1594            });
1595        }
1596    }
1597    ranges
1598}
1599
1600fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
1601    repeat_ranges(score)
1602        .into_iter()
1603        .filter_map(|range| {
1604            let first = rows
1605                .iter()
1606                .position(|row| row.measure_indices.contains(&range.first_measure))?;
1607            let last = rows
1608                .iter()
1609                .position(|row| row.measure_indices.contains(&range.last_measure))?;
1610            Some((first, last))
1611        })
1612        .collect()
1613}
1614
1615fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
1616    let mut segments = Vec::new();
1617    for system in systems {
1618        for segment in &system.span_segments {
1619            if let Some(existing) = segments
1620                .iter_mut()
1621                .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
1622            {
1623                existing.ends_here |= segment.ends_here;
1624            } else {
1625                segments.push(PageSpanSegment {
1626                    span_index: segment.span_index,
1627                    starts_here: segment.starts_here,
1628                    ends_here: segment.ends_here,
1629                });
1630            }
1631        }
1632    }
1633    segments
1634}
1635
1636fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
1637    systems
1638        .iter()
1639        .flat_map(|system| system.measure_marks.iter().cloned())
1640        .collect()
1641}
1642
1643fn page_publication(
1644    score: &Score,
1645    measure_score: &Score,
1646    config: &PrintConfig,
1647    systems: &[SystemLayout],
1648    is_title_page: bool,
1649    page_number: Option<usize>,
1650) -> PagePublication {
1651    let metadata = &score.metadata;
1652    let part_labels = if config.publication.show_part_names {
1653        let parts = match config.part_layout {
1654            PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
1655            PartLayoutPolicy::ExtractedPart { part_index } => score
1656                .parts
1657                .get(part_index)
1658                .into_iter()
1659                .enumerate()
1660                .map(|(index, part)| (part_index + index, part))
1661                .collect(),
1662        };
1663        parts
1664            .into_iter()
1665            .map(|(part_index, part)| PartLabel {
1666                part_index,
1667                name: part.name.clone(),
1668                short_name: part.short_name.clone(),
1669            })
1670            .collect()
1671    } else {
1672        Vec::new()
1673    };
1674    let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
1675        score
1676            .part_groups
1677            .iter()
1678            .map(|group| PartGroupMark {
1679                first_part: group.first_part,
1680                last_part: group.last_part,
1681                symbol: group.symbol.clone(),
1682                barlines_connect: group.barlines_connect,
1683            })
1684            .collect()
1685    } else {
1686        Vec::new()
1687    };
1688    let measure_numbers = if config.publication.show_measure_numbers {
1689        let staff = measure_score
1690            .parts
1691            .first()
1692            .and_then(|part| part.staves.first());
1693        systems
1694            .iter()
1695            .flat_map(|system| system.measure_indices.iter().copied())
1696            .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
1697            .map(|measure| measure.number)
1698            .collect()
1699    } else {
1700        Vec::new()
1701    };
1702    let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1703    let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
1704        (paper_height, paper_width)
1705    } else {
1706        (paper_width, paper_height)
1707    };
1708    let mut text_blocks = Vec::new();
1709    if !is_title_page
1710        && let Some(text) = config
1711            .publication
1712            .header_text
1713            .as_ref()
1714            .or(config.publication.running_title.as_ref())
1715    {
1716        text_blocks.push(PublicationTextBlock {
1717            role: PublicationTextRole::Header,
1718            text: text.clone(),
1719            x_mm: config.margin_left_mm + config.safe_left_mm,
1720            y_mm: config.margin_top_mm,
1721            width_mm: page_width
1722                - config.margin_left_mm
1723                - config.margin_right_mm
1724                - config.safe_left_mm
1725                - config.safe_right_mm,
1726            height_mm: config.publication.line_height_mm,
1727            alignment: config.publication.header_alignment,
1728        });
1729    }
1730    if let Some(text) = config.publication.footer_text.as_ref() {
1731        text_blocks.push(PublicationTextBlock {
1732            role: PublicationTextRole::Footer,
1733            text: text.clone(),
1734            x_mm: config.margin_left_mm + config.safe_left_mm,
1735            y_mm: page_height - config.margin_bottom_mm,
1736            width_mm: page_width
1737                - config.margin_left_mm
1738                - config.margin_right_mm
1739                - config.safe_left_mm
1740                - config.safe_right_mm,
1741            height_mm: config.publication.line_height_mm,
1742            alignment: config.publication.footer_alignment,
1743        });
1744    }
1745    if config.publication.page_number_in_footer {
1746        if let Some(page_number) = page_number {
1747            let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1748            let (page_width, page_height) =
1749                if matches!(config.orientation, PageOrientation::Landscape) {
1750                    (paper_height, paper_width)
1751                } else {
1752                    (paper_width, paper_height)
1753                };
1754            text_blocks.push(PublicationTextBlock {
1755                role: PublicationTextRole::Footer,
1756                text: page_number.to_string(),
1757                x_mm: config.margin_left_mm + config.safe_left_mm,
1758                y_mm: page_height - config.margin_bottom_mm,
1759                width_mm: page_width
1760                    - config.margin_left_mm
1761                    - config.margin_right_mm
1762                    - config.safe_left_mm
1763                    - config.safe_right_mm,
1764                height_mm: config.publication.line_height_mm,
1765                alignment: config.publication.footer_alignment,
1766            });
1767        }
1768    }
1769    if is_title_page {
1770        let content_height = page_height
1771            - config.margin_top_mm
1772            - config.margin_bottom_mm
1773            - config.safe_top_mm
1774            - config.safe_bottom_mm;
1775        let title_x = config.margin_left_mm + config.safe_left_mm;
1776        let title_width = page_width
1777            - config.margin_left_mm
1778            - config.margin_right_mm
1779            - config.safe_left_mm
1780            - config.safe_right_mm;
1781        let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
1782        if !metadata.title.trim().is_empty() {
1783            text_blocks.push(PublicationTextBlock {
1784                role: PublicationTextRole::Title,
1785                text: metadata.title.clone(),
1786                x_mm: title_x,
1787                y_mm: title_y,
1788                width_mm: title_width,
1789                height_mm: config.publication.line_height_mm,
1790                alignment: config.publication.title_alignment,
1791            });
1792        }
1793        if !metadata.movement_title.trim().is_empty() {
1794            text_blocks.push(PublicationTextBlock {
1795                role: PublicationTextRole::Subtitle,
1796                text: metadata.movement_title.clone(),
1797                x_mm: title_x,
1798                y_mm: title_y + config.publication.line_height_mm * 2.5,
1799                width_mm: title_width,
1800                height_mm: config.publication.line_height_mm,
1801                alignment: config.publication.title_alignment,
1802            });
1803        }
1804        let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
1805            (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
1806                format!("{composer} / {lyricist}")
1807            }
1808            (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
1809            (_, lyricist) => lyricist.to_string(),
1810        };
1811        if !credit.is_empty() {
1812            text_blocks.push(PublicationTextBlock {
1813                role: PublicationTextRole::Credit,
1814                text: credit,
1815                x_mm: title_x,
1816                y_mm: title_y + config.publication.line_height_mm * 5.0,
1817                width_mm: title_width,
1818                height_mm: config.publication.line_height_mm,
1819                alignment: config.publication.title_alignment,
1820            });
1821        }
1822        if !metadata.copyright.trim().is_empty() {
1823            text_blocks.push(PublicationTextBlock {
1824                role: PublicationTextRole::Copyright,
1825                text: metadata.copyright.clone(),
1826                x_mm: title_x,
1827                y_mm: page_height - config.margin_bottom_mm,
1828                width_mm: title_width,
1829                height_mm: config.publication.line_height_mm,
1830                alignment: config.publication.title_alignment,
1831            });
1832        }
1833    }
1834    PagePublication {
1835        is_title_page,
1836        title: metadata.title.clone(),
1837        movement_title: metadata.movement_title.clone(),
1838        composer: metadata.composer.clone(),
1839        lyricist: metadata.lyricist.clone(),
1840        copyright: metadata.copyright.clone(),
1841        running_title: config.publication.running_title.clone(),
1842        score_texts: score.texts.clone(),
1843        part_labels,
1844        part_groups,
1845        measure_numbers,
1846        text_blocks,
1847    }
1848}
1849
1850#[allow(clippy::too_many_arguments)]
1851fn build_page_layout(
1852    score: &Score,
1853    layout_score: &Score,
1854    config: &PrintConfig,
1855    systems: Vec<SystemLayout>,
1856    page_index: usize,
1857    page_number: Option<usize>,
1858    width_mm: f32,
1859    height_mm: f32,
1860    content_width_mm: f32,
1861    content_height_mm: f32,
1862    break_reason: BreakReason,
1863    is_title_page: bool,
1864) -> PageLayout {
1865    let publication = page_publication(
1866        score,
1867        layout_score,
1868        config,
1869        &systems,
1870        is_title_page,
1871        page_number,
1872    );
1873    let span_segments = if is_title_page {
1874        Vec::new()
1875    } else {
1876        page_span_segments(&systems)
1877    };
1878    let measure_marks = if is_title_page {
1879        Vec::new()
1880    } else {
1881        page_measure_marks(&systems)
1882    };
1883    PageLayout {
1884        address: PageAddress { page_index },
1885        page_index,
1886        page_number,
1887        color_policy: config.color_policy,
1888        crop_mark_policy: config.crop_mark_policy,
1889        glyph_resources: config.glyph_resources.clone(),
1890        publication,
1891        width_mm,
1892        height_mm,
1893        content_width_mm,
1894        content_height_mm,
1895        bleed_top_mm: config.bleed_top_mm,
1896        bleed_right_mm: config.bleed_right_mm,
1897        bleed_bottom_mm: config.bleed_bottom_mm,
1898        bleed_left_mm: config.bleed_left_mm,
1899        span_segments,
1900        measure_marks,
1901        systems,
1902        break_reason,
1903    }
1904}
1905
1906fn score_for_part_layout(
1907    score: &Score,
1908    policy: PartLayoutPolicy,
1909) -> Result<Score, PrintLayoutError> {
1910    let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
1911        return Ok(score.clone());
1912    };
1913    let Some(part) = score.parts.get(part_index) else {
1914        return Err(PrintLayoutError::InvalidPartIndex);
1915    };
1916    let mut selected = score.clone();
1917    selected.parts = vec![part.clone()];
1918    selected.part_groups.clear();
1919    Ok(selected)
1920}
1921
1922fn validate_print_config(config: &PrintConfig) -> Result<(f32, f32, f32), PrintLayoutError> {
1923    let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
1924    if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
1925        return Err(PrintLayoutError::InvalidPaperDimensions);
1926    }
1927    if matches!(config.orientation, PageOrientation::Landscape) {
1928        std::mem::swap(&mut width_mm, &mut height_mm);
1929    }
1930
1931    let margins = [
1932        config.margin_top_mm,
1933        config.margin_right_mm,
1934        config.margin_bottom_mm,
1935        config.margin_left_mm,
1936        config.bleed_top_mm,
1937        config.bleed_right_mm,
1938        config.bleed_bottom_mm,
1939        config.bleed_left_mm,
1940        config.safe_top_mm,
1941        config.safe_right_mm,
1942        config.safe_bottom_mm,
1943        config.safe_left_mm,
1944    ];
1945    if margins
1946        .iter()
1947        .any(|value| !value.is_finite() || *value < 0.0)
1948    {
1949        return Err(PrintLayoutError::InvalidMargins);
1950    }
1951    if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
1952        return Err(PrintLayoutError::InvalidSystemHeight);
1953    }
1954    if !config.scale.is_finite() || config.scale <= 0.0 {
1955        return Err(PrintLayoutError::InvalidScale);
1956    }
1957    let scaled_system_height_mm = config.system_height_mm * config.scale;
1958    if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
1959        return Err(PrintLayoutError::InvalidScale);
1960    }
1961    if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
1962        return Err(PrintLayoutError::InvalidPublicationLineHeight);
1963    }
1964    if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
1965    {
1966        return Err(PrintLayoutError::InvalidGlyphResourceKey);
1967    }
1968    Ok((width_mm, height_mm, scaled_system_height_mm))
1969}
1970
1971/// Compute physical page and system placement without rendering or host integration.
1972pub fn compute_print_layout(
1973    score: &Score,
1974    config: &PrintConfig,
1975) -> Result<PrintLayoutResult, PrintLayoutError> {
1976    let layout_score = score_for_part_layout(score, config.part_layout)?;
1977    let (width_mm, height_mm, scaled_system_height_mm) = validate_print_config(config)?;
1978
1979    let content_width_mm = width_mm
1980        - config.margin_left_mm
1981        - config.margin_right_mm
1982        - config.safe_left_mm
1983        - config.safe_right_mm;
1984    let content_height_mm = height_mm
1985        - config.margin_top_mm
1986        - config.margin_bottom_mm
1987        - config.safe_top_mm
1988        - config.safe_bottom_mm;
1989    if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
1990        return Err(PrintLayoutError::NoUsablePageArea);
1991    }
1992
1993    let systems_per_page = config
1994        .systems_per_page
1995        .unwrap_or_else(|| {
1996            (content_height_mm / scaled_system_height_mm)
1997                .floor()
1998                .max(1.0) as usize
1999        })
2000        .max(1);
2001    let layout = compute_layout(
2002        &layout_score,
2003        &LayoutConfig {
2004            measures_per_row: config.measures_per_system.max(1),
2005            first_row_measures: config.first_system_measures.or_else(|| {
2006                (matches!(
2007                    config.pickup_policy,
2008                    PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
2009                ) && has_first_measure_pickup(&layout_score))
2010                .then_some(1)
2011            }),
2012            ..LayoutConfig::default()
2013        },
2014    );
2015
2016    let mut keep_together = config.keep_together.clone();
2017    if matches!(
2018        config.notation_break_policy,
2019        NotationBreakPolicy::KeepVoltaTogether
2020    ) {
2021        keep_together.extend(volta_ranges(&layout_score));
2022    }
2023    let rows = apply_keep_together(
2024        &layout_score,
2025        layout.rows,
2026        &keep_together,
2027        config.measures_per_system.max(1),
2028    )?;
2029
2030    let has_explicit_page_break = rows.iter().any(|row| {
2031        row.measure_indices.last().is_some_and(|&measure_index| {
2032            layout_score
2033                .parts
2034                .iter()
2035                .flat_map(|part| part.staves.iter())
2036                .filter_map(|staff| staff.measures.get(measure_index))
2037                .any(|measure| measure.page_break)
2038        })
2039    });
2040    let repeat_system_ranges = if matches!(
2041        config.notation_break_policy,
2042        NotationBreakPolicy::KeepRepeatsTogether
2043    ) {
2044        repeat_system_ranges(&layout_score, &rows)
2045    } else {
2046        Vec::new()
2047    };
2048    if repeat_system_ranges
2049        .iter()
2050        .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
2051    {
2052        return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
2053    }
2054    let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
2055        && !has_explicit_page_break
2056        && systems_per_page > 1
2057        && rows.len() > systems_per_page
2058        && repeat_system_ranges.is_empty()
2059    {
2060        let page_count = rows.len().div_ceil(systems_per_page);
2061        let base = rows.len() / page_count;
2062        let remainder = rows.len() % page_count;
2063        (0..page_count)
2064            .map(|index| base + usize::from(index < remainder))
2065            .collect::<Vec<_>>()
2066    } else {
2067        Vec::new()
2068    };
2069
2070    let mut pages = Vec::new();
2071    let mut page_systems = Vec::new();
2072    let mut page_index = 0;
2073    for (system_index, row) in rows.iter().enumerate() {
2074        let repeat_starts_here = repeat_system_ranges
2075            .iter()
2076            .any(|(first, _)| *first == system_index);
2077        if repeat_starts_here && !page_systems.is_empty() {
2078            let page_number = match config.page_numbering {
2079                PageNumbering::None => None,
2080                PageNumbering::OneBased => Some(page_index + 1),
2081            };
2082            pages.push(build_page_layout(
2083                score,
2084                &layout_score,
2085                config,
2086                std::mem::take(&mut page_systems),
2087                page_index,
2088                page_number,
2089                width_mm,
2090                height_mm,
2091                content_width_mm,
2092                content_height_mm,
2093                BreakReason::PageCapacity,
2094                false,
2095            ));
2096            page_index += 1;
2097        }
2098        let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
2099            layout_score
2100                .parts
2101                .iter()
2102                .flat_map(|part| part.staves.iter())
2103                .filter_map(|staff| staff.measures.get(measure_index))
2104                .any(|measure| measure.page_break)
2105        });
2106        let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
2107            layout_score
2108                .parts
2109                .iter()
2110                .flat_map(|part| part.staves.iter())
2111                .filter_map(|staff| staff.measures.get(measure_index))
2112                .any(|measure| measure.system_break)
2113        });
2114        let is_last_system = system_index + 1 == rows.len();
2115        let break_reason = if explicit_page_break {
2116            BreakReason::ExplicitPageBreak
2117        } else if explicit_system_break {
2118            BreakReason::ExplicitSystemBreak
2119        } else if is_last_system {
2120            BreakReason::EndOfScore
2121        } else {
2122            BreakReason::MeasureCapacity
2123        };
2124        let system = SystemLayout {
2125            address: SystemAddress {
2126                system_index,
2127                page_index,
2128                index_on_page: page_systems.len(),
2129            },
2130            system_index,
2131            page_index,
2132            measure_indices: row.measure_indices.clone(),
2133            measure_spans: measure_spans(&layout_score, &row.measure_indices),
2134            span_segments: span_segments(&layout.spans, &row.measure_indices),
2135            measure_marks: measure_marks(&layout_score, &row.measure_indices),
2136            top_mm: config.margin_top_mm
2137                + config.safe_top_mm
2138                + page_systems.len() as f32 * scaled_system_height_mm,
2139            height_mm: scaled_system_height_mm,
2140            break_reason,
2141        };
2142        page_systems.push(system);
2143
2144        let page_capacity = page_capacities
2145            .get(page_index)
2146            .copied()
2147            .unwrap_or(systems_per_page);
2148        let page_is_full = page_systems.len() >= page_capacity;
2149        if page_is_full || explicit_page_break {
2150            let page_break_reason = if explicit_page_break {
2151                BreakReason::ExplicitPageBreak
2152            } else if is_last_system {
2153                BreakReason::EndOfScore
2154            } else {
2155                BreakReason::PageCapacity
2156            };
2157            let page_number = match config.page_numbering {
2158                PageNumbering::None => None,
2159                PageNumbering::OneBased => Some(page_index + 1),
2160            };
2161            pages.push(build_page_layout(
2162                score,
2163                &layout_score,
2164                config,
2165                std::mem::take(&mut page_systems),
2166                page_index,
2167                page_number,
2168                width_mm,
2169                height_mm,
2170                content_width_mm,
2171                content_height_mm,
2172                page_break_reason,
2173                false,
2174            ));
2175            page_index += 1;
2176        }
2177    }
2178    if !page_systems.is_empty() || pages.is_empty() {
2179        let page_number = match config.page_numbering {
2180            PageNumbering::None => None,
2181            PageNumbering::OneBased => Some(page_index + 1),
2182        };
2183        pages.push(build_page_layout(
2184            score,
2185            &layout_score,
2186            config,
2187            page_systems,
2188            page_index,
2189            page_number,
2190            width_mm,
2191            height_mm,
2192            content_width_mm,
2193            content_height_mm,
2194            BreakReason::EndOfScore,
2195            false,
2196        ));
2197    }
2198
2199    if config.publication.title_page {
2200        for page in &mut pages {
2201            page.page_index += 1;
2202            page.address.page_index = page.page_index;
2203            page.page_number = match config.page_numbering {
2204                PageNumbering::None => None,
2205                PageNumbering::OneBased => Some(page.page_index + 1),
2206            };
2207            for system in &mut page.systems {
2208                system.page_index += 1;
2209                system.address.page_index = system.page_index;
2210            }
2211            page.publication = page_publication(
2212                score,
2213                &layout_score,
2214                config,
2215                &page.systems,
2216                false,
2217                page.page_number,
2218            );
2219        }
2220        let page_number = match config.page_numbering {
2221            PageNumbering::None => None,
2222            PageNumbering::OneBased => Some(1),
2223        };
2224        pages.insert(
2225            0,
2226            build_page_layout(
2227                score,
2228                &layout_score,
2229                config,
2230                Vec::new(),
2231                0,
2232                page_number,
2233                width_mm,
2234                height_mm,
2235                content_width_mm,
2236                content_height_mm,
2237                BreakReason::TitlePage,
2238                true,
2239            ),
2240        );
2241    }
2242
2243    Ok(PrintLayoutResult {
2244        contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
2245        pages,
2246    })
2247}
2248
2249#[cfg(test)]
2250mod tests {
2251    use super::*;
2252    use acorde_core::{
2253        Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score, Staff, Step,
2254    };
2255
2256    fn score_with_measures(count: usize) -> Score {
2257        let mut score = Score::default();
2258        let mut part = Part::new("Piano", "Pno.");
2259        let mut staff = Staff::new(Clef::Treble);
2260        staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
2261        part.staves = vec![staff];
2262        score.parts = vec![part];
2263        score
2264    }
2265
2266    #[test]
2267    fn publication_metadata_accepts_legacy_partial_json() {
2268        let publication: PagePublication =
2269            serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
2270                .expect("legacy publication metadata should deserialize");
2271
2272        assert!(publication.is_title_page);
2273        assert_eq!(publication.title, "Legacy score");
2274        assert!(publication.movement_title.is_empty());
2275        assert!(publication.part_labels.is_empty());
2276        assert!(publication.part_groups.is_empty());
2277        assert!(publication.text_blocks.is_empty());
2278    }
2279
2280    #[test]
2281    fn layout_validation_rejects_unsupported_contract_version() {
2282        let score = score_with_measures(1);
2283        let mut result =
2284            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2285        result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
2286
2287        assert_eq!(
2288            result.validate(),
2289            Err(PrintLayoutError::UnsupportedContractVersion {
2290                found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
2291            })
2292        );
2293    }
2294
2295    #[test]
2296    fn paginates_rows_and_preserves_measure_indices() {
2297        let score = score_with_measures(5);
2298        let result = compute_print_layout(
2299            &score,
2300            &PrintConfig {
2301                measures_per_system: 2,
2302                systems_per_page: Some(2),
2303                ..PrintConfig::default()
2304            },
2305        )
2306        .expect("valid print config");
2307        assert_eq!(result.pages.len(), 2);
2308        assert_eq!(
2309            result.pages[0]
2310                .systems
2311                .iter()
2312                .map(|s| s.measure_indices.clone())
2313                .collect::<Vec<_>>(),
2314            vec![vec![0, 1], vec![2, 3]]
2315        );
2316        assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
2317        assert_eq!(result.pages[1].systems[0].page_index, 1);
2318        assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
2319        assert_eq!(
2320            result.pages[1].systems[0].break_reason,
2321            BreakReason::EndOfScore
2322        );
2323        assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
2324    }
2325
2326    #[test]
2327    fn forced_page_break_starts_next_system_on_next_page() {
2328        let mut score = score_with_measures(3);
2329        score.parts[0].staves[0].measures[0].page_break = true;
2330        let result = compute_print_layout(
2331            &score,
2332            &PrintConfig {
2333                measures_per_system: 3,
2334                systems_per_page: Some(8),
2335                ..PrintConfig::default()
2336            },
2337        )
2338        .expect("valid print config");
2339        assert_eq!(result.pages.len(), 2);
2340        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2341        assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
2342        assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
2343        assert_eq!(
2344            result.pages[0].systems[0].break_reason,
2345            BreakReason::ExplicitPageBreak
2346        );
2347    }
2348
2349    #[test]
2350    fn keep_together_range_is_not_split_across_systems() {
2351        let score = score_with_measures(5);
2352        let result = compute_print_layout(
2353            &score,
2354            &PrintConfig {
2355                measures_per_system: 3,
2356                systems_per_page: Some(8),
2357                keep_together: vec![KeepTogetherRange {
2358                    first_measure: 1,
2359                    last_measure: 2,
2360                }],
2361                ..PrintConfig::default()
2362            },
2363        )
2364        .expect("valid keep-together range");
2365        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2366        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2367        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
2368    }
2369
2370    #[test]
2371    fn first_system_measure_capacity_is_preserved_in_print_layout() {
2372        let score = score_with_measures(5);
2373        let result = compute_print_layout(
2374            &score,
2375            &PrintConfig {
2376                measures_per_system: 3,
2377                first_system_measures: Some(1),
2378                systems_per_page: Some(8),
2379                ..PrintConfig::default()
2380            },
2381        )
2382        .expect("valid first-system capacity");
2383        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2384        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2385        assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
2386    }
2387
2388    #[test]
2389    fn pickup_policy_isolates_a_partial_first_measure() {
2390        let mut score = score_with_measures(4);
2391        score.parts[0].staves[0].measures[0].voices[0] =
2392            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2393        let result = compute_print_layout(
2394            &score,
2395            &PrintConfig {
2396                measures_per_system: 3,
2397                pickup_policy: PickupPolicy::DetectFirstMeasure,
2398                systems_per_page: Some(8),
2399                ..PrintConfig::default()
2400            },
2401        )
2402        .expect("valid pickup policy");
2403        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2404        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2405    }
2406
2407    #[test]
2408    fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
2409        let mut score = score_with_measures(4);
2410        score.parts[0].staves[0].measures[0].voices[0] =
2411            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2412        let result = compute_print_layout(
2413            &score,
2414            &PrintConfig {
2415                measures_per_system: 3,
2416                systems_per_page: Some(8),
2417                ..PrintConfig::default()
2418            },
2419        )
2420        .expect("valid automatic pickup policy");
2421        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2422        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2423    }
2424
2425    #[test]
2426    fn system_exposes_physical_span_for_multi_rest_slot() {
2427        let mut score = score_with_measures(6);
2428        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2429        let result = compute_print_layout(&score, &PrintConfig::default())
2430            .expect("valid multi-rest print layout");
2431        assert_eq!(
2432            result.pages[0].systems[0].measure_spans[1],
2433            MeasureSpan {
2434                first_measure: 1,
2435                last_measure: 3,
2436            }
2437        );
2438    }
2439
2440    #[test]
2441    fn multirest_width_drives_system_breaking_without_splitting() {
2442        let mut score = score_with_measures(5);
2443        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2444        let result = compute_print_layout(
2445            &score,
2446            &PrintConfig {
2447                measures_per_system: 2,
2448                pickup_policy: PickupPolicy::Preserve,
2449                systems_per_page: Some(8),
2450                ..PrintConfig::default()
2451            },
2452        )
2453        .expect("valid multi-rest pagination");
2454        assert_eq!(
2455            result.pages[0]
2456                .systems
2457                .iter()
2458                .map(|system| system.measure_indices.clone())
2459                .collect::<Vec<_>>(),
2460            vec![vec![0], vec![1], vec![2, 3], vec![4]]
2461        );
2462        assert_eq!(
2463            result.pages[0].systems[1].measure_spans[0],
2464            MeasureSpan {
2465                first_measure: 1,
2466                last_measure: 3,
2467            }
2468        );
2469    }
2470
2471    #[test]
2472    fn system_exposes_cross_system_span_segments() {
2473        let mut score = score_with_measures(4);
2474        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2475        start.slur_start = true;
2476        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2477        end.slur_end = true;
2478        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2479        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2480        let result = compute_print_layout(
2481            &score,
2482            &PrintConfig {
2483                measures_per_system: 2,
2484                pickup_policy: PickupPolicy::Preserve,
2485                systems_per_page: Some(8),
2486                ..PrintConfig::default()
2487            },
2488        )
2489        .expect("valid cross-system span layout");
2490        assert_eq!(
2491            result.pages[0].systems[0].span_segments,
2492            vec![SpanSegment {
2493                span_index: 0,
2494                starts_here: true,
2495                ends_here: false,
2496            }]
2497        );
2498        assert_eq!(
2499            result.pages[0].systems[1].span_segments,
2500            vec![SpanSegment {
2501                span_index: 0,
2502                starts_here: false,
2503                ends_here: true,
2504            }]
2505        );
2506    }
2507
2508    #[test]
2509    fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
2510        let mut score = score_with_measures(4);
2511        let measures = &mut score.parts[0].staves[0].measures;
2512        measures[0].barline_right = Barline::RepeatEnd;
2513        measures[1].barline_left = Barline::RepeatStart;
2514        measures[2].volta = Some(acorde_core::VoltaBracket {
2515            number: 1,
2516            kind: "begin".to_string(),
2517        });
2518        measures[2].navigation = Some("ToCoda".to_string());
2519        measures[2].rehearsal = Some("B".to_string());
2520        let result = compute_print_layout(
2521            &score,
2522            &PrintConfig {
2523                measures_per_system: 2,
2524                systems_per_page: Some(8),
2525                ..PrintConfig::default()
2526            },
2527        )
2528        .expect("valid measure mark layout");
2529        assert_eq!(
2530            result.pages[0].systems[0].measure_marks,
2531            vec![
2532                MeasureMark {
2533                    measure_index: 0,
2534                    repeat_start: false,
2535                    repeat_end: true,
2536                    volta_number: None,
2537                    volta_kind: None,
2538                    navigation: None,
2539                    rehearsal: None,
2540                    text_annotations: vec![],
2541                },
2542                MeasureMark {
2543                    measure_index: 1,
2544                    repeat_start: true,
2545                    repeat_end: false,
2546                    volta_number: None,
2547                    volta_kind: None,
2548                    navigation: None,
2549                    rehearsal: None,
2550                    text_annotations: vec![],
2551                },
2552            ]
2553        );
2554        assert_eq!(
2555            result.pages[0].systems[1].measure_marks,
2556            vec![MeasureMark {
2557                measure_index: 2,
2558                repeat_start: false,
2559                repeat_end: false,
2560                volta_number: Some(1),
2561                volta_kind: Some("begin".to_string()),
2562                navigation: Some("ToCoda".to_string()),
2563                rehearsal: Some("B".to_string()),
2564                text_annotations: vec![
2565                    acorde_core::StyledText {
2566                        style: acorde_core::TextStyle::RehearsalMark,
2567                        text: "B".to_string(),
2568                        placement: None,
2569                        offset_x: None,
2570                        offset_y: None,
2571                        relative_x: None,
2572                        relative_y: None,
2573                    },
2574                    acorde_core::StyledText {
2575                        style: acorde_core::TextStyle::Generic,
2576                        text: "ToCoda".to_string(),
2577                        placement: None,
2578                        offset_x: None,
2579                        offset_y: None,
2580                        relative_x: None,
2581                        relative_y: None,
2582                    },
2583                ],
2584            }]
2585        );
2586    }
2587
2588    #[test]
2589    fn system_exposes_explicit_measure_text_without_legacy_fields() {
2590        let mut score = score_with_measures(1);
2591        score.parts[0].staves[0].measures[0]
2592            .texts
2593            .push(acorde_core::StyledText {
2594                style: acorde_core::TextStyle::Expression,
2595                text: "dolce".to_string(),
2596                placement: Some("above".to_string()),
2597                offset_x: Some(2.0),
2598                offset_y: Some(-1.0),
2599                relative_x: None,
2600                relative_y: None,
2601            });
2602        let result = compute_print_layout(&score, &PrintConfig::default())
2603            .expect("valid explicit measure text layout");
2604        let annotations = &result.pages[0].systems[0].measure_marks[0].text_annotations;
2605        assert_eq!(annotations.len(), 1);
2606        assert_eq!(annotations[0].style, acorde_core::TextStyle::Expression);
2607        assert_eq!(annotations[0].text, "dolce");
2608        assert_eq!(annotations[0].placement.as_deref(), Some("above"));
2609        assert_eq!(annotations[0].offset_x, Some(2.0));
2610        assert_eq!(annotations[0].offset_y, Some(-1.0));
2611    }
2612
2613    #[test]
2614    fn page_aggregates_cross_system_span_ownership() {
2615        let mut score = score_with_measures(4);
2616        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2617        start.slur_start = true;
2618        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2619        end.slur_end = true;
2620        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2621        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2622        let result = compute_print_layout(
2623            &score,
2624            &PrintConfig {
2625                measures_per_system: 2,
2626                pickup_policy: PickupPolicy::Preserve,
2627                systems_per_page: Some(1),
2628                ..PrintConfig::default()
2629            },
2630        )
2631        .expect("valid page span layout");
2632        assert_eq!(
2633            result.pages[0].span_segments,
2634            vec![PageSpanSegment {
2635                span_index: 0,
2636                starts_here: true,
2637                ends_here: false,
2638            }]
2639        );
2640        assert_eq!(
2641            result.pages[1].span_segments,
2642            vec![PageSpanSegment {
2643                span_index: 0,
2644                starts_here: false,
2645                ends_here: true,
2646            }]
2647        );
2648    }
2649
2650    #[test]
2651    fn page_artifact_measure_span_borrows_system_spans() {
2652        let mut score = score_with_measures(4);
2653        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2654        start.slur_start = true;
2655        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2656        end.slur_end = true;
2657        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2658        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2659        let result = compute_print_layout(
2660            &score,
2661            &PrintConfig {
2662                measures_per_system: 2,
2663                pickup_policy: PickupPolicy::Preserve,
2664                systems_per_page: Some(1),
2665                ..PrintConfig::default()
2666            },
2667        )
2668        .expect("valid page artifact");
2669        let first = result
2670            .page(PageAddress { page_index: 0 })
2671            .expect("first page");
2672        assert_eq!(
2673            first.measure_span(),
2674            Some(MeasureSpan {
2675                first_measure: 0,
2676                last_measure: 1,
2677            })
2678        );
2679        assert!(first.has_span_continuation());
2680        assert!(result.page(PageAddress { page_index: 99 }).is_none());
2681        assert!(result.validate().is_ok());
2682    }
2683
2684    #[test]
2685    fn export_page_artifacts_reports_host_glyph_resource_requirement() {
2686        let result = compute_print_layout(
2687            &score_with_measures(1),
2688            &PrintConfig {
2689                glyph_resources: GlyphResourcePolicy::HostProvided("licensed-font-v1".into()),
2690                ..PrintConfig::default()
2691            },
2692        )
2693        .expect("valid host resource policy");
2694
2695        let artifacts = result
2696            .export_page_artifacts()
2697            .expect("host resource requirement is a diagnostic");
2698        assert_eq!(
2699            artifacts[0].diagnostics,
2700            vec![PageArtifactDiagnostic::GlyphResourceRequired]
2701        );
2702        assert_eq!(
2703            artifacts[0].layout.glyph_resources,
2704            GlyphResourcePolicy::HostProvided("licensed-font-v1".into())
2705        );
2706    }
2707
2708    #[test]
2709    fn page_artifact_diagnostics_report_glyph_overflow_sides() {
2710        let result = compute_print_layout(&score_with_measures(1), &PrintConfig::default())
2711            .expect("valid print layout");
2712        let page = &result.pages[0];
2713        assert_eq!(
2714            page.artifact_diagnostics(Some(GlyphExtents {
2715                left_mm: -1.0,
2716                top_mm: -2.0,
2717                right_mm: page.content_width_mm + 3.0,
2718                bottom_mm: page.content_height_mm + 4.0,
2719            })),
2720            vec![PageArtifactDiagnostic::GlyphOverflow {
2721                left: true,
2722                top: true,
2723                right: true,
2724                bottom: true,
2725            }]
2726        );
2727        assert!(page.artifact_diagnostics(None).is_empty());
2728    }
2729
2730    #[test]
2731    fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
2732        let mut score = score_with_measures(4);
2733        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2734        start.slur_start = true;
2735        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2736        end.slur_end = true;
2737        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2738        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2739        let result = compute_print_layout(
2740            &score,
2741            &PrintConfig {
2742                measures_per_system: 2,
2743                pickup_policy: PickupPolicy::Preserve,
2744                systems_per_page: Some(1),
2745                ..PrintConfig::default()
2746            },
2747        )
2748        .expect("valid print config");
2749
2750        let artifacts = result
2751            .export_page_artifacts()
2752            .expect("valid page artifacts");
2753        assert_eq!(artifacts.len(), 2);
2754        assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
2755        assert_eq!(artifacts[1].page_index, 1);
2756        assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
2757        assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
2758        assert_eq!(
2759            artifacts[0].measure_span,
2760            Some(MeasureSpan {
2761                first_measure: 0,
2762                last_measure: 1,
2763            })
2764        );
2765        assert_eq!(
2766            artifacts[0].diagnostics,
2767            vec![PageArtifactDiagnostic::SpanContinuation {
2768                span_index: 0,
2769                starts_here: true,
2770                ends_here: false,
2771            }]
2772        );
2773        assert_eq!(
2774            artifacts[1].diagnostics,
2775            vec![PageArtifactDiagnostic::SpanContinuation {
2776                span_index: 0,
2777                starts_here: false,
2778                ends_here: true,
2779            }]
2780        );
2781    }
2782
2783    #[test]
2784    fn export_page_artifacts_rejects_invalid_serialized_layout() {
2785        let score = score_with_measures(1);
2786        let mut result =
2787            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2788        result.pages[0].width_mm = f32::NAN;
2789
2790        assert!(matches!(
2791            result.export_page_artifacts(),
2792            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2793        ));
2794    }
2795
2796    #[test]
2797    fn page_lookup_rejects_mismatched_serialized_address() {
2798        let score = score_with_measures(1);
2799        let mut result =
2800            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2801        result.pages[0].address = PageAddress { page_index: 7 };
2802
2803        assert!(result.page(PageAddress { page_index: 0 }).is_none());
2804        assert_eq!(
2805            result.validate(),
2806            Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
2807        );
2808    }
2809
2810    #[test]
2811    fn layout_validation_rejects_mismatched_system_address() {
2812        let score = score_with_measures(2);
2813        let mut result = compute_print_layout(
2814            &score,
2815            &PrintConfig {
2816                measures_per_system: 1,
2817                ..PrintConfig::default()
2818            },
2819        )
2820        .expect("valid print config");
2821        result.pages[0].systems[0].address.index_on_page = 4;
2822
2823        assert_eq!(
2824            result.validate(),
2825            Err(PrintLayoutError::InvalidSystemAddress {
2826                page_index: 0,
2827                index_on_page: 0,
2828                system_index: 0,
2829            })
2830        );
2831    }
2832
2833    #[test]
2834    fn layout_validation_rejects_non_monotonic_page_number() {
2835        let score = score_with_measures(2);
2836        let mut result = compute_print_layout(
2837            &score,
2838            &PrintConfig {
2839                measures_per_system: 1,
2840                page_numbering: PageNumbering::OneBased,
2841                systems_per_page: Some(1),
2842                ..PrintConfig::default()
2843            },
2844        )
2845        .expect("valid print config");
2846        result.pages[1].page_number = Some(1);
2847
2848        assert_eq!(
2849            result.validate(),
2850            Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
2851        );
2852    }
2853
2854    #[test]
2855    fn layout_validation_rejects_inconsistent_title_page_metadata() {
2856        let score = score_with_measures(1);
2857        let mut result =
2858            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2859        result.pages[0].publication.is_title_page = true;
2860
2861        assert_eq!(
2862            result.validate(),
2863            Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
2864        );
2865    }
2866
2867    #[test]
2868    fn layout_validation_rejects_non_finite_page_geometry() {
2869        let score = score_with_measures(1);
2870        let mut result =
2871            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2872        result.pages[0].width_mm = f32::NAN;
2873
2874        assert_eq!(
2875            result.validate(),
2876            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2877        );
2878    }
2879
2880    #[test]
2881    fn layout_validation_rejects_non_positive_system_geometry() {
2882        let score = score_with_measures(1);
2883        let mut result =
2884            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2885        result.pages[0].systems[0].height_mm = 0.0;
2886
2887        assert_eq!(
2888            result.validate(),
2889            Err(PrintLayoutError::InvalidSystemGeometry {
2890                page_index: 0,
2891                index_on_page: 0,
2892            })
2893        );
2894    }
2895
2896    #[test]
2897    fn layout_validation_rejects_content_larger_than_page() {
2898        let score = score_with_measures(1);
2899        let mut result =
2900            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2901        result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
2902
2903        assert_eq!(
2904            result.validate(),
2905            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2906        );
2907    }
2908
2909    #[test]
2910    fn notation_policy_keeps_volta_range_in_one_system() {
2911        let mut score = score_with_measures(4);
2912        score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
2913            number: 1,
2914            kind: "begin".to_string(),
2915        });
2916        score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
2917            number: 1,
2918            kind: "end".to_string(),
2919        });
2920        let result = compute_print_layout(
2921            &score,
2922            &PrintConfig {
2923                measures_per_system: 2,
2924                systems_per_page: Some(8),
2925                notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
2926                ..PrintConfig::default()
2927            },
2928        )
2929        .expect("valid volta-preserving layout");
2930        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2931        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2932        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
2933    }
2934
2935    #[test]
2936    fn notation_policy_keeps_repeat_section_on_one_page() {
2937        let mut score = score_with_measures(5);
2938        score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
2939        score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
2940        let result = compute_print_layout(
2941            &score,
2942            &PrintConfig {
2943                measures_per_system: 2,
2944                systems_per_page: Some(2),
2945                notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
2946                ..PrintConfig::default()
2947            },
2948        )
2949        .expect("valid repeat-preserving layout");
2950        assert_eq!(result.pages[0].systems.len(), 1);
2951        assert_eq!(result.pages[1].systems.len(), 2);
2952        assert_eq!(
2953            result.pages[1]
2954                .systems
2955                .iter()
2956                .flat_map(|system| system.measure_indices.iter().copied())
2957                .collect::<Vec<_>>(),
2958            vec![2, 3, 4]
2959        );
2960    }
2961
2962    #[test]
2963    fn balance_policy_avoids_single_system_final_page() {
2964        let score = score_with_measures(5);
2965        let result = compute_print_layout(
2966            &score,
2967            &PrintConfig {
2968                measures_per_system: 1,
2969                systems_per_page: Some(4),
2970                final_page_policy: FinalPagePolicy::Balance,
2971                ..PrintConfig::default()
2972            },
2973        )
2974        .expect("valid balanced print config");
2975        assert_eq!(result.pages.len(), 2);
2976        assert_eq!(result.pages[0].systems.len(), 3);
2977        assert_eq!(result.pages[1].systems.len(), 2);
2978    }
2979
2980    #[test]
2981    fn balance_policy_preserves_explicit_page_breaks() {
2982        let mut score = score_with_measures(5);
2983        score.parts[0].staves[0].measures[1].page_break = true;
2984        let result = compute_print_layout(
2985            &score,
2986            &PrintConfig {
2987                measures_per_system: 1,
2988                systems_per_page: Some(4),
2989                final_page_policy: FinalPagePolicy::Balance,
2990                ..PrintConfig::default()
2991            },
2992        )
2993        .expect("valid explicit-break print config");
2994        assert_eq!(result.pages[0].systems.len(), 2);
2995        assert_eq!(result.pages[1].systems.len(), 3);
2996    }
2997
2998    #[test]
2999    fn keep_together_rejects_ranges_larger_than_system_capacity() {
3000        let score = score_with_measures(4);
3001        let error = compute_print_layout(
3002            &score,
3003            &PrintConfig {
3004                measures_per_system: 2,
3005                keep_together: vec![KeepTogetherRange {
3006                    first_measure: 0,
3007                    last_measure: 2,
3008                }],
3009                ..PrintConfig::default()
3010            },
3011        )
3012        .expect_err("range must fit in one system");
3013        assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
3014    }
3015
3016    #[test]
3017    fn keep_together_rejects_explicit_break_inside_range() {
3018        let mut score = score_with_measures(4);
3019        score.parts[0].staves[0].measures[1].system_break = true;
3020        let error = compute_print_layout(
3021            &score,
3022            &PrintConfig {
3023                measures_per_system: 3,
3024                keep_together: vec![KeepTogetherRange {
3025                    first_measure: 0,
3026                    last_measure: 2,
3027                }],
3028                ..PrintConfig::default()
3029            },
3030        )
3031        .expect_err("explicit break must win");
3032        assert_eq!(
3033            error,
3034            PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
3035        );
3036    }
3037
3038    #[test]
3039    fn rejects_margins_that_leave_no_page_area() {
3040        let score = score_with_measures(1);
3041        let error = compute_print_layout(
3042            &score,
3043            &PrintConfig {
3044                margin_left_mm: 200.0,
3045                ..PrintConfig::default()
3046            },
3047        )
3048        .expect_err("invalid page area");
3049        assert_eq!(error, PrintLayoutError::NoUsablePageArea);
3050    }
3051
3052    #[test]
3053    fn safe_area_reduces_content_and_bleed_is_exposed() {
3054        let score = score_with_measures(1);
3055        let result = compute_print_layout(
3056            &score,
3057            &PrintConfig {
3058                bleed_top_mm: 3.0,
3059                bleed_right_mm: 3.0,
3060                bleed_bottom_mm: 3.0,
3061                bleed_left_mm: 3.0,
3062                safe_top_mm: 5.0,
3063                safe_right_mm: 6.0,
3064                safe_bottom_mm: 7.0,
3065                safe_left_mm: 8.0,
3066                ..PrintConfig::default()
3067            },
3068        )
3069        .expect("valid print config");
3070        let page = &result.pages[0];
3071        assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
3072        assert_eq!(page.bleed_left_mm, 3.0);
3073        assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
3074        assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
3075        assert_eq!(page.systems[0].top_mm, 21.0);
3076    }
3077
3078    #[test]
3079    fn scale_changes_system_height_and_page_capacity() {
3080        let score = score_with_measures(10);
3081        let result = compute_print_layout(
3082            &score,
3083            &PrintConfig {
3084                scale: 2.0,
3085                measures_per_system: 1,
3086                systems_per_page: None,
3087                ..PrintConfig::default()
3088            },
3089        )
3090        .expect("valid print config");
3091        assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
3092        assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
3093        assert_eq!(result.pages.len(), 2);
3094    }
3095
3096    #[test]
3097    fn rejects_non_positive_scale() {
3098        let score = score_with_measures(1);
3099        let error = compute_print_layout(
3100            &score,
3101            &PrintConfig {
3102                scale: 0.0,
3103                ..PrintConfig::default()
3104            },
3105        )
3106        .expect_err("invalid scale");
3107        assert_eq!(error, PrintLayoutError::InvalidScale);
3108    }
3109
3110    #[test]
3111    fn page_numbering_is_configurable() {
3112        let score = score_with_measures(5);
3113        let numbered = compute_print_layout(
3114            &score,
3115            &PrintConfig {
3116                measures_per_system: 1,
3117                systems_per_page: Some(2),
3118                ..PrintConfig::default()
3119            },
3120        )
3121        .expect("valid print config");
3122        assert_eq!(numbered.pages[0].page_number, Some(1));
3123        assert_eq!(numbered.pages[1].page_number, Some(2));
3124
3125        let unnumbered = compute_print_layout(
3126            &score,
3127            &PrintConfig {
3128                page_numbering: PageNumbering::None,
3129                measures_per_system: 1,
3130                systems_per_page: Some(2),
3131                ..PrintConfig::default()
3132            },
3133        )
3134        .expect("valid print config");
3135        assert!(
3136            unnumbered
3137                .pages
3138                .iter()
3139                .all(|page| page.page_number.is_none())
3140        );
3141    }
3142
3143    #[test]
3144    fn rejects_invalid_publication_line_height() {
3145        let score = score_with_measures(1);
3146        let error = compute_print_layout(
3147            &score,
3148            &PrintConfig {
3149                publication: PublicationConfig {
3150                    line_height_mm: 0.0,
3151                    ..PublicationConfig::default()
3152                },
3153                ..PrintConfig::default()
3154            },
3155        )
3156        .expect_err("invalid publication line height");
3157        assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
3158    }
3159
3160    #[test]
3161    fn rejects_empty_host_glyph_resource_key() {
3162        let score = score_with_measures(1);
3163        let error = compute_print_layout(
3164            &score,
3165            &PrintConfig {
3166                glyph_resources: GlyphResourcePolicy::HostProvided("  ".into()),
3167                ..PrintConfig::default()
3168            },
3169        )
3170        .expect_err("empty host resource key");
3171        assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
3172    }
3173
3174    #[test]
3175    fn glyph_resource_descriptor_requires_reproducible_metadata() {
3176        let descriptor = GlyphResourceDescriptor {
3177            contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
3178            resource_key: "publisher-font-v2".into(),
3179            metrics_contract_version: 1,
3180            license_notice: "licensed by publisher".into(),
3181            fallback: GlyphFallbackPolicy::UseResource("acorde-vector-glyphs-v1".into()),
3182        };
3183        assert_eq!(descriptor.validate(), Ok(()));
3184    }
3185
3186    #[test]
3187    fn glyph_resource_descriptor_rejects_missing_license_and_self_fallback() {
3188        let missing_license = GlyphResourceDescriptor {
3189            contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
3190            resource_key: "font".into(),
3191            metrics_contract_version: 1,
3192            license_notice: " ".into(),
3193            fallback: GlyphFallbackPolicy::Reject,
3194        };
3195        assert_eq!(
3196            missing_license.validate(),
3197            Err(GlyphResourceDescriptorError::EmptyLicenseNotice)
3198        );
3199
3200        let self_fallback = GlyphResourceDescriptor {
3201            contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
3202            resource_key: "font".into(),
3203            metrics_contract_version: 1,
3204            license_notice: "licensed".into(),
3205            fallback: GlyphFallbackPolicy::UseResource("font".into()),
3206        };
3207        assert_eq!(
3208            self_fallback.validate(),
3209            Err(GlyphResourceDescriptorError::FallbackMatchesPrimary)
3210        );
3211    }
3212
3213    #[test]
3214    fn print_color_and_crop_policies_are_exposed_per_page() {
3215        let score = score_with_measures(1);
3216        let result = compute_print_layout(
3217            &score,
3218            &PrintConfig {
3219                color_policy: PrintColorPolicy::Preserve,
3220                crop_mark_policy: CropMarkPolicy::BleedEdges,
3221                ..PrintConfig::default()
3222            },
3223        )
3224        .expect("valid print config");
3225        let page = &result.pages[0];
3226        assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
3227        assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
3228        assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
3229    }
3230
3231    #[test]
3232    fn glyph_resource_policy_is_exposed_per_page() {
3233        let score = score_with_measures(1);
3234        let result = compute_print_layout(
3235            &score,
3236            &PrintConfig {
3237                glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
3238                ..PrintConfig::default()
3239            },
3240        )
3241        .expect("valid print config");
3242        assert_eq!(
3243            result.pages[0].glyph_resources,
3244            GlyphResourcePolicy::HostProvided("music-font-v1".into())
3245        );
3246    }
3247
3248    #[test]
3249    fn publication_metadata_is_deterministic_and_page_scoped() {
3250        let mut score = score_with_measures(3);
3251        score.metadata.title = "Suite".into();
3252        score.metadata.movement_title = "I. Prelude".into();
3253        score.metadata.composer = "Composer".into();
3254        score.metadata.copyright = "© 2026 Composer".into();
3255        score.metadata.lyricist = "Lyricist".into();
3256        score.metadata.copyright = "Copyright".into();
3257        score.parts.push(Part::new("Strings", "Str."));
3258        score.part_groups.push(PartGroup {
3259            first_part: 0,
3260            last_part: 1,
3261            symbol: PartGroupSymbol::Bracket,
3262            barlines_connect: true,
3263        });
3264        for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
3265            measure.number = (index + 1) as u32;
3266        }
3267        let result = compute_print_layout(
3268            &score,
3269            &PrintConfig {
3270                measures_per_system: 2,
3271                systems_per_page: Some(1),
3272                publication: PublicationConfig {
3273                    running_title: Some("Suite — Composer".into()),
3274                    header_text: Some("Suite".into()),
3275                    footer_text: Some("Copyright".into()),
3276                    page_number_in_footer: true,
3277                    header_alignment: PublicationTextAlignment::Center,
3278                    footer_alignment: PublicationTextAlignment::Right,
3279                    ..PublicationConfig::default()
3280                },
3281                ..PrintConfig::default()
3282            },
3283        )
3284        .expect("valid print config");
3285        assert_eq!(result.pages[0].publication.title, "Suite");
3286        assert_eq!(
3287            result.pages[0].publication.running_title.as_deref(),
3288            Some("Suite — Composer")
3289        );
3290        assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
3291        assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
3292        assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
3293        assert_eq!(result.pages[0].publication.part_groups.len(), 1);
3294        assert_eq!(
3295            result.pages[0].publication.part_groups[0].symbol,
3296            PartGroupSymbol::Bracket
3297        );
3298        assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
3299        assert_eq!(
3300            result.pages[0].publication.text_blocks[0].role,
3301            PublicationTextRole::Header
3302        );
3303        assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
3304        assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
3305        assert_eq!(
3306            result.pages[0].publication.text_blocks[1].role,
3307            PublicationTextRole::Footer
3308        );
3309        assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
3310        assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
3311        assert_eq!(
3312            result.pages[0].publication.text_blocks[0].alignment,
3313            PublicationTextAlignment::Center
3314        );
3315        assert_eq!(
3316            result.pages[0].publication.text_blocks[1].alignment,
3317            PublicationTextAlignment::Right
3318        );
3319        let artifacts = result
3320            .export_page_artifacts()
3321            .expect("publication pages export without host resources");
3322        assert_eq!(artifacts.len(), result.pages.len());
3323        assert_eq!(artifacts[0].layout.publication, result.pages[0].publication);
3324        assert!(
3325            artifacts
3326                .iter()
3327                .all(|artifact| artifact.diagnostics.is_empty())
3328        );
3329    }
3330
3331    #[test]
3332    fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
3333        let mut score = score_with_measures(2);
3334        let mut part = Part::new("Flute", "Fl.");
3335        let mut staff = Staff::new(Clef::Treble);
3336        staff.measures = vec![Measure::empty(4, 4); 5];
3337        part.staves = vec![staff];
3338        score.parts.push(part);
3339
3340        let extracted = compute_print_layout(
3341            &score,
3342            &PrintConfig {
3343                part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
3344                measures_per_system: 2,
3345                systems_per_page: Some(1),
3346                ..PrintConfig::default()
3347            },
3348        )
3349        .expect("valid extracted part");
3350        assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
3351        assert_eq!(extracted.pages.len(), 3);
3352        assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
3353
3354        let error = compute_print_layout(
3355            &score,
3356            &PrintConfig {
3357                part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
3358                ..PrintConfig::default()
3359            },
3360        )
3361        .expect_err("missing extracted part");
3362        assert_eq!(error, PrintLayoutError::InvalidPartIndex);
3363    }
3364
3365    #[test]
3366    fn title_page_is_inserted_without_consuming_music_page_capacity() {
3367        let mut score = score_with_measures(3);
3368        score.texts.push(StyledText {
3369            style: TextStyle::Expression,
3370            text: "Dedication".into(),
3371            placement: None,
3372            offset_x: None,
3373            offset_y: None,
3374            relative_x: None,
3375            relative_y: None,
3376        });
3377        score.metadata.title = "Suite".into();
3378        score.metadata.movement_title = "I. Prelude".into();
3379        score.metadata.composer = "Composer".into();
3380        score.metadata.copyright = "© 2026 Composer".into();
3381        let result = compute_print_layout(
3382            &score,
3383            &PrintConfig {
3384                systems_per_page: Some(1),
3385                measures_per_system: 2,
3386                publication: PublicationConfig {
3387                    title_page: true,
3388                    ..PublicationConfig::default()
3389                },
3390                ..PrintConfig::default()
3391            },
3392        )
3393        .expect("valid title page config");
3394        assert_eq!(result.pages.len(), 3);
3395        assert!(result.pages[0].systems.is_empty());
3396        assert!(result.pages[0].publication.is_title_page);
3397        assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
3398        assert_eq!(result.pages[0].page_number, Some(1));
3399        assert_eq!(result.pages[1].page_number, Some(2));
3400        assert_eq!(result.pages[1].systems[0].page_index, 1);
3401        assert!(!result.pages[1].publication.is_title_page);
3402        assert_eq!(result.pages[0].publication.score_texts, score.texts);
3403        assert!(result.validate().is_ok());
3404        assert_eq!(
3405            result.pages[0]
3406                .publication
3407                .text_blocks
3408                .iter()
3409                .map(|block| block.role)
3410                .collect::<Vec<_>>(),
3411            vec![
3412                PublicationTextRole::Title,
3413                PublicationTextRole::Subtitle,
3414                PublicationTextRole::Credit,
3415                PublicationTextRole::Copyright
3416            ]
3417        );
3418    }
3419
3420    #[test]
3421    fn print_presets_are_versioned_and_select_the_expected_scope() {
3422        assert_eq!(PrintPreset::A4Score.schema_version(), 1);
3423        assert_eq!(
3424            PrintPreset::A4Score.config().part_layout,
3425            PartLayoutPolicy::FullScore
3426        );
3427        assert_eq!(
3428            PrintPreset::LetterPart { part_index: 2 }
3429                .config()
3430                .part_layout,
3431            PartLayoutPolicy::ExtractedPart { part_index: 2 }
3432        );
3433        assert_eq!(
3434            PrintPreset::LetterScore.config().paper_size,
3435            PaperSize::Letter
3436        );
3437        assert!(
3438            PrintPreset::A4Score
3439                .config_with_title_page(true)
3440                .publication
3441                .title_page
3442        );
3443        assert!(!PrintPreset::A4Score.config().publication.title_page);
3444        assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 1);
3445    }
3446
3447    #[test]
3448    fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
3449        let metrics = GlyphMetrics {
3450            advance_mm: 4.0,
3451            left_mm: -1.0,
3452            top_mm: -2.0,
3453            width_mm: 2.0,
3454            height_mm: 4.0,
3455        };
3456        let mut placements = vec![
3457            GlyphPlacement {
3458                resource_key: "high".into(),
3459                metrics,
3460                x_mm: 10.0,
3461                y_mm: 20.0,
3462                priority: 10,
3463            },
3464            GlyphPlacement {
3465                resource_key: "low".into(),
3466                metrics,
3467                x_mm: 10.0,
3468                y_mm: 20.0,
3469                priority: 1,
3470            },
3471        ];
3472        let moved = resolve_glyph_collisions(&mut placements, 1.0);
3473        assert_eq!(moved, 1);
3474        assert_eq!(placements[0].y_mm, 20.0);
3475        assert_eq!(placements[1].y_mm, 25.0);
3476    }
3477
3478    #[test]
3479    fn class_aware_collision_resolution_uses_stable_semantic_tie_breakers() {
3480        let metrics = GlyphMetrics {
3481            advance_mm: 4.0,
3482            left_mm: -1.0,
3483            top_mm: -2.0,
3484            width_mm: 2.0,
3485            height_mm: 4.0,
3486        };
3487        let mut placements = vec![
3488            GlyphPlacement {
3489                resource_key: "annotation".into(),
3490                metrics,
3491                x_mm: 10.0,
3492                y_mm: 20.0,
3493                priority: 1,
3494            },
3495            GlyphPlacement {
3496                resource_key: "critical".into(),
3497                metrics,
3498                x_mm: 10.0,
3499                y_mm: 20.0,
3500                priority: 1,
3501            },
3502        ];
3503        let classes = [
3504            GlyphCollisionClass::Annotation,
3505            GlyphCollisionClass::Critical,
3506        ];
3507        assert_eq!(
3508            resolve_glyph_collisions_with_classes(&mut placements, &classes, 1.0),
3509            Ok(1)
3510        );
3511        assert_eq!(placements[0].y_mm, 25.0);
3512        assert_eq!(placements[1].y_mm, 20.0);
3513        assert_eq!(
3514            resolve_glyph_horizontal_collisions_with_classes(
3515                &mut placements,
3516                &[GlyphCollisionClass::Annotation],
3517                1.0,
3518            ),
3519            Err(GlyphPlacementError::CollisionClassCount {
3520                placements: 2,
3521                classes: 1,
3522            })
3523        );
3524    }
3525
3526    #[test]
3527    fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
3528        let metrics = GlyphMetrics {
3529            advance_mm: 4.0,
3530            left_mm: -1.0,
3531            top_mm: -1.0,
3532            width_mm: 2.0,
3533            height_mm: 2.0,
3534        };
3535        let mut placements = vec![
3536            GlyphPlacement {
3537                resource_key: "high".into(),
3538                metrics,
3539                x_mm: 10.0,
3540                y_mm: 20.0,
3541                priority: 10,
3542            },
3543            GlyphPlacement {
3544                resource_key: "low".into(),
3545                metrics,
3546                x_mm: 10.0,
3547                y_mm: 0.0,
3548                priority: 1,
3549            },
3550        ];
3551        assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
3552        assert_eq!(placements[1].y_mm, 0.0);
3553    }
3554
3555    #[test]
3556    fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
3557        let mut placements = vec![GlyphPlacement {
3558            resource_key: "test".into(),
3559            metrics: GlyphMetrics {
3560                advance_mm: 1.0,
3561                left_mm: 0.0,
3562                top_mm: 0.0,
3563                width_mm: 1.0,
3564                height_mm: 1.0,
3565            },
3566            x_mm: 0.0,
3567            y_mm: 0.0,
3568            priority: 0,
3569        }];
3570        assert_eq!(validate_glyph_placements(&placements), Ok(()));
3571        placements[0].x_mm = f32::NAN;
3572        assert_eq!(
3573            validate_glyph_placements(&placements),
3574            Err(GlyphPlacementError::NonFinite { index: 0 })
3575        );
3576        placements[0].x_mm = 0.0;
3577        placements[0].metrics.width_mm = -1.0;
3578        assert_eq!(
3579            validate_glyph_placements(&placements),
3580            Err(GlyphPlacementError::NegativeExtent { index: 0 })
3581        );
3582    }
3583
3584    #[test]
3585    fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
3586        let metrics = GlyphMetrics {
3587            advance_mm: 4.0,
3588            left_mm: -1.0,
3589            top_mm: -1.0,
3590            width_mm: 2.0,
3591            height_mm: 2.0,
3592        };
3593        let mut placements = vec![
3594            GlyphPlacement {
3595                resource_key: "high".into(),
3596                metrics,
3597                x_mm: 10.0,
3598                y_mm: 20.0,
3599                priority: 10,
3600            },
3601            GlyphPlacement {
3602                resource_key: "low".into(),
3603                metrics,
3604                x_mm: 10.0,
3605                y_mm: 20.0,
3606                priority: 1,
3607            },
3608            GlyphPlacement {
3609                resource_key: "far".into(),
3610                metrics,
3611                x_mm: 10.0,
3612                y_mm: 30.0,
3613                priority: 1,
3614            },
3615        ];
3616        assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
3617        assert_eq!(placements[0].x_mm, 10.0);
3618        assert_eq!(placements[1].x_mm, 13.0);
3619        assert_eq!(placements[2].x_mm, 10.0);
3620    }
3621
3622    #[test]
3623    fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
3624        let metrics = GlyphMetrics {
3625            advance_mm: 1.0,
3626            left_mm: 0.0,
3627            top_mm: 0.0,
3628            width_mm: 1.0,
3629            height_mm: 1.0,
3630        };
3631        let mut placements = vec![
3632            GlyphPlacement {
3633                resource_key: "second".into(),
3634                metrics,
3635                x_mm: 20.0,
3636                y_mm: 0.0,
3637                priority: 0,
3638            },
3639            GlyphPlacement {
3640                resource_key: "first".into(),
3641                metrics,
3642                x_mm: 10.0,
3643                y_mm: 0.0,
3644                priority: 0,
3645            },
3646            GlyphPlacement {
3647                resource_key: "third".into(),
3648                metrics,
3649                x_mm: 30.0,
3650                y_mm: 0.0,
3651                priority: 0,
3652            },
3653        ];
3654        assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
3655        assert_eq!(placements[0].x_mm, 23.0);
3656        assert_eq!(placements[1].x_mm, 10.0);
3657        assert_eq!(placements[2].x_mm, 36.0);
3658        assert_eq!(
3659            distribute_glyph_spacing(&mut placements, f32::NAN),
3660            Err(GlyphPlacementError::NonFiniteSpacing)
3661        );
3662        let before = placements.clone();
3663        assert_eq!(
3664            distribute_glyph_spacing(&mut placements, f32::MAX),
3665            Err(GlyphPlacementError::NonFiniteSpacing)
3666        );
3667        assert_eq!(placements, before);
3668    }
3669
3670    #[test]
3671    fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
3672        let mut placement = GlyphPlacement {
3673            resource_key: " ".into(),
3674            metrics: GlyphMetrics {
3675                advance_mm: 1.0,
3676                left_mm: 0.0,
3677                top_mm: 0.0,
3678                width_mm: 1.0,
3679                height_mm: 1.0,
3680            },
3681            x_mm: 0.0,
3682            y_mm: 0.0,
3683            priority: 0,
3684        };
3685        assert_eq!(
3686            validate_glyph_placements(&[placement.clone()]),
3687            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3688        );
3689        placement.resource_key = "glyph".into();
3690        placement.metrics.advance_mm = -1.0;
3691        assert_eq!(
3692            validate_glyph_placements(&[placement]),
3693            Err(GlyphPlacementError::NegativeAdvance { index: 0 })
3694        );
3695    }
3696
3697    #[test]
3698    fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
3699        let mut placements = vec![GlyphPlacement {
3700            resource_key: String::new(),
3701            metrics: GlyphMetrics {
3702                advance_mm: 1.0,
3703                left_mm: 0.0,
3704                top_mm: 0.0,
3705                width_mm: 1.0,
3706                height_mm: 1.0,
3707            },
3708            x_mm: 0.0,
3709            y_mm: 0.0,
3710            priority: 0,
3711        }];
3712        assert_eq!(
3713            resolve_glyph_collisions_checked(&mut placements, 1.0),
3714            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3715        );
3716        assert_eq!(
3717            resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
3718            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3719        );
3720        assert_eq!(placements[0].x_mm, 0.0);
3721        assert_eq!(placements[0].y_mm, 0.0);
3722    }
3723
3724    #[test]
3725    fn checked_collision_resolvers_reject_non_finite_gap() {
3726        let metrics = GlyphMetrics {
3727            advance_mm: 1.0,
3728            left_mm: 0.0,
3729            top_mm: 0.0,
3730            width_mm: 1.0,
3731            height_mm: 1.0,
3732        };
3733        let original = vec![GlyphPlacement {
3734            resource_key: "glyph".into(),
3735            metrics,
3736            x_mm: 0.0,
3737            y_mm: 0.0,
3738            priority: 0,
3739        }];
3740        let mut vertical = original.clone();
3741        assert_eq!(
3742            resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
3743            Err(GlyphPlacementError::NonFiniteSpacing)
3744        );
3745        assert_eq!(vertical, original);
3746
3747        let mut horizontal = original.clone();
3748        assert_eq!(
3749            resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
3750            Err(GlyphPlacementError::NonFiniteSpacing)
3751        );
3752        assert_eq!(horizontal, original);
3753    }
3754
3755    #[test]
3756    fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
3757        let metrics = GlyphMetrics {
3758            advance_mm: 1.0,
3759            left_mm: 0.0,
3760            top_mm: 0.0,
3761            width_mm: f32::MAX / 2.0,
3762            height_mm: f32::MAX / 2.0,
3763        };
3764        let original = vec![
3765            GlyphPlacement {
3766                resource_key: "high".into(),
3767                metrics,
3768                x_mm: 0.0,
3769                y_mm: 0.0,
3770                priority: 1,
3771            },
3772            GlyphPlacement {
3773                resource_key: "low".into(),
3774                metrics,
3775                x_mm: 0.0,
3776                y_mm: 0.0,
3777                priority: 0,
3778            },
3779        ];
3780        let mut vertical = original.clone();
3781        assert_eq!(
3782            resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
3783            Err(GlyphPlacementError::NonFinite { index: 1 })
3784        );
3785        assert_eq!(vertical, original);
3786
3787        let mut horizontal = original.clone();
3788        assert_eq!(
3789            resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
3790            Err(GlyphPlacementError::NonFinite { index: 1 })
3791        );
3792        assert_eq!(horizontal, original);
3793    }
3794
3795    #[test]
3796    fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
3797        let metrics = GlyphMetrics {
3798            advance_mm: 1.0,
3799            left_mm: -1.0,
3800            top_mm: -2.0,
3801            width_mm: 3.0,
3802            height_mm: 4.0,
3803        };
3804        let placements = vec![
3805            GlyphPlacement {
3806                resource_key: "a".into(),
3807                metrics,
3808                x_mm: 10.0,
3809                y_mm: 20.0,
3810                priority: 0,
3811            },
3812            GlyphPlacement {
3813                resource_key: "b".into(),
3814                metrics,
3815                x_mm: 30.0,
3816                y_mm: 5.0,
3817                priority: 0,
3818            },
3819        ];
3820        assert_eq!(
3821            glyph_extents(&placements),
3822            Ok(Some(GlyphExtents {
3823                left_mm: 9.0,
3824                top_mm: 3.0,
3825                right_mm: 32.0,
3826                bottom_mm: 22.0,
3827            }))
3828        );
3829        let extents = glyph_extents(&placements).unwrap().unwrap();
3830        assert_eq!(extents.width_mm(), 23.0);
3831        assert_eq!(extents.height_mm(), 19.0);
3832        assert_eq!(glyph_extents(&[]), Ok(None));
3833    }
3834
3835    #[test]
3836    fn glyph_extents_reject_derived_bound_overflow() {
3837        let placements = [GlyphPlacement {
3838            resource_key: "edge".into(),
3839            metrics: GlyphMetrics {
3840                advance_mm: 1.0,
3841                left_mm: 0.0,
3842                top_mm: 0.0,
3843                width_mm: f32::MAX,
3844                height_mm: 1.0,
3845            },
3846            x_mm: f32::MAX,
3847            y_mm: 0.0,
3848            priority: 0,
3849        }];
3850        assert_eq!(
3851            glyph_extents(&placements),
3852            Err(GlyphPlacementError::NonFinite { index: 0 })
3853        );
3854    }
3855}