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 = 24;
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    /// A span continues across a page boundary and needs a continuation mark in the host.
813    SpanContinuation {
814        span_index: usize,
815        starts_here: bool,
816        ends_here: bool,
817    },
818}
819
820impl PageLayout {
821    /// Return the inclusive physical measure range represented on this page.
822    pub fn measure_span(&self) -> Option<MeasureSpan> {
823        let mut spans = self
824            .systems
825            .iter()
826            .flat_map(|system| system.measure_spans.iter().copied());
827        let first = spans.next()?;
828        Some(spans.fold(first, |range, span| MeasureSpan {
829            first_measure: range.first_measure.min(span.first_measure),
830            last_measure: range.last_measure.max(span.last_measure),
831        }))
832    }
833
834    /// Whether a span continues into or out of another printed page.
835    pub fn has_span_continuation(&self) -> bool {
836        self.span_segments
837            .iter()
838            .any(|segment| !segment.starts_here || !segment.ends_here)
839    }
840}
841
842/// Deterministic page/system geometry for a score.
843#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
844pub struct PrintLayoutResult {
845    pub contract_version: u16,
846    pub pages: Vec<PageLayout>,
847}
848
849impl PrintLayoutResult {
850    /// Validate page and system addresses before consuming a serialized layout.
851    ///
852    /// Layouts produced by [`compute_print_layout`] satisfy this contract. The explicit
853    /// validation is useful for hosts that persist or transport `PrintLayoutResult` values.
854    pub fn validate(&self) -> Result<(), PrintLayoutError> {
855        if self.contract_version != PRINT_LAYOUT_CONTRACT_VERSION {
856            return Err(PrintLayoutError::UnsupportedContractVersion {
857                found: self.contract_version,
858            });
859        }
860        let mut expected_system_index = 0;
861        let mut previous_page_number = None;
862        let mut numbered_pages = None;
863        for (page_index, page) in self.pages.iter().enumerate() {
864            if page.page_index != page_index || page.address.page_index != page_index {
865                return Err(PrintLayoutError::InvalidPageAddress { page_index });
866            }
867            if !page.width_mm.is_finite()
868                || !page.height_mm.is_finite()
869                || page.width_mm <= 0.0
870                || page.height_mm <= 0.0
871                || !page.content_width_mm.is_finite()
872                || !page.content_height_mm.is_finite()
873                || page.content_width_mm <= 0.0
874                || page.content_height_mm <= 0.0
875                || page.content_width_mm > page.width_mm
876                || page.content_height_mm > page.height_mm
877                || !page.bleed_top_mm.is_finite()
878                || !page.bleed_right_mm.is_finite()
879                || !page.bleed_bottom_mm.is_finite()
880                || !page.bleed_left_mm.is_finite()
881                || page.bleed_top_mm < 0.0
882                || page.bleed_right_mm < 0.0
883                || page.bleed_bottom_mm < 0.0
884                || page.bleed_left_mm < 0.0
885            {
886                return Err(PrintLayoutError::InvalidPageGeometry { page_index });
887            }
888            let is_title_break = page.break_reason == BreakReason::TitlePage;
889            if is_title_break != page.publication.is_title_page
890                || (is_title_break && (page_index != 0 || !page.systems.is_empty()))
891            {
892                return Err(PrintLayoutError::InvalidTitlePage { page_index });
893            }
894            match page.page_number {
895                Some(page_number)
896                    if page_number == 0
897                        || numbered_pages == Some(false)
898                        || page_index.checked_add(1) != Some(page_number)
899                        || previous_page_number.is_some_and(|previous| page_number <= previous) =>
900                {
901                    return Err(PrintLayoutError::InvalidPageNumber { page_index });
902                }
903                Some(page_number) => {
904                    numbered_pages = Some(true);
905                    previous_page_number = Some(page_number);
906                }
907                None if numbered_pages == Some(true) => {
908                    return Err(PrintLayoutError::InvalidPageNumber { page_index });
909                }
910                None => numbered_pages = Some(false),
911            }
912            for (index_on_page, system) in page.systems.iter().enumerate() {
913                if system.page_index != page_index
914                    || system.address.page_index != page_index
915                    || system.address.index_on_page != index_on_page
916                    || system.system_index != expected_system_index
917                    || system.address.system_index != expected_system_index
918                {
919                    return Err(PrintLayoutError::InvalidSystemAddress {
920                        page_index,
921                        index_on_page,
922                        system_index: expected_system_index,
923                    });
924                }
925                if !system.top_mm.is_finite()
926                    || system.top_mm < 0.0
927                    || !system.height_mm.is_finite()
928                    || system.height_mm <= 0.0
929                {
930                    return Err(PrintLayoutError::InvalidSystemGeometry {
931                        page_index,
932                        index_on_page,
933                    });
934                }
935                expected_system_index += 1;
936            }
937        }
938        Ok(())
939    }
940
941    /// Retrieve one page artifact by its stable address without recomputing layout.
942    pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
943        self.pages
944            .get(address.page_index)
945            .filter(|page| page.address == address)
946    }
947
948    /// Export validated page descriptors for host renderers and archival backends.
949    ///
950    /// The returned vector preserves physical page order. No filesystem, PDF backend, font
951    /// loader, or renderer-specific object is involved; hosts can serialize or render each
952    /// descriptor independently. Validation happens before any descriptor is returned.
953    pub fn export_page_artifacts(&self) -> Result<Vec<PageArtifact>, PrintLayoutError> {
954        self.validate()?;
955        Ok(self
956            .pages
957            .iter()
958            .map(|page| PageArtifact {
959                address: page.address,
960                page_index: page.page_index,
961                page_number: page.page_number,
962                width_mm: page.width_mm,
963                height_mm: page.height_mm,
964                content_width_mm: page.content_width_mm,
965                content_height_mm: page.content_height_mm,
966                measure_span: page.measure_span(),
967                diagnostics: page
968                    .span_segments
969                    .iter()
970                    .filter(|segment| !segment.starts_here || !segment.ends_here)
971                    .map(|segment| PageArtifactDiagnostic::SpanContinuation {
972                        span_index: segment.span_index,
973                        starts_here: segment.starts_here,
974                        ends_here: segment.ends_here,
975                    })
976                    .collect(),
977                layout: page.clone(),
978            })
979            .collect())
980    }
981}
982
983#[derive(Debug, thiserror::Error, PartialEq)]
984pub enum PrintLayoutError {
985    #[error("paper dimensions must be finite and greater than zero")]
986    InvalidPaperDimensions,
987    #[error("margins must be finite and non-negative")]
988    InvalidMargins,
989    #[error("system height must be finite and greater than zero")]
990    InvalidSystemHeight,
991    #[error("print scale must be finite and greater than zero")]
992    InvalidScale,
993    #[error("margins leave no usable page area")]
994    NoUsablePageArea,
995    #[error("keep-together range is outside the score or reversed")]
996    InvalidKeepTogetherRange,
997    #[error("keep-together range exceeds the measures-per-system capacity")]
998    KeepTogetherExceedsSystemCapacity,
999    #[error("keep-together range conflicts with an explicit system or page break")]
1000    KeepTogetherConflictsWithExplicitBreak,
1001    #[error("repeat section exceeds the systems-per-page capacity")]
1002    RepeatRangeExceedsPageCapacity,
1003    #[error("extracted part index is outside the score")]
1004    InvalidPartIndex,
1005    #[error("publication line height must be finite and greater than zero")]
1006    InvalidPublicationLineHeight,
1007    #[error("host-provided glyph resource key must not be empty")]
1008    InvalidGlyphResourceKey,
1009    #[error("unsupported print layout contract version {found}")]
1010    UnsupportedContractVersion { found: u16 },
1011    #[error("page {page_index} has an inconsistent stable address")]
1012    InvalidPageAddress { page_index: usize },
1013    #[error("page {page_index} has an invalid or non-monotonic page number")]
1014    InvalidPageNumber { page_index: usize },
1015    #[error("page {page_index} has inconsistent title-page metadata")]
1016    InvalidTitlePage { page_index: usize },
1017    #[error(
1018        "system {system_index} at page {page_index}, position {index_on_page} has an inconsistent stable address"
1019    )]
1020    InvalidSystemAddress {
1021        page_index: usize,
1022        index_on_page: usize,
1023        system_index: usize,
1024    },
1025    #[error("page {page_index} has invalid physical geometry")]
1026    InvalidPageGeometry { page_index: usize },
1027    #[error("system at page {page_index}, position {index_on_page} has invalid physical geometry")]
1028    InvalidSystemGeometry {
1029        page_index: usize,
1030        index_on_page: usize,
1031    },
1032}
1033
1034fn apply_keep_together(
1035    score: &Score,
1036    mut rows: Vec<crate::RowLayout>,
1037    ranges: &[KeepTogetherRange],
1038    capacity: usize,
1039) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
1040    let measure_count = score
1041        .parts
1042        .first()
1043        .and_then(|part| part.staves.first())
1044        .map(|staff| staff.measures.len())
1045        .unwrap_or(0);
1046    for range in ranges {
1047        let length = range
1048            .last_measure
1049            .checked_sub(range.first_measure)
1050            .and_then(|length| length.checked_add(1));
1051        if range.first_measure > range.last_measure || range.last_measure >= measure_count {
1052            return Err(PrintLayoutError::InvalidKeepTogetherRange);
1053        }
1054        if length.is_none_or(|length| length > capacity) {
1055            return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1056        }
1057        for measure_index in range.first_measure..range.last_measure {
1058            let has_break = score
1059                .parts
1060                .iter()
1061                .flat_map(|part| part.staves.iter())
1062                .filter_map(|staff| staff.measures.get(measure_index))
1063                .any(|measure| measure.system_break || measure.page_break);
1064            if has_break {
1065                return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
1066            }
1067        }
1068
1069        // Split at the range boundaries before merging rows. This allows a range that
1070        // crosses an existing system boundary to be reflowed without pulling unrelated
1071        // measures into the merged system.
1072        let mut split_rows = Vec::with_capacity(rows.len() + 2);
1073        for row in rows {
1074            let mut cuts = vec![0, row.measure_indices.len()];
1075            if let Some(position) = row
1076                .measure_indices
1077                .iter()
1078                .position(|&index| index == range.first_measure)
1079            {
1080                cuts.push(position);
1081            }
1082            if let Some(position) = row
1083                .measure_indices
1084                .iter()
1085                .position(|&index| index == range.last_measure)
1086            {
1087                cuts.push(position + 1);
1088            }
1089            cuts.sort_unstable();
1090            cuts.dedup();
1091            for window in cuts.windows(2) {
1092                if window[0] < window[1] {
1093                    split_rows.push(crate::RowLayout {
1094                        measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
1095                    });
1096                }
1097            }
1098        }
1099        rows = split_rows;
1100
1101        let first_row = rows
1102            .iter()
1103            .position(|row| row.measure_indices.contains(&range.first_measure));
1104        let last_row = rows
1105            .iter()
1106            .position(|row| row.measure_indices.contains(&range.last_measure));
1107        let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
1108            return Err(PrintLayoutError::InvalidKeepTogetherRange);
1109        };
1110
1111        if first_row != last_row {
1112            let merged: Vec<usize> = rows[first_row..=last_row]
1113                .iter()
1114                .flat_map(|row| row.measure_indices.iter().copied())
1115                .collect();
1116            if merged.len() > capacity {
1117                return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1118            }
1119            rows.splice(
1120                first_row..=last_row,
1121                [crate::RowLayout {
1122                    measure_indices: merged,
1123                }],
1124            );
1125        }
1126
1127        let row_index = rows
1128            .iter()
1129            .position(|row| row.measure_indices.contains(&range.first_measure))
1130            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1131        let row = rows.remove(row_index);
1132        let start = row
1133            .measure_indices
1134            .iter()
1135            .position(|&index| index == range.first_measure)
1136            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1137        let end = row
1138            .measure_indices
1139            .iter()
1140            .position(|&index| index == range.last_measure)
1141            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1142        let mut replacement = Vec::new();
1143        if start > 0 {
1144            replacement.push(crate::RowLayout {
1145                measure_indices: row.measure_indices[..start].to_vec(),
1146            });
1147        }
1148        replacement.push(crate::RowLayout {
1149            measure_indices: row.measure_indices[start..=end].to_vec(),
1150        });
1151        if end + 1 < row.measure_indices.len() {
1152            replacement.push(crate::RowLayout {
1153                measure_indices: row.measure_indices[end + 1..].to_vec(),
1154            });
1155        }
1156        rows.splice(row_index..row_index, replacement);
1157    }
1158    Ok(rows)
1159}
1160
1161fn has_first_measure_pickup(score: &Score) -> bool {
1162    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1163        return false;
1164    };
1165    let Some(measure) = staff.measures.first() else {
1166        return false;
1167    };
1168    let expected = measure
1169        .time_sig
1170        .as_ref()
1171        .unwrap_or(&score.settings.time_signature)
1172        .total_beats();
1173    let actual = measure
1174        .voices
1175        .iter()
1176        .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
1177        .fold(0.0, f64::max);
1178    actual > 1e-9 && actual + 1e-9 < expected
1179}
1180
1181fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
1182    let measure_count = score
1183        .parts
1184        .first()
1185        .and_then(|part| part.staves.first())
1186        .map(|staff| staff.measures.len())
1187        .unwrap_or(0);
1188    measure_indices
1189        .iter()
1190        .filter_map(|&first_measure| {
1191            if first_measure >= measure_count {
1192                return None;
1193            }
1194            let count = score
1195                .parts
1196                .iter()
1197                .flat_map(|part| part.staves.iter())
1198                .filter_map(|staff| staff.measures.get(first_measure))
1199                .filter_map(|measure| measure.multi_rest_count)
1200                .map(usize::from)
1201                .max()
1202                .unwrap_or(1)
1203                .max(1);
1204            Some(MeasureSpan {
1205                first_measure,
1206                last_measure: first_measure
1207                    .saturating_add(count.saturating_sub(1))
1208                    .min(measure_count.saturating_sub(1)),
1209            })
1210        })
1211        .collect()
1212}
1213
1214fn span_bounds(span: &SpanMark) -> (usize, usize) {
1215    match span {
1216        SpanMark::Hairpin { start, end, .. }
1217        | SpanMark::Ottava { start, end, .. }
1218        | SpanMark::Pedal { start, end }
1219        | SpanMark::Slur { start, end }
1220        | SpanMark::TrillLine { start, end }
1221        | SpanMark::Glissando { start, end } => (
1222            start.measure.min(end.measure),
1223            start.measure.max(end.measure),
1224        ),
1225    }
1226}
1227
1228fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
1229    let (Some(&first_measure), Some(&last_measure)) =
1230        (measure_indices.first(), measure_indices.last())
1231    else {
1232        return Vec::new();
1233    };
1234    spans
1235        .iter()
1236        .enumerate()
1237        .filter_map(|(span_index, span)| {
1238            let (start_measure, end_measure) = span_bounds(span);
1239            (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
1240                span_index,
1241                starts_here: (first_measure..=last_measure).contains(&start_measure),
1242                ends_here: (first_measure..=last_measure).contains(&end_measure),
1243            })
1244        })
1245        .collect()
1246}
1247
1248fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
1249    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1250        return Vec::new();
1251    };
1252    measure_indices
1253        .iter()
1254        .filter_map(|&measure_index| {
1255            let measure = staff.measures.get(measure_index)?;
1256            let repeat_start = matches!(
1257                measure.barline_left,
1258                Barline::RepeatStart | Barline::RepeatBoth
1259            );
1260            let repeat_end = matches!(
1261                measure.barline_right,
1262                Barline::RepeatEnd | Barline::RepeatBoth
1263            );
1264            let has_mark = repeat_start
1265                || repeat_end
1266                || measure.volta.is_some()
1267                || measure.navigation.is_some()
1268                || measure.rehearsal.is_some();
1269            has_mark.then(|| MeasureMark {
1270                measure_index,
1271                repeat_start,
1272                repeat_end,
1273                volta_number: measure.volta.as_ref().map(|volta| volta.number),
1274                volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
1275                navigation: measure.navigation.clone(),
1276                rehearsal: measure.rehearsal.clone(),
1277            })
1278        })
1279        .collect()
1280}
1281
1282fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1283    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1284        return Vec::new();
1285    };
1286    let mut ranges = Vec::new();
1287    let mut start = None;
1288    for (index, measure) in staff.measures.iter().enumerate() {
1289        let Some(volta) = measure.volta.as_ref() else {
1290            continue;
1291        };
1292        if matches!(volta.kind.as_str(), "begin" | "begin_end") {
1293            start = Some(index);
1294        }
1295        if matches!(volta.kind.as_str(), "end" | "begin_end")
1296            && let Some(first_measure) = start.take()
1297        {
1298            ranges.push(KeepTogetherRange {
1299                first_measure,
1300                last_measure: index,
1301            });
1302        }
1303    }
1304    ranges
1305}
1306
1307fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1308    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1309        return Vec::new();
1310    };
1311    let mut ranges = Vec::new();
1312    let mut start = None;
1313    for (index, measure) in staff.measures.iter().enumerate() {
1314        if matches!(
1315            measure.barline_left,
1316            Barline::RepeatStart | Barline::RepeatBoth
1317        ) {
1318            start = Some(index);
1319        }
1320        if matches!(
1321            measure.barline_right,
1322            Barline::RepeatEnd | Barline::RepeatBoth
1323        ) {
1324            ranges.push(KeepTogetherRange {
1325                first_measure: start.take().unwrap_or(0),
1326                last_measure: index,
1327            });
1328        }
1329    }
1330    ranges
1331}
1332
1333fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
1334    repeat_ranges(score)
1335        .into_iter()
1336        .filter_map(|range| {
1337            let first = rows
1338                .iter()
1339                .position(|row| row.measure_indices.contains(&range.first_measure))?;
1340            let last = rows
1341                .iter()
1342                .position(|row| row.measure_indices.contains(&range.last_measure))?;
1343            Some((first, last))
1344        })
1345        .collect()
1346}
1347
1348fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
1349    let mut segments = Vec::new();
1350    for system in systems {
1351        for segment in &system.span_segments {
1352            if let Some(existing) = segments
1353                .iter_mut()
1354                .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
1355            {
1356                existing.ends_here |= segment.ends_here;
1357            } else {
1358                segments.push(PageSpanSegment {
1359                    span_index: segment.span_index,
1360                    starts_here: segment.starts_here,
1361                    ends_here: segment.ends_here,
1362                });
1363            }
1364        }
1365    }
1366    segments
1367}
1368
1369fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
1370    systems
1371        .iter()
1372        .flat_map(|system| system.measure_marks.iter().cloned())
1373        .collect()
1374}
1375
1376fn page_publication(
1377    score: &Score,
1378    measure_score: &Score,
1379    config: &PrintConfig,
1380    systems: &[SystemLayout],
1381    is_title_page: bool,
1382    page_number: Option<usize>,
1383) -> PagePublication {
1384    let metadata = &score.metadata;
1385    let part_labels = if config.publication.show_part_names {
1386        let parts = match config.part_layout {
1387            PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
1388            PartLayoutPolicy::ExtractedPart { part_index } => score
1389                .parts
1390                .get(part_index)
1391                .into_iter()
1392                .enumerate()
1393                .map(|(index, part)| (part_index + index, part))
1394                .collect(),
1395        };
1396        parts
1397            .into_iter()
1398            .map(|(part_index, part)| PartLabel {
1399                part_index,
1400                name: part.name.clone(),
1401                short_name: part.short_name.clone(),
1402            })
1403            .collect()
1404    } else {
1405        Vec::new()
1406    };
1407    let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
1408        score
1409            .part_groups
1410            .iter()
1411            .map(|group| PartGroupMark {
1412                first_part: group.first_part,
1413                last_part: group.last_part,
1414                symbol: group.symbol.clone(),
1415                barlines_connect: group.barlines_connect,
1416            })
1417            .collect()
1418    } else {
1419        Vec::new()
1420    };
1421    let measure_numbers = if config.publication.show_measure_numbers {
1422        let staff = measure_score
1423            .parts
1424            .first()
1425            .and_then(|part| part.staves.first());
1426        systems
1427            .iter()
1428            .flat_map(|system| system.measure_indices.iter().copied())
1429            .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
1430            .map(|measure| measure.number)
1431            .collect()
1432    } else {
1433        Vec::new()
1434    };
1435    let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1436    let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
1437        (paper_height, paper_width)
1438    } else {
1439        (paper_width, paper_height)
1440    };
1441    let mut text_blocks = Vec::new();
1442    if !is_title_page
1443        && let Some(text) = config
1444            .publication
1445            .header_text
1446            .as_ref()
1447            .or(config.publication.running_title.as_ref())
1448    {
1449        text_blocks.push(PublicationTextBlock {
1450            role: PublicationTextRole::Header,
1451            text: text.clone(),
1452            x_mm: config.margin_left_mm + config.safe_left_mm,
1453            y_mm: config.margin_top_mm,
1454            width_mm: page_width
1455                - config.margin_left_mm
1456                - config.margin_right_mm
1457                - config.safe_left_mm
1458                - config.safe_right_mm,
1459            height_mm: config.publication.line_height_mm,
1460            alignment: config.publication.header_alignment,
1461        });
1462    }
1463    if let Some(text) = config.publication.footer_text.as_ref() {
1464        text_blocks.push(PublicationTextBlock {
1465            role: PublicationTextRole::Footer,
1466            text: text.clone(),
1467            x_mm: config.margin_left_mm + config.safe_left_mm,
1468            y_mm: page_height - config.margin_bottom_mm,
1469            width_mm: page_width
1470                - config.margin_left_mm
1471                - config.margin_right_mm
1472                - config.safe_left_mm
1473                - config.safe_right_mm,
1474            height_mm: config.publication.line_height_mm,
1475            alignment: config.publication.footer_alignment,
1476        });
1477    }
1478    if config.publication.page_number_in_footer {
1479        if let Some(page_number) = page_number {
1480            let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1481            let (page_width, page_height) =
1482                if matches!(config.orientation, PageOrientation::Landscape) {
1483                    (paper_height, paper_width)
1484                } else {
1485                    (paper_width, paper_height)
1486                };
1487            text_blocks.push(PublicationTextBlock {
1488                role: PublicationTextRole::Footer,
1489                text: page_number.to_string(),
1490                x_mm: config.margin_left_mm + config.safe_left_mm,
1491                y_mm: page_height - config.margin_bottom_mm,
1492                width_mm: page_width
1493                    - config.margin_left_mm
1494                    - config.margin_right_mm
1495                    - config.safe_left_mm
1496                    - config.safe_right_mm,
1497                height_mm: config.publication.line_height_mm,
1498                alignment: config.publication.footer_alignment,
1499            });
1500        }
1501    }
1502    if is_title_page {
1503        let content_height = page_height
1504            - config.margin_top_mm
1505            - config.margin_bottom_mm
1506            - config.safe_top_mm
1507            - config.safe_bottom_mm;
1508        let title_x = config.margin_left_mm + config.safe_left_mm;
1509        let title_width = page_width
1510            - config.margin_left_mm
1511            - config.margin_right_mm
1512            - config.safe_left_mm
1513            - config.safe_right_mm;
1514        let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
1515        if !metadata.title.trim().is_empty() {
1516            text_blocks.push(PublicationTextBlock {
1517                role: PublicationTextRole::Title,
1518                text: metadata.title.clone(),
1519                x_mm: title_x,
1520                y_mm: title_y,
1521                width_mm: title_width,
1522                height_mm: config.publication.line_height_mm,
1523                alignment: config.publication.title_alignment,
1524            });
1525        }
1526        if !metadata.movement_title.trim().is_empty() {
1527            text_blocks.push(PublicationTextBlock {
1528                role: PublicationTextRole::Subtitle,
1529                text: metadata.movement_title.clone(),
1530                x_mm: title_x,
1531                y_mm: title_y + config.publication.line_height_mm * 2.5,
1532                width_mm: title_width,
1533                height_mm: config.publication.line_height_mm,
1534                alignment: config.publication.title_alignment,
1535            });
1536        }
1537        let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
1538            (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
1539                format!("{composer} / {lyricist}")
1540            }
1541            (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
1542            (_, lyricist) => lyricist.to_string(),
1543        };
1544        if !credit.is_empty() {
1545            text_blocks.push(PublicationTextBlock {
1546                role: PublicationTextRole::Credit,
1547                text: credit,
1548                x_mm: title_x,
1549                y_mm: title_y + config.publication.line_height_mm * 5.0,
1550                width_mm: title_width,
1551                height_mm: config.publication.line_height_mm,
1552                alignment: config.publication.title_alignment,
1553            });
1554        }
1555        if !metadata.copyright.trim().is_empty() {
1556            text_blocks.push(PublicationTextBlock {
1557                role: PublicationTextRole::Copyright,
1558                text: metadata.copyright.clone(),
1559                x_mm: title_x,
1560                y_mm: page_height - config.margin_bottom_mm,
1561                width_mm: title_width,
1562                height_mm: config.publication.line_height_mm,
1563                alignment: config.publication.title_alignment,
1564            });
1565        }
1566    }
1567    PagePublication {
1568        is_title_page,
1569        title: metadata.title.clone(),
1570        movement_title: metadata.movement_title.clone(),
1571        composer: metadata.composer.clone(),
1572        lyricist: metadata.lyricist.clone(),
1573        copyright: metadata.copyright.clone(),
1574        running_title: config.publication.running_title.clone(),
1575        part_labels,
1576        part_groups,
1577        measure_numbers,
1578        text_blocks,
1579    }
1580}
1581
1582fn score_for_part_layout(
1583    score: &Score,
1584    policy: PartLayoutPolicy,
1585) -> Result<Score, PrintLayoutError> {
1586    let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
1587        return Ok(score.clone());
1588    };
1589    let Some(part) = score.parts.get(part_index) else {
1590        return Err(PrintLayoutError::InvalidPartIndex);
1591    };
1592    let mut selected = score.clone();
1593    selected.parts = vec![part.clone()];
1594    selected.part_groups.clear();
1595    Ok(selected)
1596}
1597
1598/// Compute physical page and system placement without rendering or host integration.
1599pub fn compute_print_layout(
1600    score: &Score,
1601    config: &PrintConfig,
1602) -> Result<PrintLayoutResult, PrintLayoutError> {
1603    let layout_score = score_for_part_layout(score, config.part_layout)?;
1604    let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
1605    if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
1606        return Err(PrintLayoutError::InvalidPaperDimensions);
1607    }
1608    if matches!(config.orientation, PageOrientation::Landscape) {
1609        std::mem::swap(&mut width_mm, &mut height_mm);
1610    }
1611
1612    let margins = [
1613        config.margin_top_mm,
1614        config.margin_right_mm,
1615        config.margin_bottom_mm,
1616        config.margin_left_mm,
1617        config.bleed_top_mm,
1618        config.bleed_right_mm,
1619        config.bleed_bottom_mm,
1620        config.bleed_left_mm,
1621        config.safe_top_mm,
1622        config.safe_right_mm,
1623        config.safe_bottom_mm,
1624        config.safe_left_mm,
1625    ];
1626    if margins
1627        .iter()
1628        .any(|value| !value.is_finite() || *value < 0.0)
1629    {
1630        return Err(PrintLayoutError::InvalidMargins);
1631    }
1632    if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
1633        return Err(PrintLayoutError::InvalidSystemHeight);
1634    }
1635    if !config.scale.is_finite() || config.scale <= 0.0 {
1636        return Err(PrintLayoutError::InvalidScale);
1637    }
1638    let scaled_system_height_mm = config.system_height_mm * config.scale;
1639    if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
1640        return Err(PrintLayoutError::InvalidScale);
1641    }
1642    if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
1643        return Err(PrintLayoutError::InvalidPublicationLineHeight);
1644    }
1645    if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
1646    {
1647        return Err(PrintLayoutError::InvalidGlyphResourceKey);
1648    }
1649    if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
1650        return Err(PrintLayoutError::InvalidPublicationLineHeight);
1651    }
1652
1653    let content_width_mm = width_mm
1654        - config.margin_left_mm
1655        - config.margin_right_mm
1656        - config.safe_left_mm
1657        - config.safe_right_mm;
1658    let content_height_mm = height_mm
1659        - config.margin_top_mm
1660        - config.margin_bottom_mm
1661        - config.safe_top_mm
1662        - config.safe_bottom_mm;
1663    if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
1664        return Err(PrintLayoutError::NoUsablePageArea);
1665    }
1666
1667    let systems_per_page = config
1668        .systems_per_page
1669        .unwrap_or_else(|| {
1670            (content_height_mm / scaled_system_height_mm)
1671                .floor()
1672                .max(1.0) as usize
1673        })
1674        .max(1);
1675    let layout = compute_layout(
1676        &layout_score,
1677        &LayoutConfig {
1678            measures_per_row: config.measures_per_system.max(1),
1679            first_row_measures: config.first_system_measures.or_else(|| {
1680                (matches!(
1681                    config.pickup_policy,
1682                    PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
1683                ) && has_first_measure_pickup(&layout_score))
1684                .then_some(1)
1685            }),
1686            ..LayoutConfig::default()
1687        },
1688    );
1689
1690    let mut keep_together = config.keep_together.clone();
1691    if matches!(
1692        config.notation_break_policy,
1693        NotationBreakPolicy::KeepVoltaTogether
1694    ) {
1695        keep_together.extend(volta_ranges(&layout_score));
1696    }
1697    let rows = apply_keep_together(
1698        &layout_score,
1699        layout.rows,
1700        &keep_together,
1701        config.measures_per_system.max(1),
1702    )?;
1703
1704    let has_explicit_page_break = rows.iter().any(|row| {
1705        row.measure_indices.last().is_some_and(|&measure_index| {
1706            layout_score
1707                .parts
1708                .iter()
1709                .flat_map(|part| part.staves.iter())
1710                .filter_map(|staff| staff.measures.get(measure_index))
1711                .any(|measure| measure.page_break)
1712        })
1713    });
1714    let repeat_system_ranges = if matches!(
1715        config.notation_break_policy,
1716        NotationBreakPolicy::KeepRepeatsTogether
1717    ) {
1718        repeat_system_ranges(&layout_score, &rows)
1719    } else {
1720        Vec::new()
1721    };
1722    if repeat_system_ranges
1723        .iter()
1724        .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
1725    {
1726        return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
1727    }
1728    let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
1729        && !has_explicit_page_break
1730        && systems_per_page > 1
1731        && rows.len() > systems_per_page
1732        && repeat_system_ranges.is_empty()
1733    {
1734        let page_count = rows.len().div_ceil(systems_per_page);
1735        let base = rows.len() / page_count;
1736        let remainder = rows.len() % page_count;
1737        (0..page_count)
1738            .map(|index| base + usize::from(index < remainder))
1739            .collect::<Vec<_>>()
1740    } else {
1741        Vec::new()
1742    };
1743
1744    let mut pages = Vec::new();
1745    let mut page_systems = Vec::new();
1746    let mut page_index = 0;
1747    for (system_index, row) in rows.iter().enumerate() {
1748        let repeat_starts_here = repeat_system_ranges
1749            .iter()
1750            .any(|(first, _)| *first == system_index);
1751        if repeat_starts_here && !page_systems.is_empty() {
1752            pages.push(PageLayout {
1753                address: PageAddress { page_index },
1754                page_index,
1755                page_number: match config.page_numbering {
1756                    PageNumbering::None => None,
1757                    PageNumbering::OneBased => Some(page_index + 1),
1758                },
1759                color_policy: config.color_policy,
1760                crop_mark_policy: config.crop_mark_policy,
1761                glyph_resources: config.glyph_resources.clone(),
1762                publication: page_publication(
1763                    score,
1764                    &layout_score,
1765                    config,
1766                    &page_systems,
1767                    false,
1768                    match config.page_numbering {
1769                        PageNumbering::None => None,
1770                        PageNumbering::OneBased => Some(page_index + 1),
1771                    },
1772                ),
1773                width_mm,
1774                height_mm,
1775                content_width_mm,
1776                content_height_mm,
1777                bleed_top_mm: config.bleed_top_mm,
1778                bleed_right_mm: config.bleed_right_mm,
1779                bleed_bottom_mm: config.bleed_bottom_mm,
1780                bleed_left_mm: config.bleed_left_mm,
1781                span_segments: page_span_segments(&page_systems),
1782                measure_marks: page_measure_marks(&page_systems),
1783                systems: std::mem::take(&mut page_systems),
1784                break_reason: BreakReason::PageCapacity,
1785            });
1786            page_index += 1;
1787        }
1788        let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
1789            layout_score
1790                .parts
1791                .iter()
1792                .flat_map(|part| part.staves.iter())
1793                .filter_map(|staff| staff.measures.get(measure_index))
1794                .any(|measure| measure.page_break)
1795        });
1796        let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
1797            layout_score
1798                .parts
1799                .iter()
1800                .flat_map(|part| part.staves.iter())
1801                .filter_map(|staff| staff.measures.get(measure_index))
1802                .any(|measure| measure.system_break)
1803        });
1804        let is_last_system = system_index + 1 == rows.len();
1805        let break_reason = if explicit_page_break {
1806            BreakReason::ExplicitPageBreak
1807        } else if explicit_system_break {
1808            BreakReason::ExplicitSystemBreak
1809        } else if is_last_system {
1810            BreakReason::EndOfScore
1811        } else {
1812            BreakReason::MeasureCapacity
1813        };
1814        let system = SystemLayout {
1815            address: SystemAddress {
1816                system_index,
1817                page_index,
1818                index_on_page: page_systems.len(),
1819            },
1820            system_index,
1821            page_index,
1822            measure_indices: row.measure_indices.clone(),
1823            measure_spans: measure_spans(&layout_score, &row.measure_indices),
1824            span_segments: span_segments(&layout.spans, &row.measure_indices),
1825            measure_marks: measure_marks(&layout_score, &row.measure_indices),
1826            top_mm: config.margin_top_mm
1827                + config.safe_top_mm
1828                + page_systems.len() as f32 * scaled_system_height_mm,
1829            height_mm: scaled_system_height_mm,
1830            break_reason,
1831        };
1832        page_systems.push(system);
1833
1834        let page_capacity = page_capacities
1835            .get(page_index)
1836            .copied()
1837            .unwrap_or(systems_per_page);
1838        let page_is_full = page_systems.len() >= page_capacity;
1839        if page_is_full || explicit_page_break {
1840            let page_break_reason = if explicit_page_break {
1841                BreakReason::ExplicitPageBreak
1842            } else if is_last_system {
1843                BreakReason::EndOfScore
1844            } else {
1845                BreakReason::PageCapacity
1846            };
1847            pages.push(PageLayout {
1848                address: PageAddress { page_index },
1849                page_index,
1850                page_number: match config.page_numbering {
1851                    PageNumbering::None => None,
1852                    PageNumbering::OneBased => Some(page_index + 1),
1853                },
1854                color_policy: config.color_policy,
1855                crop_mark_policy: config.crop_mark_policy,
1856                glyph_resources: config.glyph_resources.clone(),
1857                publication: page_publication(
1858                    score,
1859                    &layout_score,
1860                    config,
1861                    &page_systems,
1862                    false,
1863                    match config.page_numbering {
1864                        PageNumbering::None => None,
1865                        PageNumbering::OneBased => Some(page_index + 1),
1866                    },
1867                ),
1868                width_mm,
1869                height_mm,
1870                content_width_mm,
1871                content_height_mm,
1872                bleed_top_mm: config.bleed_top_mm,
1873                bleed_right_mm: config.bleed_right_mm,
1874                bleed_bottom_mm: config.bleed_bottom_mm,
1875                bleed_left_mm: config.bleed_left_mm,
1876                span_segments: page_span_segments(&page_systems),
1877                measure_marks: page_measure_marks(&page_systems),
1878                systems: std::mem::take(&mut page_systems),
1879                break_reason: page_break_reason,
1880            });
1881            page_index += 1;
1882        }
1883    }
1884    if !page_systems.is_empty() || pages.is_empty() {
1885        pages.push(PageLayout {
1886            address: PageAddress { page_index },
1887            page_index,
1888            page_number: match config.page_numbering {
1889                PageNumbering::None => None,
1890                PageNumbering::OneBased => Some(page_index + 1),
1891            },
1892            color_policy: config.color_policy,
1893            crop_mark_policy: config.crop_mark_policy,
1894            glyph_resources: config.glyph_resources.clone(),
1895            publication: page_publication(
1896                score,
1897                &layout_score,
1898                config,
1899                &page_systems,
1900                false,
1901                match config.page_numbering {
1902                    PageNumbering::None => None,
1903                    PageNumbering::OneBased => Some(page_index + 1),
1904                },
1905            ),
1906            width_mm,
1907            height_mm,
1908            content_width_mm,
1909            content_height_mm,
1910            bleed_top_mm: config.bleed_top_mm,
1911            bleed_right_mm: config.bleed_right_mm,
1912            bleed_bottom_mm: config.bleed_bottom_mm,
1913            bleed_left_mm: config.bleed_left_mm,
1914            span_segments: page_span_segments(&page_systems),
1915            measure_marks: page_measure_marks(&page_systems),
1916            systems: page_systems,
1917            break_reason: BreakReason::EndOfScore,
1918        });
1919    }
1920
1921    if config.publication.title_page {
1922        for page in &mut pages {
1923            page.page_index += 1;
1924            page.address.page_index = page.page_index;
1925            page.page_number = match config.page_numbering {
1926                PageNumbering::None => None,
1927                PageNumbering::OneBased => Some(page.page_index + 1),
1928            };
1929            for system in &mut page.systems {
1930                system.page_index += 1;
1931                system.address.page_index = system.page_index;
1932            }
1933            page.publication = page_publication(
1934                score,
1935                &layout_score,
1936                config,
1937                &page.systems,
1938                false,
1939                page.page_number,
1940            );
1941        }
1942        pages.insert(
1943            0,
1944            PageLayout {
1945                address: PageAddress { page_index: 0 },
1946                page_index: 0,
1947                page_number: match config.page_numbering {
1948                    PageNumbering::None => None,
1949                    PageNumbering::OneBased => Some(1),
1950                },
1951                color_policy: config.color_policy,
1952                crop_mark_policy: config.crop_mark_policy,
1953                glyph_resources: config.glyph_resources.clone(),
1954                publication: page_publication(
1955                    score,
1956                    &layout_score,
1957                    config,
1958                    &[],
1959                    true,
1960                    match config.page_numbering {
1961                        PageNumbering::None => None,
1962                        PageNumbering::OneBased => Some(1),
1963                    },
1964                ),
1965                width_mm,
1966                height_mm,
1967                content_width_mm,
1968                content_height_mm,
1969                bleed_top_mm: config.bleed_top_mm,
1970                bleed_right_mm: config.bleed_right_mm,
1971                bleed_bottom_mm: config.bleed_bottom_mm,
1972                bleed_left_mm: config.bleed_left_mm,
1973                span_segments: Vec::new(),
1974                measure_marks: Vec::new(),
1975                systems: Vec::new(),
1976                break_reason: BreakReason::TitlePage,
1977            },
1978        );
1979    }
1980
1981    Ok(PrintLayoutResult {
1982        contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
1983        pages,
1984    })
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989    use super::*;
1990    use acorde_core::{
1991        Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score, Staff, Step,
1992    };
1993
1994    fn score_with_measures(count: usize) -> Score {
1995        let mut score = Score::default();
1996        let mut part = Part::new("Piano", "Pno.");
1997        let mut staff = Staff::new(Clef::Treble);
1998        staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
1999        part.staves = vec![staff];
2000        score.parts = vec![part];
2001        score
2002    }
2003
2004    #[test]
2005    fn publication_metadata_accepts_legacy_partial_json() {
2006        let publication: PagePublication =
2007            serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
2008                .expect("legacy publication metadata should deserialize");
2009
2010        assert!(publication.is_title_page);
2011        assert_eq!(publication.title, "Legacy score");
2012        assert!(publication.movement_title.is_empty());
2013        assert!(publication.part_labels.is_empty());
2014        assert!(publication.part_groups.is_empty());
2015        assert!(publication.text_blocks.is_empty());
2016    }
2017
2018    #[test]
2019    fn layout_validation_rejects_unsupported_contract_version() {
2020        let score = score_with_measures(1);
2021        let mut result =
2022            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2023        result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
2024
2025        assert_eq!(
2026            result.validate(),
2027            Err(PrintLayoutError::UnsupportedContractVersion {
2028                found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
2029            })
2030        );
2031    }
2032
2033    #[test]
2034    fn paginates_rows_and_preserves_measure_indices() {
2035        let score = score_with_measures(5);
2036        let result = compute_print_layout(
2037            &score,
2038            &PrintConfig {
2039                measures_per_system: 2,
2040                systems_per_page: Some(2),
2041                ..PrintConfig::default()
2042            },
2043        )
2044        .expect("valid print config");
2045        assert_eq!(result.pages.len(), 2);
2046        assert_eq!(
2047            result.pages[0]
2048                .systems
2049                .iter()
2050                .map(|s| s.measure_indices.clone())
2051                .collect::<Vec<_>>(),
2052            vec![vec![0, 1], vec![2, 3]]
2053        );
2054        assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
2055        assert_eq!(result.pages[1].systems[0].page_index, 1);
2056        assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
2057        assert_eq!(
2058            result.pages[1].systems[0].break_reason,
2059            BreakReason::EndOfScore
2060        );
2061        assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
2062    }
2063
2064    #[test]
2065    fn forced_page_break_starts_next_system_on_next_page() {
2066        let mut score = score_with_measures(3);
2067        score.parts[0].staves[0].measures[0].page_break = true;
2068        let result = compute_print_layout(
2069            &score,
2070            &PrintConfig {
2071                measures_per_system: 3,
2072                systems_per_page: Some(8),
2073                ..PrintConfig::default()
2074            },
2075        )
2076        .expect("valid print config");
2077        assert_eq!(result.pages.len(), 2);
2078        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2079        assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
2080        assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
2081        assert_eq!(
2082            result.pages[0].systems[0].break_reason,
2083            BreakReason::ExplicitPageBreak
2084        );
2085    }
2086
2087    #[test]
2088    fn keep_together_range_is_not_split_across_systems() {
2089        let score = score_with_measures(5);
2090        let result = compute_print_layout(
2091            &score,
2092            &PrintConfig {
2093                measures_per_system: 3,
2094                systems_per_page: Some(8),
2095                keep_together: vec![KeepTogetherRange {
2096                    first_measure: 1,
2097                    last_measure: 2,
2098                }],
2099                ..PrintConfig::default()
2100            },
2101        )
2102        .expect("valid keep-together range");
2103        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2104        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2105        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
2106    }
2107
2108    #[test]
2109    fn first_system_measure_capacity_is_preserved_in_print_layout() {
2110        let score = score_with_measures(5);
2111        let result = compute_print_layout(
2112            &score,
2113            &PrintConfig {
2114                measures_per_system: 3,
2115                first_system_measures: Some(1),
2116                systems_per_page: Some(8),
2117                ..PrintConfig::default()
2118            },
2119        )
2120        .expect("valid first-system capacity");
2121        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2122        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2123        assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
2124    }
2125
2126    #[test]
2127    fn pickup_policy_isolates_a_partial_first_measure() {
2128        let mut score = score_with_measures(4);
2129        score.parts[0].staves[0].measures[0].voices[0] =
2130            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2131        let result = compute_print_layout(
2132            &score,
2133            &PrintConfig {
2134                measures_per_system: 3,
2135                pickup_policy: PickupPolicy::DetectFirstMeasure,
2136                systems_per_page: Some(8),
2137                ..PrintConfig::default()
2138            },
2139        )
2140        .expect("valid pickup policy");
2141        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2142        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2143    }
2144
2145    #[test]
2146    fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
2147        let mut score = score_with_measures(4);
2148        score.parts[0].staves[0].measures[0].voices[0] =
2149            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2150        let result = compute_print_layout(
2151            &score,
2152            &PrintConfig {
2153                measures_per_system: 3,
2154                systems_per_page: Some(8),
2155                ..PrintConfig::default()
2156            },
2157        )
2158        .expect("valid automatic pickup policy");
2159        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2160        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2161    }
2162
2163    #[test]
2164    fn system_exposes_physical_span_for_multi_rest_slot() {
2165        let mut score = score_with_measures(6);
2166        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2167        let result = compute_print_layout(&score, &PrintConfig::default())
2168            .expect("valid multi-rest print layout");
2169        assert_eq!(
2170            result.pages[0].systems[0].measure_spans[1],
2171            MeasureSpan {
2172                first_measure: 1,
2173                last_measure: 3,
2174            }
2175        );
2176    }
2177
2178    #[test]
2179    fn multirest_width_drives_system_breaking_without_splitting() {
2180        let mut score = score_with_measures(5);
2181        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2182        let result = compute_print_layout(
2183            &score,
2184            &PrintConfig {
2185                measures_per_system: 2,
2186                pickup_policy: PickupPolicy::Preserve,
2187                systems_per_page: Some(8),
2188                ..PrintConfig::default()
2189            },
2190        )
2191        .expect("valid multi-rest pagination");
2192        assert_eq!(
2193            result.pages[0]
2194                .systems
2195                .iter()
2196                .map(|system| system.measure_indices.clone())
2197                .collect::<Vec<_>>(),
2198            vec![vec![0], vec![1], vec![2, 3], vec![4]]
2199        );
2200        assert_eq!(
2201            result.pages[0].systems[1].measure_spans[0],
2202            MeasureSpan {
2203                first_measure: 1,
2204                last_measure: 3,
2205            }
2206        );
2207    }
2208
2209    #[test]
2210    fn system_exposes_cross_system_span_segments() {
2211        let mut score = score_with_measures(4);
2212        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2213        start.slur_start = true;
2214        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2215        end.slur_end = true;
2216        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2217        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2218        let result = compute_print_layout(
2219            &score,
2220            &PrintConfig {
2221                measures_per_system: 2,
2222                pickup_policy: PickupPolicy::Preserve,
2223                systems_per_page: Some(8),
2224                ..PrintConfig::default()
2225            },
2226        )
2227        .expect("valid cross-system span layout");
2228        assert_eq!(
2229            result.pages[0].systems[0].span_segments,
2230            vec![SpanSegment {
2231                span_index: 0,
2232                starts_here: true,
2233                ends_here: false,
2234            }]
2235        );
2236        assert_eq!(
2237            result.pages[0].systems[1].span_segments,
2238            vec![SpanSegment {
2239                span_index: 0,
2240                starts_here: false,
2241                ends_here: true,
2242            }]
2243        );
2244    }
2245
2246    #[test]
2247    fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
2248        let mut score = score_with_measures(4);
2249        let measures = &mut score.parts[0].staves[0].measures;
2250        measures[0].barline_right = Barline::RepeatEnd;
2251        measures[1].barline_left = Barline::RepeatStart;
2252        measures[2].volta = Some(acorde_core::VoltaBracket {
2253            number: 1,
2254            kind: "begin".to_string(),
2255        });
2256        measures[2].navigation = Some("ToCoda".to_string());
2257        measures[2].rehearsal = Some("B".to_string());
2258        let result = compute_print_layout(
2259            &score,
2260            &PrintConfig {
2261                measures_per_system: 2,
2262                systems_per_page: Some(8),
2263                ..PrintConfig::default()
2264            },
2265        )
2266        .expect("valid measure mark layout");
2267        assert_eq!(
2268            result.pages[0].systems[0].measure_marks,
2269            vec![
2270                MeasureMark {
2271                    measure_index: 0,
2272                    repeat_start: false,
2273                    repeat_end: true,
2274                    volta_number: None,
2275                    volta_kind: None,
2276                    navigation: None,
2277                    rehearsal: None,
2278                },
2279                MeasureMark {
2280                    measure_index: 1,
2281                    repeat_start: true,
2282                    repeat_end: false,
2283                    volta_number: None,
2284                    volta_kind: None,
2285                    navigation: None,
2286                    rehearsal: None,
2287                },
2288            ]
2289        );
2290        assert_eq!(
2291            result.pages[0].systems[1].measure_marks,
2292            vec![MeasureMark {
2293                measure_index: 2,
2294                repeat_start: false,
2295                repeat_end: false,
2296                volta_number: Some(1),
2297                volta_kind: Some("begin".to_string()),
2298                navigation: Some("ToCoda".to_string()),
2299                rehearsal: Some("B".to_string()),
2300            }]
2301        );
2302    }
2303
2304    #[test]
2305    fn page_aggregates_cross_system_span_ownership() {
2306        let mut score = score_with_measures(4);
2307        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2308        start.slur_start = true;
2309        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2310        end.slur_end = true;
2311        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2312        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2313        let result = compute_print_layout(
2314            &score,
2315            &PrintConfig {
2316                measures_per_system: 2,
2317                pickup_policy: PickupPolicy::Preserve,
2318                systems_per_page: Some(1),
2319                ..PrintConfig::default()
2320            },
2321        )
2322        .expect("valid page span layout");
2323        assert_eq!(
2324            result.pages[0].span_segments,
2325            vec![PageSpanSegment {
2326                span_index: 0,
2327                starts_here: true,
2328                ends_here: false,
2329            }]
2330        );
2331        assert_eq!(
2332            result.pages[1].span_segments,
2333            vec![PageSpanSegment {
2334                span_index: 0,
2335                starts_here: false,
2336                ends_here: true,
2337            }]
2338        );
2339    }
2340
2341    #[test]
2342    fn page_artifact_measure_span_borrows_system_spans() {
2343        let mut score = score_with_measures(4);
2344        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2345        start.slur_start = true;
2346        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2347        end.slur_end = true;
2348        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2349        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2350        let result = compute_print_layout(
2351            &score,
2352            &PrintConfig {
2353                measures_per_system: 2,
2354                pickup_policy: PickupPolicy::Preserve,
2355                systems_per_page: Some(1),
2356                ..PrintConfig::default()
2357            },
2358        )
2359        .expect("valid page artifact");
2360        let first = result
2361            .page(PageAddress { page_index: 0 })
2362            .expect("first page");
2363        assert_eq!(
2364            first.measure_span(),
2365            Some(MeasureSpan {
2366                first_measure: 0,
2367                last_measure: 1,
2368            })
2369        );
2370        assert!(first.has_span_continuation());
2371        assert!(result.page(PageAddress { page_index: 99 }).is_none());
2372        assert!(result.validate().is_ok());
2373    }
2374
2375    #[test]
2376    fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
2377        let mut score = score_with_measures(4);
2378        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2379        start.slur_start = true;
2380        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2381        end.slur_end = true;
2382        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2383        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2384        let result = compute_print_layout(
2385            &score,
2386            &PrintConfig {
2387                measures_per_system: 2,
2388                systems_per_page: Some(1),
2389                ..PrintConfig::default()
2390            },
2391        )
2392        .expect("valid print config");
2393
2394        let artifacts = result
2395            .export_page_artifacts()
2396            .expect("valid page artifacts");
2397        assert_eq!(artifacts.len(), 2);
2398        assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
2399        assert_eq!(artifacts[1].page_index, 1);
2400        assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
2401        assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
2402        assert_eq!(
2403            artifacts[0].measure_span,
2404            Some(MeasureSpan {
2405                first_measure: 0,
2406                last_measure: 1,
2407            })
2408        );
2409        assert_eq!(
2410            artifacts[0].diagnostics,
2411            vec![PageArtifactDiagnostic::SpanContinuation {
2412                span_index: 0,
2413                starts_here: true,
2414                ends_here: false,
2415            }]
2416        );
2417        assert!(artifacts[1].diagnostics.is_empty());
2418    }
2419
2420    #[test]
2421    fn export_page_artifacts_rejects_invalid_serialized_layout() {
2422        let score = score_with_measures(1);
2423        let mut result =
2424            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2425        result.pages[0].width_mm = f32::NAN;
2426
2427        assert!(matches!(
2428            result.export_page_artifacts(),
2429            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2430        ));
2431    }
2432
2433    #[test]
2434    fn page_lookup_rejects_mismatched_serialized_address() {
2435        let score = score_with_measures(1);
2436        let mut result =
2437            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2438        result.pages[0].address = PageAddress { page_index: 7 };
2439
2440        assert!(result.page(PageAddress { page_index: 0 }).is_none());
2441        assert_eq!(
2442            result.validate(),
2443            Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
2444        );
2445    }
2446
2447    #[test]
2448    fn layout_validation_rejects_mismatched_system_address() {
2449        let score = score_with_measures(2);
2450        let mut result = compute_print_layout(
2451            &score,
2452            &PrintConfig {
2453                measures_per_system: 1,
2454                ..PrintConfig::default()
2455            },
2456        )
2457        .expect("valid print config");
2458        result.pages[0].systems[0].address.index_on_page = 4;
2459
2460        assert_eq!(
2461            result.validate(),
2462            Err(PrintLayoutError::InvalidSystemAddress {
2463                page_index: 0,
2464                index_on_page: 0,
2465                system_index: 0,
2466            })
2467        );
2468    }
2469
2470    #[test]
2471    fn layout_validation_rejects_non_monotonic_page_number() {
2472        let score = score_with_measures(2);
2473        let mut result = compute_print_layout(
2474            &score,
2475            &PrintConfig {
2476                measures_per_system: 1,
2477                page_numbering: PageNumbering::OneBased,
2478                systems_per_page: Some(1),
2479                ..PrintConfig::default()
2480            },
2481        )
2482        .expect("valid print config");
2483        result.pages[1].page_number = Some(1);
2484
2485        assert_eq!(
2486            result.validate(),
2487            Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
2488        );
2489    }
2490
2491    #[test]
2492    fn layout_validation_rejects_inconsistent_title_page_metadata() {
2493        let score = score_with_measures(1);
2494        let mut result =
2495            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2496        result.pages[0].publication.is_title_page = true;
2497
2498        assert_eq!(
2499            result.validate(),
2500            Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
2501        );
2502    }
2503
2504    #[test]
2505    fn layout_validation_rejects_non_finite_page_geometry() {
2506        let score = score_with_measures(1);
2507        let mut result =
2508            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2509        result.pages[0].width_mm = f32::NAN;
2510
2511        assert_eq!(
2512            result.validate(),
2513            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2514        );
2515    }
2516
2517    #[test]
2518    fn layout_validation_rejects_non_positive_system_geometry() {
2519        let score = score_with_measures(1);
2520        let mut result =
2521            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2522        result.pages[0].systems[0].height_mm = 0.0;
2523
2524        assert_eq!(
2525            result.validate(),
2526            Err(PrintLayoutError::InvalidSystemGeometry {
2527                page_index: 0,
2528                index_on_page: 0,
2529            })
2530        );
2531    }
2532
2533    #[test]
2534    fn layout_validation_rejects_content_larger_than_page() {
2535        let score = score_with_measures(1);
2536        let mut result =
2537            compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2538        result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
2539
2540        assert_eq!(
2541            result.validate(),
2542            Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2543        );
2544    }
2545
2546    #[test]
2547    fn notation_policy_keeps_volta_range_in_one_system() {
2548        let mut score = score_with_measures(4);
2549        score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
2550            number: 1,
2551            kind: "begin".to_string(),
2552        });
2553        score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
2554            number: 1,
2555            kind: "end".to_string(),
2556        });
2557        let result = compute_print_layout(
2558            &score,
2559            &PrintConfig {
2560                measures_per_system: 2,
2561                systems_per_page: Some(8),
2562                notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
2563                ..PrintConfig::default()
2564            },
2565        )
2566        .expect("valid volta-preserving layout");
2567        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2568        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2569        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
2570    }
2571
2572    #[test]
2573    fn notation_policy_keeps_repeat_section_on_one_page() {
2574        let mut score = score_with_measures(5);
2575        score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
2576        score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
2577        let result = compute_print_layout(
2578            &score,
2579            &PrintConfig {
2580                measures_per_system: 2,
2581                systems_per_page: Some(2),
2582                notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
2583                ..PrintConfig::default()
2584            },
2585        )
2586        .expect("valid repeat-preserving layout");
2587        assert_eq!(result.pages[0].systems.len(), 1);
2588        assert_eq!(result.pages[1].systems.len(), 2);
2589        assert_eq!(
2590            result.pages[1]
2591                .systems
2592                .iter()
2593                .flat_map(|system| system.measure_indices.iter().copied())
2594                .collect::<Vec<_>>(),
2595            vec![2, 3, 4]
2596        );
2597    }
2598
2599    #[test]
2600    fn balance_policy_avoids_single_system_final_page() {
2601        let score = score_with_measures(5);
2602        let result = compute_print_layout(
2603            &score,
2604            &PrintConfig {
2605                measures_per_system: 1,
2606                systems_per_page: Some(4),
2607                final_page_policy: FinalPagePolicy::Balance,
2608                ..PrintConfig::default()
2609            },
2610        )
2611        .expect("valid balanced print config");
2612        assert_eq!(result.pages.len(), 2);
2613        assert_eq!(result.pages[0].systems.len(), 3);
2614        assert_eq!(result.pages[1].systems.len(), 2);
2615    }
2616
2617    #[test]
2618    fn balance_policy_preserves_explicit_page_breaks() {
2619        let mut score = score_with_measures(5);
2620        score.parts[0].staves[0].measures[1].page_break = true;
2621        let result = compute_print_layout(
2622            &score,
2623            &PrintConfig {
2624                measures_per_system: 1,
2625                systems_per_page: Some(4),
2626                final_page_policy: FinalPagePolicy::Balance,
2627                ..PrintConfig::default()
2628            },
2629        )
2630        .expect("valid explicit-break print config");
2631        assert_eq!(result.pages[0].systems.len(), 2);
2632        assert_eq!(result.pages[1].systems.len(), 3);
2633    }
2634
2635    #[test]
2636    fn keep_together_rejects_ranges_larger_than_system_capacity() {
2637        let score = score_with_measures(4);
2638        let error = compute_print_layout(
2639            &score,
2640            &PrintConfig {
2641                measures_per_system: 2,
2642                keep_together: vec![KeepTogetherRange {
2643                    first_measure: 0,
2644                    last_measure: 2,
2645                }],
2646                ..PrintConfig::default()
2647            },
2648        )
2649        .expect_err("range must fit in one system");
2650        assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
2651    }
2652
2653    #[test]
2654    fn keep_together_rejects_explicit_break_inside_range() {
2655        let mut score = score_with_measures(4);
2656        score.parts[0].staves[0].measures[1].system_break = true;
2657        let error = compute_print_layout(
2658            &score,
2659            &PrintConfig {
2660                measures_per_system: 3,
2661                keep_together: vec![KeepTogetherRange {
2662                    first_measure: 0,
2663                    last_measure: 2,
2664                }],
2665                ..PrintConfig::default()
2666            },
2667        )
2668        .expect_err("explicit break must win");
2669        assert_eq!(
2670            error,
2671            PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
2672        );
2673    }
2674
2675    #[test]
2676    fn rejects_margins_that_leave_no_page_area() {
2677        let score = score_with_measures(1);
2678        let error = compute_print_layout(
2679            &score,
2680            &PrintConfig {
2681                margin_left_mm: 200.0,
2682                ..PrintConfig::default()
2683            },
2684        )
2685        .expect_err("invalid page area");
2686        assert_eq!(error, PrintLayoutError::NoUsablePageArea);
2687    }
2688
2689    #[test]
2690    fn safe_area_reduces_content_and_bleed_is_exposed() {
2691        let score = score_with_measures(1);
2692        let result = compute_print_layout(
2693            &score,
2694            &PrintConfig {
2695                bleed_top_mm: 3.0,
2696                bleed_right_mm: 3.0,
2697                bleed_bottom_mm: 3.0,
2698                bleed_left_mm: 3.0,
2699                safe_top_mm: 5.0,
2700                safe_right_mm: 6.0,
2701                safe_bottom_mm: 7.0,
2702                safe_left_mm: 8.0,
2703                ..PrintConfig::default()
2704            },
2705        )
2706        .expect("valid print config");
2707        let page = &result.pages[0];
2708        assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
2709        assert_eq!(page.bleed_left_mm, 3.0);
2710        assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
2711        assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
2712        assert_eq!(page.systems[0].top_mm, 21.0);
2713    }
2714
2715    #[test]
2716    fn scale_changes_system_height_and_page_capacity() {
2717        let score = score_with_measures(10);
2718        let result = compute_print_layout(
2719            &score,
2720            &PrintConfig {
2721                scale: 2.0,
2722                measures_per_system: 1,
2723                systems_per_page: None,
2724                ..PrintConfig::default()
2725            },
2726        )
2727        .expect("valid print config");
2728        assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
2729        assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
2730        assert_eq!(result.pages.len(), 2);
2731    }
2732
2733    #[test]
2734    fn rejects_non_positive_scale() {
2735        let score = score_with_measures(1);
2736        let error = compute_print_layout(
2737            &score,
2738            &PrintConfig {
2739                scale: 0.0,
2740                ..PrintConfig::default()
2741            },
2742        )
2743        .expect_err("invalid scale");
2744        assert_eq!(error, PrintLayoutError::InvalidScale);
2745    }
2746
2747    #[test]
2748    fn page_numbering_is_configurable() {
2749        let score = score_with_measures(5);
2750        let numbered = compute_print_layout(
2751            &score,
2752            &PrintConfig {
2753                measures_per_system: 1,
2754                systems_per_page: Some(2),
2755                ..PrintConfig::default()
2756            },
2757        )
2758        .expect("valid print config");
2759        assert_eq!(numbered.pages[0].page_number, Some(1));
2760        assert_eq!(numbered.pages[1].page_number, Some(2));
2761
2762        let unnumbered = compute_print_layout(
2763            &score,
2764            &PrintConfig {
2765                page_numbering: PageNumbering::None,
2766                measures_per_system: 1,
2767                systems_per_page: Some(2),
2768                ..PrintConfig::default()
2769            },
2770        )
2771        .expect("valid print config");
2772        assert!(
2773            unnumbered
2774                .pages
2775                .iter()
2776                .all(|page| page.page_number.is_none())
2777        );
2778    }
2779
2780    #[test]
2781    fn rejects_invalid_publication_line_height() {
2782        let score = score_with_measures(1);
2783        let error = compute_print_layout(
2784            &score,
2785            &PrintConfig {
2786                publication: PublicationConfig {
2787                    line_height_mm: 0.0,
2788                    ..PublicationConfig::default()
2789                },
2790                ..PrintConfig::default()
2791            },
2792        )
2793        .expect_err("invalid publication line height");
2794        assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
2795    }
2796
2797    #[test]
2798    fn rejects_empty_host_glyph_resource_key() {
2799        let score = score_with_measures(1);
2800        let error = compute_print_layout(
2801            &score,
2802            &PrintConfig {
2803                glyph_resources: GlyphResourcePolicy::HostProvided("  ".into()),
2804                ..PrintConfig::default()
2805            },
2806        )
2807        .expect_err("empty host resource key");
2808        assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
2809    }
2810
2811    #[test]
2812    fn print_color_and_crop_policies_are_exposed_per_page() {
2813        let score = score_with_measures(1);
2814        let result = compute_print_layout(
2815            &score,
2816            &PrintConfig {
2817                color_policy: PrintColorPolicy::Preserve,
2818                crop_mark_policy: CropMarkPolicy::BleedEdges,
2819                ..PrintConfig::default()
2820            },
2821        )
2822        .expect("valid print config");
2823        let page = &result.pages[0];
2824        assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
2825        assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
2826        assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
2827    }
2828
2829    #[test]
2830    fn glyph_resource_policy_is_exposed_per_page() {
2831        let score = score_with_measures(1);
2832        let result = compute_print_layout(
2833            &score,
2834            &PrintConfig {
2835                glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
2836                ..PrintConfig::default()
2837            },
2838        )
2839        .expect("valid print config");
2840        assert_eq!(
2841            result.pages[0].glyph_resources,
2842            GlyphResourcePolicy::HostProvided("music-font-v1".into())
2843        );
2844    }
2845
2846    #[test]
2847    fn publication_metadata_is_deterministic_and_page_scoped() {
2848        let mut score = score_with_measures(3);
2849        score.metadata.title = "Suite".into();
2850        score.metadata.movement_title = "I. Prelude".into();
2851        score.metadata.composer = "Composer".into();
2852        score.metadata.copyright = "© 2026 Composer".into();
2853        score.metadata.lyricist = "Lyricist".into();
2854        score.metadata.copyright = "Copyright".into();
2855        score.parts.push(Part::new("Strings", "Str."));
2856        score.part_groups.push(PartGroup {
2857            first_part: 0,
2858            last_part: 1,
2859            symbol: PartGroupSymbol::Bracket,
2860            barlines_connect: true,
2861        });
2862        for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
2863            measure.number = (index + 1) as u32;
2864        }
2865        let result = compute_print_layout(
2866            &score,
2867            &PrintConfig {
2868                measures_per_system: 2,
2869                systems_per_page: Some(1),
2870                publication: PublicationConfig {
2871                    running_title: Some("Suite — Composer".into()),
2872                    header_text: Some("Suite".into()),
2873                    footer_text: Some("Copyright".into()),
2874                    page_number_in_footer: true,
2875                    header_alignment: PublicationTextAlignment::Center,
2876                    footer_alignment: PublicationTextAlignment::Right,
2877                    ..PublicationConfig::default()
2878                },
2879                ..PrintConfig::default()
2880            },
2881        )
2882        .expect("valid print config");
2883        assert_eq!(result.pages[0].publication.title, "Suite");
2884        assert_eq!(
2885            result.pages[0].publication.running_title.as_deref(),
2886            Some("Suite — Composer")
2887        );
2888        assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
2889        assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
2890        assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
2891        assert_eq!(result.pages[0].publication.part_groups.len(), 1);
2892        assert_eq!(
2893            result.pages[0].publication.part_groups[0].symbol,
2894            PartGroupSymbol::Bracket
2895        );
2896        assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
2897        assert_eq!(
2898            result.pages[0].publication.text_blocks[0].role,
2899            PublicationTextRole::Header
2900        );
2901        assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
2902        assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
2903        assert_eq!(
2904            result.pages[0].publication.text_blocks[1].role,
2905            PublicationTextRole::Footer
2906        );
2907        assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
2908        assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
2909        assert_eq!(
2910            result.pages[0].publication.text_blocks[0].alignment,
2911            PublicationTextAlignment::Center
2912        );
2913        assert_eq!(
2914            result.pages[0].publication.text_blocks[1].alignment,
2915            PublicationTextAlignment::Right
2916        );
2917    }
2918
2919    #[test]
2920    fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
2921        let mut score = score_with_measures(2);
2922        let mut part = Part::new("Flute", "Fl.");
2923        let mut staff = Staff::new(Clef::Treble);
2924        staff.measures = vec![Measure::empty(4, 4); 5];
2925        part.staves = vec![staff];
2926        score.parts.push(part);
2927
2928        let extracted = compute_print_layout(
2929            &score,
2930            &PrintConfig {
2931                part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
2932                measures_per_system: 2,
2933                systems_per_page: Some(1),
2934                ..PrintConfig::default()
2935            },
2936        )
2937        .expect("valid extracted part");
2938        assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
2939        assert_eq!(extracted.pages.len(), 3);
2940        assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
2941
2942        let error = compute_print_layout(
2943            &score,
2944            &PrintConfig {
2945                part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
2946                ..PrintConfig::default()
2947            },
2948        )
2949        .expect_err("missing extracted part");
2950        assert_eq!(error, PrintLayoutError::InvalidPartIndex);
2951    }
2952
2953    #[test]
2954    fn title_page_is_inserted_without_consuming_music_page_capacity() {
2955        let mut score = score_with_measures(3);
2956        score.metadata.title = "Suite".into();
2957        score.metadata.movement_title = "I. Prelude".into();
2958        score.metadata.composer = "Composer".into();
2959        score.metadata.copyright = "© 2026 Composer".into();
2960        let result = compute_print_layout(
2961            &score,
2962            &PrintConfig {
2963                systems_per_page: Some(1),
2964                measures_per_system: 2,
2965                publication: PublicationConfig {
2966                    title_page: true,
2967                    ..PublicationConfig::default()
2968                },
2969                ..PrintConfig::default()
2970            },
2971        )
2972        .expect("valid title page config");
2973        assert_eq!(result.pages.len(), 3);
2974        assert!(result.pages[0].systems.is_empty());
2975        assert!(result.pages[0].publication.is_title_page);
2976        assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
2977        assert_eq!(result.pages[0].page_number, Some(1));
2978        assert_eq!(result.pages[1].page_number, Some(2));
2979        assert_eq!(result.pages[1].systems[0].page_index, 1);
2980        assert!(!result.pages[1].publication.is_title_page);
2981        assert!(result.validate().is_ok());
2982        assert_eq!(
2983            result.pages[0]
2984                .publication
2985                .text_blocks
2986                .iter()
2987                .map(|block| block.role)
2988                .collect::<Vec<_>>(),
2989            vec![
2990                PublicationTextRole::Title,
2991                PublicationTextRole::Subtitle,
2992                PublicationTextRole::Credit,
2993                PublicationTextRole::Copyright
2994            ]
2995        );
2996    }
2997
2998    #[test]
2999    fn print_presets_are_versioned_and_select_the_expected_scope() {
3000        assert_eq!(PrintPreset::A4Score.schema_version(), 1);
3001        assert_eq!(
3002            PrintPreset::A4Score.config().part_layout,
3003            PartLayoutPolicy::FullScore
3004        );
3005        assert_eq!(
3006            PrintPreset::LetterPart { part_index: 2 }
3007                .config()
3008                .part_layout,
3009            PartLayoutPolicy::ExtractedPart { part_index: 2 }
3010        );
3011        assert_eq!(
3012            PrintPreset::LetterScore.config().paper_size,
3013            PaperSize::Letter
3014        );
3015        assert!(
3016            PrintPreset::A4Score
3017                .config_with_title_page(true)
3018                .publication
3019                .title_page
3020        );
3021        assert!(!PrintPreset::A4Score.config().publication.title_page);
3022        assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 1);
3023    }
3024
3025    #[test]
3026    fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
3027        let metrics = GlyphMetrics {
3028            advance_mm: 4.0,
3029            left_mm: -1.0,
3030            top_mm: -2.0,
3031            width_mm: 2.0,
3032            height_mm: 4.0,
3033        };
3034        let mut placements = vec![
3035            GlyphPlacement {
3036                resource_key: "high".into(),
3037                metrics,
3038                x_mm: 10.0,
3039                y_mm: 20.0,
3040                priority: 10,
3041            },
3042            GlyphPlacement {
3043                resource_key: "low".into(),
3044                metrics,
3045                x_mm: 10.0,
3046                y_mm: 20.0,
3047                priority: 1,
3048            },
3049        ];
3050        let moved = resolve_glyph_collisions(&mut placements, 1.0);
3051        assert_eq!(moved, 1);
3052        assert_eq!(placements[0].y_mm, 20.0);
3053        assert_eq!(placements[1].y_mm, 25.0);
3054    }
3055
3056    #[test]
3057    fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
3058        let metrics = GlyphMetrics {
3059            advance_mm: 4.0,
3060            left_mm: -1.0,
3061            top_mm: -1.0,
3062            width_mm: 2.0,
3063            height_mm: 2.0,
3064        };
3065        let mut placements = vec![
3066            GlyphPlacement {
3067                resource_key: "high".into(),
3068                metrics,
3069                x_mm: 10.0,
3070                y_mm: 20.0,
3071                priority: 10,
3072            },
3073            GlyphPlacement {
3074                resource_key: "low".into(),
3075                metrics,
3076                x_mm: 10.0,
3077                y_mm: 0.0,
3078                priority: 1,
3079            },
3080        ];
3081        assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
3082        assert_eq!(placements[1].y_mm, 0.0);
3083    }
3084
3085    #[test]
3086    fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
3087        let mut placements = vec![GlyphPlacement {
3088            resource_key: "test".into(),
3089            metrics: GlyphMetrics {
3090                advance_mm: 1.0,
3091                left_mm: 0.0,
3092                top_mm: 0.0,
3093                width_mm: 1.0,
3094                height_mm: 1.0,
3095            },
3096            x_mm: 0.0,
3097            y_mm: 0.0,
3098            priority: 0,
3099        }];
3100        assert_eq!(validate_glyph_placements(&placements), Ok(()));
3101        placements[0].x_mm = f32::NAN;
3102        assert_eq!(
3103            validate_glyph_placements(&placements),
3104            Err(GlyphPlacementError::NonFinite { index: 0 })
3105        );
3106        placements[0].x_mm = 0.0;
3107        placements[0].metrics.width_mm = -1.0;
3108        assert_eq!(
3109            validate_glyph_placements(&placements),
3110            Err(GlyphPlacementError::NegativeExtent { index: 0 })
3111        );
3112    }
3113
3114    #[test]
3115    fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
3116        let metrics = GlyphMetrics {
3117            advance_mm: 4.0,
3118            left_mm: -1.0,
3119            top_mm: -1.0,
3120            width_mm: 2.0,
3121            height_mm: 2.0,
3122        };
3123        let mut placements = vec![
3124            GlyphPlacement {
3125                resource_key: "high".into(),
3126                metrics,
3127                x_mm: 10.0,
3128                y_mm: 20.0,
3129                priority: 10,
3130            },
3131            GlyphPlacement {
3132                resource_key: "low".into(),
3133                metrics,
3134                x_mm: 10.0,
3135                y_mm: 20.0,
3136                priority: 1,
3137            },
3138            GlyphPlacement {
3139                resource_key: "far".into(),
3140                metrics,
3141                x_mm: 10.0,
3142                y_mm: 30.0,
3143                priority: 1,
3144            },
3145        ];
3146        assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
3147        assert_eq!(placements[0].x_mm, 10.0);
3148        assert_eq!(placements[1].x_mm, 13.0);
3149        assert_eq!(placements[2].x_mm, 10.0);
3150    }
3151
3152    #[test]
3153    fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
3154        let metrics = GlyphMetrics {
3155            advance_mm: 1.0,
3156            left_mm: 0.0,
3157            top_mm: 0.0,
3158            width_mm: 1.0,
3159            height_mm: 1.0,
3160        };
3161        let mut placements = vec![
3162            GlyphPlacement {
3163                resource_key: "second".into(),
3164                metrics,
3165                x_mm: 20.0,
3166                y_mm: 0.0,
3167                priority: 0,
3168            },
3169            GlyphPlacement {
3170                resource_key: "first".into(),
3171                metrics,
3172                x_mm: 10.0,
3173                y_mm: 0.0,
3174                priority: 0,
3175            },
3176            GlyphPlacement {
3177                resource_key: "third".into(),
3178                metrics,
3179                x_mm: 30.0,
3180                y_mm: 0.0,
3181                priority: 0,
3182            },
3183        ];
3184        assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
3185        assert_eq!(placements[0].x_mm, 23.0);
3186        assert_eq!(placements[1].x_mm, 10.0);
3187        assert_eq!(placements[2].x_mm, 36.0);
3188        assert_eq!(
3189            distribute_glyph_spacing(&mut placements, f32::NAN),
3190            Err(GlyphPlacementError::NonFiniteSpacing)
3191        );
3192        let before = placements.clone();
3193        assert_eq!(
3194            distribute_glyph_spacing(&mut placements, f32::MAX),
3195            Err(GlyphPlacementError::NonFiniteSpacing)
3196        );
3197        assert_eq!(placements, before);
3198    }
3199
3200    #[test]
3201    fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
3202        let mut placement = GlyphPlacement {
3203            resource_key: " ".into(),
3204            metrics: GlyphMetrics {
3205                advance_mm: 1.0,
3206                left_mm: 0.0,
3207                top_mm: 0.0,
3208                width_mm: 1.0,
3209                height_mm: 1.0,
3210            },
3211            x_mm: 0.0,
3212            y_mm: 0.0,
3213            priority: 0,
3214        };
3215        assert_eq!(
3216            validate_glyph_placements(&[placement.clone()]),
3217            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3218        );
3219        placement.resource_key = "glyph".into();
3220        placement.metrics.advance_mm = -1.0;
3221        assert_eq!(
3222            validate_glyph_placements(&[placement]),
3223            Err(GlyphPlacementError::NegativeAdvance { index: 0 })
3224        );
3225    }
3226
3227    #[test]
3228    fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
3229        let mut placements = vec![GlyphPlacement {
3230            resource_key: String::new(),
3231            metrics: GlyphMetrics {
3232                advance_mm: 1.0,
3233                left_mm: 0.0,
3234                top_mm: 0.0,
3235                width_mm: 1.0,
3236                height_mm: 1.0,
3237            },
3238            x_mm: 0.0,
3239            y_mm: 0.0,
3240            priority: 0,
3241        }];
3242        assert_eq!(
3243            resolve_glyph_collisions_checked(&mut placements, 1.0),
3244            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3245        );
3246        assert_eq!(
3247            resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
3248            Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3249        );
3250        assert_eq!(placements[0].x_mm, 0.0);
3251        assert_eq!(placements[0].y_mm, 0.0);
3252    }
3253
3254    #[test]
3255    fn checked_collision_resolvers_reject_non_finite_gap() {
3256        let metrics = GlyphMetrics {
3257            advance_mm: 1.0,
3258            left_mm: 0.0,
3259            top_mm: 0.0,
3260            width_mm: 1.0,
3261            height_mm: 1.0,
3262        };
3263        let original = vec![GlyphPlacement {
3264            resource_key: "glyph".into(),
3265            metrics,
3266            x_mm: 0.0,
3267            y_mm: 0.0,
3268            priority: 0,
3269        }];
3270        let mut vertical = original.clone();
3271        assert_eq!(
3272            resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
3273            Err(GlyphPlacementError::NonFiniteSpacing)
3274        );
3275        assert_eq!(vertical, original);
3276
3277        let mut horizontal = original.clone();
3278        assert_eq!(
3279            resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
3280            Err(GlyphPlacementError::NonFiniteSpacing)
3281        );
3282        assert_eq!(horizontal, original);
3283    }
3284
3285    #[test]
3286    fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
3287        let metrics = GlyphMetrics {
3288            advance_mm: 1.0,
3289            left_mm: 0.0,
3290            top_mm: 0.0,
3291            width_mm: f32::MAX / 2.0,
3292            height_mm: f32::MAX / 2.0,
3293        };
3294        let original = vec![
3295            GlyphPlacement {
3296                resource_key: "high".into(),
3297                metrics,
3298                x_mm: 0.0,
3299                y_mm: 0.0,
3300                priority: 1,
3301            },
3302            GlyphPlacement {
3303                resource_key: "low".into(),
3304                metrics,
3305                x_mm: 0.0,
3306                y_mm: 0.0,
3307                priority: 0,
3308            },
3309        ];
3310        let mut vertical = original.clone();
3311        assert_eq!(
3312            resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
3313            Err(GlyphPlacementError::NonFinite { index: 1 })
3314        );
3315        assert_eq!(vertical, original);
3316
3317        let mut horizontal = original.clone();
3318        assert_eq!(
3319            resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
3320            Err(GlyphPlacementError::NonFinite { index: 1 })
3321        );
3322        assert_eq!(horizontal, original);
3323    }
3324
3325    #[test]
3326    fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
3327        let metrics = GlyphMetrics {
3328            advance_mm: 1.0,
3329            left_mm: -1.0,
3330            top_mm: -2.0,
3331            width_mm: 3.0,
3332            height_mm: 4.0,
3333        };
3334        let placements = vec![
3335            GlyphPlacement {
3336                resource_key: "a".into(),
3337                metrics,
3338                x_mm: 10.0,
3339                y_mm: 20.0,
3340                priority: 0,
3341            },
3342            GlyphPlacement {
3343                resource_key: "b".into(),
3344                metrics,
3345                x_mm: 30.0,
3346                y_mm: 5.0,
3347                priority: 0,
3348            },
3349        ];
3350        assert_eq!(
3351            glyph_extents(&placements),
3352            Ok(Some(GlyphExtents {
3353                left_mm: 9.0,
3354                top_mm: 3.0,
3355                right_mm: 32.0,
3356                bottom_mm: 22.0,
3357            }))
3358        );
3359        let extents = glyph_extents(&placements).unwrap().unwrap();
3360        assert_eq!(extents.width_mm(), 23.0);
3361        assert_eq!(extents.height_mm(), 19.0);
3362        assert_eq!(glyph_extents(&[]), Ok(None));
3363    }
3364
3365    #[test]
3366    fn glyph_extents_reject_derived_bound_overflow() {
3367        let placements = [GlyphPlacement {
3368            resource_key: "edge".into(),
3369            metrics: GlyphMetrics {
3370                advance_mm: 1.0,
3371                left_mm: 0.0,
3372                top_mm: 0.0,
3373                width_mm: f32::MAX,
3374                height_mm: 1.0,
3375            },
3376            x_mm: f32::MAX,
3377            y_mm: 0.0,
3378            priority: 0,
3379        }];
3380        assert_eq!(
3381            glyph_extents(&placements),
3382            Err(GlyphPlacementError::NonFinite { index: 0 })
3383        );
3384    }
3385}