Skip to main content

acorde_layout/
print.rs

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