Skip to main content

acorde_layout/
print.rs

1use crate::{LayoutConfig, SpanMark, compute_layout};
2use acorde_core::{Barline, Score};
3use serde::{Deserialize, Serialize};
4
5/// A paper size expressed in physical millimetres.
6#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
7pub enum PaperSize {
8    A4,
9    Letter,
10    Legal,
11    Custom { width_mm: f32, height_mm: f32 },
12}
13
14impl PaperSize {
15    fn dimensions_mm(self) -> (f32, f32) {
16        match self {
17            Self::A4 => (210.0, 297.0),
18            Self::Letter => (215.9, 279.4),
19            Self::Legal => (215.9, 355.6),
20            Self::Custom {
21                width_mm,
22                height_mm,
23            } => (width_mm, height_mm),
24        }
25    }
26}
27
28/// Page orientation for a logical print layout.
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
30pub enum PageOrientation {
31    Portrait,
32    Landscape,
33}
34
35/// Policy for the page number exposed in logical page metadata.
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37pub enum PageNumbering {
38    None,
39    OneBased,
40}
41
42/// Policy for distributing systems when automatic pagination would leave a one-system final page.
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
44pub enum FinalPagePolicy {
45    /// Preserve the configured page capacity, even when the final page is short.
46    #[default]
47    AllowSingleSystem,
48    /// Redistribute automatically paginated systems as evenly as possible across pages.
49    Balance,
50}
51
52/// Policy for reserving the first system for a partial pickup measure.
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
54pub enum PickupPolicy {
55    /// Detect a non-empty partial first measure automatically (the default).
56    #[default]
57    Auto,
58    /// Do not infer pickup measures from score content.
59    Preserve,
60    /// Detect a non-empty first measure shorter than its time signature and isolate it.
61    DetectFirstMeasure,
62}
63
64/// Policy for preserving repeat-ending notation while systems are reflowed.
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
66pub enum NotationBreakPolicy {
67    /// Keep the score's normal automatic system breaks.
68    #[default]
69    Preserve,
70    /// Keep each contiguous volta ending in one system when it fits.
71    KeepVoltaTogether,
72    /// Keep each repeat section on one page when it fits the page capacity.
73    KeepRepeatsTogether,
74}
75
76/// Color intent for a print-capable host.
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
78pub enum PrintColorPolicy {
79    #[default]
80    Monochrome,
81    Preserve,
82}
83
84/// Whether a host should expose crop marks at the configured bleed boundary.
85#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
86pub enum CropMarkPolicy {
87    #[default]
88    None,
89    BleedEdges,
90}
91
92/// How a host resolves fonts and notation glyph resources for print output.
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
94pub enum GlyphResourcePolicy {
95    /// Use the renderer's deterministic built-in vector glyphs where available.
96    #[default]
97    BuiltInVector,
98    /// Resolve a host-owned resource identified by this stable application key.
99    HostProvided(String),
100}
101
102/// A contiguous range of physical measures that must remain in one printed system.
103///
104/// Both endpoints are zero-based and inclusive. This is intentionally a layout request,
105/// not a score-model mutation, so hosts can apply publication presets without changing the
106/// editable score.
107#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
108pub struct KeepTogetherRange {
109    pub first_measure: usize,
110    pub last_measure: usize,
111}
112
113/// Host-neutral inputs for deterministic page and system layout.
114///
115/// This contract describes physical page geometry only. It intentionally does not select
116/// fonts, emit PDF, access printers, or perform filesystem I/O.
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118#[serde(default)]
119pub struct PrintConfig {
120    pub paper_size: PaperSize,
121    pub orientation: PageOrientation,
122    pub margin_top_mm: f32,
123    pub margin_right_mm: f32,
124    pub margin_bottom_mm: f32,
125    pub margin_left_mm: f32,
126    pub bleed_top_mm: f32,
127    pub bleed_right_mm: f32,
128    pub bleed_bottom_mm: f32,
129    pub bleed_left_mm: f32,
130    pub safe_top_mm: f32,
131    pub safe_right_mm: f32,
132    pub safe_bottom_mm: f32,
133    pub safe_left_mm: f32,
134    pub system_height_mm: f32,
135    /// Content scale factor. `1.0` preserves the configured system height.
136    pub scale: f32,
137    pub measures_per_system: usize,
138    /// Optional measure capacity for the first system, useful for pickup/title systems.
139    #[serde(default)]
140    pub first_system_measures: Option<usize>,
141    #[serde(default)]
142    pub pickup_policy: PickupPolicy,
143    #[serde(default)]
144    pub notation_break_policy: NotationBreakPolicy,
145    /// Override the number of systems per page. When omitted it is derived from the usable
146    /// page height and `system_height_mm`.
147    pub systems_per_page: Option<usize>,
148    pub page_numbering: PageNumbering,
149    #[serde(default)]
150    pub final_page_policy: FinalPagePolicy,
151    #[serde(default)]
152    pub color_policy: PrintColorPolicy,
153    #[serde(default)]
154    pub crop_mark_policy: CropMarkPolicy,
155    #[serde(default)]
156    pub glyph_resources: GlyphResourcePolicy,
157    /// Physical measure ranges that must not be split across systems.
158    #[serde(default)]
159    pub keep_together: Vec<KeepTogetherRange>,
160}
161
162impl Default for PrintConfig {
163    fn default() -> Self {
164        Self {
165            paper_size: PaperSize::A4,
166            orientation: PageOrientation::Portrait,
167            margin_top_mm: 16.0,
168            margin_right_mm: 14.0,
169            margin_bottom_mm: 16.0,
170            margin_left_mm: 14.0,
171            bleed_top_mm: 0.0,
172            bleed_right_mm: 0.0,
173            bleed_bottom_mm: 0.0,
174            bleed_left_mm: 0.0,
175            safe_top_mm: 0.0,
176            safe_right_mm: 0.0,
177            safe_bottom_mm: 0.0,
178            safe_left_mm: 0.0,
179            system_height_mm: 24.0,
180            scale: 1.0,
181            measures_per_system: 4,
182            first_system_measures: None,
183            pickup_policy: PickupPolicy::Auto,
184            notation_break_policy: NotationBreakPolicy::Preserve,
185            systems_per_page: None,
186            page_numbering: PageNumbering::OneBased,
187            final_page_policy: FinalPagePolicy::AllowSingleSystem,
188            color_policy: PrintColorPolicy::Monochrome,
189            crop_mark_policy: CropMarkPolicy::None,
190            glyph_resources: GlyphResourcePolicy::BuiltInVector,
191            keep_together: Vec::new(),
192        }
193    }
194}
195
196/// A logical system placed on a page.
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198pub struct SystemLayout {
199    pub address: SystemAddress,
200    pub system_index: usize,
201    pub page_index: usize,
202    pub measure_indices: Vec<usize>,
203    /// Physical intervals represented by the system, including multi-rest spans.
204    #[serde(default)]
205    pub measure_spans: Vec<MeasureSpan>,
206    /// Span segments touching this system, with start/end ownership for host continuation marks.
207    #[serde(default)]
208    pub span_segments: Vec<SpanSegment>,
209    /// Repeat, ending, navigation, and rehearsal marks belonging to this system.
210    #[serde(default)]
211    pub measure_marks: Vec<MeasureMark>,
212    pub top_mm: f32,
213    pub height_mm: f32,
214    pub break_reason: BreakReason,
215}
216
217/// Stable address of a page within one print-layout result.
218#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
219pub struct PageAddress {
220    pub page_index: usize,
221}
222
223/// Stable address of a system, including global and page-local positions.
224#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
225pub struct SystemAddress {
226    pub system_index: usize,
227    pub page_index: usize,
228    pub index_on_page: usize,
229}
230
231/// Physical measure interval represented by one visual measure slot.
232#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
233pub struct MeasureSpan {
234    pub first_measure: usize,
235    pub last_measure: usize,
236}
237
238/// A span's intersection with one printed system.
239#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
240pub struct SpanSegment {
241    pub span_index: usize,
242    pub starts_here: bool,
243    pub ends_here: bool,
244}
245
246/// A cross-system span's intersection with one printed page.
247#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
248pub struct PageSpanSegment {
249    pub span_index: usize,
250    pub starts_here: bool,
251    pub ends_here: bool,
252}
253
254/// Host-neutral notation marks attached to one physical measure in a print system.
255///
256/// This is presentation metadata only: playback order remains the responsibility of
257/// [`acorde_core::measure_sequence`].
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
259pub struct MeasureMark {
260    pub measure_index: usize,
261    pub repeat_start: bool,
262    pub repeat_end: bool,
263    pub volta_number: Option<u8>,
264    pub volta_kind: Option<String>,
265    pub navigation: Option<String>,
266    pub rehearsal: Option<String>,
267}
268
269/// Explains why a system or page ended at its final measure.
270#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
271pub enum BreakReason {
272    MeasureCapacity,
273    ExplicitSystemBreak,
274    ExplicitPageBreak,
275    PageCapacity,
276    EndOfScore,
277}
278
279/// One page in a [`PrintLayoutResult`].
280#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
281pub struct PageLayout {
282    pub address: PageAddress,
283    pub page_index: usize,
284    pub page_number: Option<usize>,
285    #[serde(default)]
286    pub color_policy: PrintColorPolicy,
287    #[serde(default)]
288    pub crop_mark_policy: CropMarkPolicy,
289    #[serde(default)]
290    pub glyph_resources: GlyphResourcePolicy,
291    pub width_mm: f32,
292    pub height_mm: f32,
293    pub content_width_mm: f32,
294    pub content_height_mm: f32,
295    pub bleed_top_mm: f32,
296    pub bleed_right_mm: f32,
297    pub bleed_bottom_mm: f32,
298    pub bleed_left_mm: f32,
299    pub systems: Vec<SystemLayout>,
300    /// Span intersections on this page, aggregated from its systems.
301    #[serde(default)]
302    pub span_segments: Vec<PageSpanSegment>,
303    /// Repeat and navigation marks on this page, in physical measure order.
304    #[serde(default)]
305    pub measure_marks: Vec<MeasureMark>,
306    pub break_reason: BreakReason,
307}
308
309impl PageLayout {
310    /// Return the inclusive physical measure range represented on this page.
311    pub fn measure_span(&self) -> Option<MeasureSpan> {
312        let mut spans = self
313            .systems
314            .iter()
315            .flat_map(|system| system.measure_spans.iter().copied());
316        let first = spans.next()?;
317        Some(spans.fold(first, |range, span| MeasureSpan {
318            first_measure: range.first_measure.min(span.first_measure),
319            last_measure: range.last_measure.max(span.last_measure),
320        }))
321    }
322
323    /// Whether a span continues into or out of another printed page.
324    pub fn has_span_continuation(&self) -> bool {
325        self.span_segments
326            .iter()
327            .any(|segment| !segment.starts_here || !segment.ends_here)
328    }
329}
330
331/// Deterministic page/system geometry for a score.
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
333pub struct PrintLayoutResult {
334    pub contract_version: u16,
335    pub pages: Vec<PageLayout>,
336}
337
338impl PrintLayoutResult {
339    /// Retrieve one page artifact by its stable address without recomputing layout.
340    pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
341        self.pages.get(address.page_index)
342    }
343}
344
345#[derive(Debug, thiserror::Error, PartialEq)]
346pub enum PrintLayoutError {
347    #[error("paper dimensions must be finite and greater than zero")]
348    InvalidPaperDimensions,
349    #[error("margins must be finite and non-negative")]
350    InvalidMargins,
351    #[error("system height must be finite and greater than zero")]
352    InvalidSystemHeight,
353    #[error("print scale must be finite and greater than zero")]
354    InvalidScale,
355    #[error("margins leave no usable page area")]
356    NoUsablePageArea,
357    #[error("keep-together range is outside the score or reversed")]
358    InvalidKeepTogetherRange,
359    #[error("keep-together range exceeds the measures-per-system capacity")]
360    KeepTogetherExceedsSystemCapacity,
361    #[error("keep-together range conflicts with an explicit system or page break")]
362    KeepTogetherConflictsWithExplicitBreak,
363    #[error("repeat section exceeds the systems-per-page capacity")]
364    RepeatRangeExceedsPageCapacity,
365}
366
367fn apply_keep_together(
368    score: &Score,
369    mut rows: Vec<crate::RowLayout>,
370    ranges: &[KeepTogetherRange],
371    capacity: usize,
372) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
373    let measure_count = score
374        .parts
375        .first()
376        .and_then(|part| part.staves.first())
377        .map(|staff| staff.measures.len())
378        .unwrap_or(0);
379    for range in ranges {
380        let length = range
381            .last_measure
382            .checked_sub(range.first_measure)
383            .and_then(|length| length.checked_add(1));
384        if range.first_measure > range.last_measure || range.last_measure >= measure_count {
385            return Err(PrintLayoutError::InvalidKeepTogetherRange);
386        }
387        if length.is_none_or(|length| length > capacity) {
388            return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
389        }
390        for measure_index in range.first_measure..range.last_measure {
391            let has_break = score
392                .parts
393                .iter()
394                .flat_map(|part| part.staves.iter())
395                .filter_map(|staff| staff.measures.get(measure_index))
396                .any(|measure| measure.system_break || measure.page_break);
397            if has_break {
398                return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
399            }
400        }
401
402        // Split at the range boundaries before merging rows. This allows a range that
403        // crosses an existing system boundary to be reflowed without pulling unrelated
404        // measures into the merged system.
405        let mut split_rows = Vec::with_capacity(rows.len() + 2);
406        for row in rows {
407            let mut cuts = vec![0, row.measure_indices.len()];
408            if let Some(position) = row
409                .measure_indices
410                .iter()
411                .position(|&index| index == range.first_measure)
412            {
413                cuts.push(position);
414            }
415            if let Some(position) = row
416                .measure_indices
417                .iter()
418                .position(|&index| index == range.last_measure)
419            {
420                cuts.push(position + 1);
421            }
422            cuts.sort_unstable();
423            cuts.dedup();
424            for window in cuts.windows(2) {
425                if window[0] < window[1] {
426                    split_rows.push(crate::RowLayout {
427                        measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
428                    });
429                }
430            }
431        }
432        rows = split_rows;
433
434        let first_row = rows
435            .iter()
436            .position(|row| row.measure_indices.contains(&range.first_measure));
437        let last_row = rows
438            .iter()
439            .position(|row| row.measure_indices.contains(&range.last_measure));
440        let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
441            return Err(PrintLayoutError::InvalidKeepTogetherRange);
442        };
443
444        if first_row != last_row {
445            let merged: Vec<usize> = rows[first_row..=last_row]
446                .iter()
447                .flat_map(|row| row.measure_indices.iter().copied())
448                .collect();
449            if merged.len() > capacity {
450                return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
451            }
452            rows.splice(
453                first_row..=last_row,
454                [crate::RowLayout {
455                    measure_indices: merged,
456                }],
457            );
458        }
459
460        let row_index = rows
461            .iter()
462            .position(|row| row.measure_indices.contains(&range.first_measure))
463            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
464        let row = rows.remove(row_index);
465        let start = row
466            .measure_indices
467            .iter()
468            .position(|&index| index == range.first_measure)
469            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
470        let end = row
471            .measure_indices
472            .iter()
473            .position(|&index| index == range.last_measure)
474            .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
475        let mut replacement = Vec::new();
476        if start > 0 {
477            replacement.push(crate::RowLayout {
478                measure_indices: row.measure_indices[..start].to_vec(),
479            });
480        }
481        replacement.push(crate::RowLayout {
482            measure_indices: row.measure_indices[start..=end].to_vec(),
483        });
484        if end + 1 < row.measure_indices.len() {
485            replacement.push(crate::RowLayout {
486                measure_indices: row.measure_indices[end + 1..].to_vec(),
487            });
488        }
489        rows.splice(row_index..row_index, replacement);
490    }
491    Ok(rows)
492}
493
494fn has_first_measure_pickup(score: &Score) -> bool {
495    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
496        return false;
497    };
498    let Some(measure) = staff.measures.first() else {
499        return false;
500    };
501    let expected = measure
502        .time_sig
503        .as_ref()
504        .unwrap_or(&score.settings.time_signature)
505        .total_beats();
506    let actual = measure
507        .voices
508        .iter()
509        .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
510        .fold(0.0, f64::max);
511    actual > 1e-9 && actual + 1e-9 < expected
512}
513
514fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
515    let measure_count = score
516        .parts
517        .first()
518        .and_then(|part| part.staves.first())
519        .map(|staff| staff.measures.len())
520        .unwrap_or(0);
521    measure_indices
522        .iter()
523        .filter_map(|&first_measure| {
524            if first_measure >= measure_count {
525                return None;
526            }
527            let count = score
528                .parts
529                .iter()
530                .flat_map(|part| part.staves.iter())
531                .filter_map(|staff| staff.measures.get(first_measure))
532                .filter_map(|measure| measure.multi_rest_count)
533                .map(usize::from)
534                .max()
535                .unwrap_or(1)
536                .max(1);
537            Some(MeasureSpan {
538                first_measure,
539                last_measure: first_measure
540                    .saturating_add(count.saturating_sub(1))
541                    .min(measure_count.saturating_sub(1)),
542            })
543        })
544        .collect()
545}
546
547fn span_bounds(span: &SpanMark) -> (usize, usize) {
548    match span {
549        SpanMark::Hairpin { start, end, .. }
550        | SpanMark::Ottava { start, end, .. }
551        | SpanMark::Pedal { start, end }
552        | SpanMark::Slur { start, end }
553        | SpanMark::TrillLine { start, end }
554        | SpanMark::Glissando { start, end } => (
555            start.measure.min(end.measure),
556            start.measure.max(end.measure),
557        ),
558    }
559}
560
561fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
562    let (Some(&first_measure), Some(&last_measure)) =
563        (measure_indices.first(), measure_indices.last())
564    else {
565        return Vec::new();
566    };
567    spans
568        .iter()
569        .enumerate()
570        .filter_map(|(span_index, span)| {
571            let (start_measure, end_measure) = span_bounds(span);
572            (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
573                span_index,
574                starts_here: (first_measure..=last_measure).contains(&start_measure),
575                ends_here: (first_measure..=last_measure).contains(&end_measure),
576            })
577        })
578        .collect()
579}
580
581fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
582    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
583        return Vec::new();
584    };
585    measure_indices
586        .iter()
587        .filter_map(|&measure_index| {
588            let measure = staff.measures.get(measure_index)?;
589            let repeat_start = matches!(
590                measure.barline_left,
591                Barline::RepeatStart | Barline::RepeatBoth
592            );
593            let repeat_end = matches!(
594                measure.barline_right,
595                Barline::RepeatEnd | Barline::RepeatBoth
596            );
597            let has_mark = repeat_start
598                || repeat_end
599                || measure.volta.is_some()
600                || measure.navigation.is_some()
601                || measure.rehearsal.is_some();
602            has_mark.then(|| MeasureMark {
603                measure_index,
604                repeat_start,
605                repeat_end,
606                volta_number: measure.volta.as_ref().map(|volta| volta.number),
607                volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
608                navigation: measure.navigation.clone(),
609                rehearsal: measure.rehearsal.clone(),
610            })
611        })
612        .collect()
613}
614
615fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
616    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
617        return Vec::new();
618    };
619    let mut ranges = Vec::new();
620    let mut start = None;
621    for (index, measure) in staff.measures.iter().enumerate() {
622        let Some(volta) = measure.volta.as_ref() else {
623            continue;
624        };
625        if matches!(volta.kind.as_str(), "begin" | "begin_end") {
626            start = Some(index);
627        }
628        if matches!(volta.kind.as_str(), "end" | "begin_end")
629            && let Some(first_measure) = start.take()
630        {
631            ranges.push(KeepTogetherRange {
632                first_measure,
633                last_measure: index,
634            });
635        }
636    }
637    ranges
638}
639
640fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
641    let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
642        return Vec::new();
643    };
644    let mut ranges = Vec::new();
645    let mut start = None;
646    for (index, measure) in staff.measures.iter().enumerate() {
647        if matches!(
648            measure.barline_left,
649            Barline::RepeatStart | Barline::RepeatBoth
650        ) {
651            start = Some(index);
652        }
653        if matches!(
654            measure.barline_right,
655            Barline::RepeatEnd | Barline::RepeatBoth
656        ) {
657            ranges.push(KeepTogetherRange {
658                first_measure: start.take().unwrap_or(0),
659                last_measure: index,
660            });
661        }
662    }
663    ranges
664}
665
666fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
667    repeat_ranges(score)
668        .into_iter()
669        .filter_map(|range| {
670            let first = rows
671                .iter()
672                .position(|row| row.measure_indices.contains(&range.first_measure))?;
673            let last = rows
674                .iter()
675                .position(|row| row.measure_indices.contains(&range.last_measure))?;
676            Some((first, last))
677        })
678        .collect()
679}
680
681fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
682    let mut segments = Vec::new();
683    for system in systems {
684        for segment in &system.span_segments {
685            if let Some(existing) = segments
686                .iter_mut()
687                .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
688            {
689                existing.ends_here |= segment.ends_here;
690            } else {
691                segments.push(PageSpanSegment {
692                    span_index: segment.span_index,
693                    starts_here: segment.starts_here,
694                    ends_here: segment.ends_here,
695                });
696            }
697        }
698    }
699    segments
700}
701
702fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
703    systems
704        .iter()
705        .flat_map(|system| system.measure_marks.iter().cloned())
706        .collect()
707}
708
709/// Compute physical page and system placement without rendering or host integration.
710pub fn compute_print_layout(
711    score: &Score,
712    config: &PrintConfig,
713) -> Result<PrintLayoutResult, PrintLayoutError> {
714    let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
715    if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
716        return Err(PrintLayoutError::InvalidPaperDimensions);
717    }
718    if matches!(config.orientation, PageOrientation::Landscape) {
719        std::mem::swap(&mut width_mm, &mut height_mm);
720    }
721
722    let margins = [
723        config.margin_top_mm,
724        config.margin_right_mm,
725        config.margin_bottom_mm,
726        config.margin_left_mm,
727        config.bleed_top_mm,
728        config.bleed_right_mm,
729        config.bleed_bottom_mm,
730        config.bleed_left_mm,
731        config.safe_top_mm,
732        config.safe_right_mm,
733        config.safe_bottom_mm,
734        config.safe_left_mm,
735    ];
736    if margins
737        .iter()
738        .any(|value| !value.is_finite() || *value < 0.0)
739    {
740        return Err(PrintLayoutError::InvalidMargins);
741    }
742    if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
743        return Err(PrintLayoutError::InvalidSystemHeight);
744    }
745    if !config.scale.is_finite() || config.scale <= 0.0 {
746        return Err(PrintLayoutError::InvalidScale);
747    }
748    let scaled_system_height_mm = config.system_height_mm * config.scale;
749    if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
750        return Err(PrintLayoutError::InvalidScale);
751    }
752
753    let content_width_mm = width_mm
754        - config.margin_left_mm
755        - config.margin_right_mm
756        - config.safe_left_mm
757        - config.safe_right_mm;
758    let content_height_mm = height_mm
759        - config.margin_top_mm
760        - config.margin_bottom_mm
761        - config.safe_top_mm
762        - config.safe_bottom_mm;
763    if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
764        return Err(PrintLayoutError::NoUsablePageArea);
765    }
766
767    let systems_per_page = config
768        .systems_per_page
769        .unwrap_or_else(|| {
770            (content_height_mm / scaled_system_height_mm)
771                .floor()
772                .max(1.0) as usize
773        })
774        .max(1);
775    let layout = compute_layout(
776        score,
777        &LayoutConfig {
778            measures_per_row: config.measures_per_system.max(1),
779            first_row_measures: config.first_system_measures.or_else(|| {
780                (matches!(
781                    config.pickup_policy,
782                    PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
783                ) && has_first_measure_pickup(score))
784                .then_some(1)
785            }),
786            ..LayoutConfig::default()
787        },
788    );
789
790    let mut keep_together = config.keep_together.clone();
791    if matches!(
792        config.notation_break_policy,
793        NotationBreakPolicy::KeepVoltaTogether
794    ) {
795        keep_together.extend(volta_ranges(score));
796    }
797    let rows = apply_keep_together(
798        score,
799        layout.rows,
800        &keep_together,
801        config.measures_per_system.max(1),
802    )?;
803
804    let has_explicit_page_break = rows.iter().any(|row| {
805        row.measure_indices.last().is_some_and(|&measure_index| {
806            score
807                .parts
808                .iter()
809                .flat_map(|part| part.staves.iter())
810                .filter_map(|staff| staff.measures.get(measure_index))
811                .any(|measure| measure.page_break)
812        })
813    });
814    let repeat_system_ranges = if matches!(
815        config.notation_break_policy,
816        NotationBreakPolicy::KeepRepeatsTogether
817    ) {
818        repeat_system_ranges(score, &rows)
819    } else {
820        Vec::new()
821    };
822    if repeat_system_ranges
823        .iter()
824        .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
825    {
826        return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
827    }
828    let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
829        && !has_explicit_page_break
830        && systems_per_page > 1
831        && rows.len() > systems_per_page
832        && repeat_system_ranges.is_empty()
833    {
834        let page_count = rows.len().div_ceil(systems_per_page);
835        let base = rows.len() / page_count;
836        let remainder = rows.len() % page_count;
837        (0..page_count)
838            .map(|index| base + usize::from(index < remainder))
839            .collect::<Vec<_>>()
840    } else {
841        Vec::new()
842    };
843
844    let mut pages = Vec::new();
845    let mut page_systems = Vec::new();
846    let mut page_index = 0;
847    for (system_index, row) in rows.iter().enumerate() {
848        let repeat_starts_here = repeat_system_ranges
849            .iter()
850            .any(|(first, _)| *first == system_index);
851        if repeat_starts_here && !page_systems.is_empty() {
852            pages.push(PageLayout {
853                address: PageAddress { page_index },
854                page_index,
855                page_number: match config.page_numbering {
856                    PageNumbering::None => None,
857                    PageNumbering::OneBased => Some(page_index + 1),
858                },
859                color_policy: config.color_policy,
860                crop_mark_policy: config.crop_mark_policy,
861                glyph_resources: config.glyph_resources.clone(),
862                width_mm,
863                height_mm,
864                content_width_mm,
865                content_height_mm,
866                bleed_top_mm: config.bleed_top_mm,
867                bleed_right_mm: config.bleed_right_mm,
868                bleed_bottom_mm: config.bleed_bottom_mm,
869                bleed_left_mm: config.bleed_left_mm,
870                span_segments: page_span_segments(&page_systems),
871                measure_marks: page_measure_marks(&page_systems),
872                systems: std::mem::take(&mut page_systems),
873                break_reason: BreakReason::PageCapacity,
874            });
875            page_index += 1;
876        }
877        let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
878            score
879                .parts
880                .iter()
881                .flat_map(|part| part.staves.iter())
882                .filter_map(|staff| staff.measures.get(measure_index))
883                .any(|measure| measure.page_break)
884        });
885        let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
886            score
887                .parts
888                .iter()
889                .flat_map(|part| part.staves.iter())
890                .filter_map(|staff| staff.measures.get(measure_index))
891                .any(|measure| measure.system_break)
892        });
893        let is_last_system = system_index + 1 == rows.len();
894        let break_reason = if explicit_page_break {
895            BreakReason::ExplicitPageBreak
896        } else if explicit_system_break {
897            BreakReason::ExplicitSystemBreak
898        } else if is_last_system {
899            BreakReason::EndOfScore
900        } else {
901            BreakReason::MeasureCapacity
902        };
903        let system = SystemLayout {
904            address: SystemAddress {
905                system_index,
906                page_index,
907                index_on_page: page_systems.len(),
908            },
909            system_index,
910            page_index,
911            measure_indices: row.measure_indices.clone(),
912            measure_spans: measure_spans(score, &row.measure_indices),
913            span_segments: span_segments(&layout.spans, &row.measure_indices),
914            measure_marks: measure_marks(score, &row.measure_indices),
915            top_mm: config.margin_top_mm
916                + config.safe_top_mm
917                + page_systems.len() as f32 * scaled_system_height_mm,
918            height_mm: scaled_system_height_mm,
919            break_reason,
920        };
921        page_systems.push(system);
922
923        let page_capacity = page_capacities
924            .get(page_index)
925            .copied()
926            .unwrap_or(systems_per_page);
927        let page_is_full = page_systems.len() >= page_capacity;
928        if page_is_full || explicit_page_break {
929            let page_break_reason = if explicit_page_break {
930                BreakReason::ExplicitPageBreak
931            } else if is_last_system {
932                BreakReason::EndOfScore
933            } else {
934                BreakReason::PageCapacity
935            };
936            pages.push(PageLayout {
937                address: PageAddress { page_index },
938                page_index,
939                page_number: match config.page_numbering {
940                    PageNumbering::None => None,
941                    PageNumbering::OneBased => Some(page_index + 1),
942                },
943                color_policy: config.color_policy,
944                crop_mark_policy: config.crop_mark_policy,
945                glyph_resources: config.glyph_resources.clone(),
946                width_mm,
947                height_mm,
948                content_width_mm,
949                content_height_mm,
950                bleed_top_mm: config.bleed_top_mm,
951                bleed_right_mm: config.bleed_right_mm,
952                bleed_bottom_mm: config.bleed_bottom_mm,
953                bleed_left_mm: config.bleed_left_mm,
954                span_segments: page_span_segments(&page_systems),
955                measure_marks: page_measure_marks(&page_systems),
956                systems: std::mem::take(&mut page_systems),
957                break_reason: page_break_reason,
958            });
959            page_index += 1;
960        }
961    }
962    if !page_systems.is_empty() || pages.is_empty() {
963        pages.push(PageLayout {
964            address: PageAddress { page_index },
965            page_index,
966            page_number: match config.page_numbering {
967                PageNumbering::None => None,
968                PageNumbering::OneBased => Some(page_index + 1),
969            },
970            color_policy: config.color_policy,
971            crop_mark_policy: config.crop_mark_policy,
972            glyph_resources: config.glyph_resources.clone(),
973            width_mm,
974            height_mm,
975            content_width_mm,
976            content_height_mm,
977            bleed_top_mm: config.bleed_top_mm,
978            bleed_right_mm: config.bleed_right_mm,
979            bleed_bottom_mm: config.bleed_bottom_mm,
980            bleed_left_mm: config.bleed_left_mm,
981            span_segments: page_span_segments(&page_systems),
982            measure_marks: page_measure_marks(&page_systems),
983            systems: page_systems,
984            break_reason: BreakReason::EndOfScore,
985        });
986    }
987
988    Ok(PrintLayoutResult {
989        contract_version: 15,
990        pages,
991    })
992}
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997    use acorde_core::{Clef, Duration, Measure, Note, Part, Pitch, Score, Staff, Step};
998
999    fn score_with_measures(count: usize) -> Score {
1000        let mut score = Score::default();
1001        let mut part = Part::new("Piano", "Pno.");
1002        let mut staff = Staff::new(Clef::Treble);
1003        staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
1004        part.staves = vec![staff];
1005        score.parts = vec![part];
1006        score
1007    }
1008
1009    #[test]
1010    fn paginates_rows_and_preserves_measure_indices() {
1011        let score = score_with_measures(5);
1012        let result = compute_print_layout(
1013            &score,
1014            &PrintConfig {
1015                measures_per_system: 2,
1016                systems_per_page: Some(2),
1017                ..PrintConfig::default()
1018            },
1019        )
1020        .expect("valid print config");
1021        assert_eq!(result.pages.len(), 2);
1022        assert_eq!(
1023            result.pages[0]
1024                .systems
1025                .iter()
1026                .map(|s| s.measure_indices.clone())
1027                .collect::<Vec<_>>(),
1028            vec![vec![0, 1], vec![2, 3]]
1029        );
1030        assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
1031        assert_eq!(result.pages[1].systems[0].page_index, 1);
1032        assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
1033        assert_eq!(
1034            result.pages[1].systems[0].break_reason,
1035            BreakReason::EndOfScore
1036        );
1037        assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
1038    }
1039
1040    #[test]
1041    fn forced_page_break_starts_next_system_on_next_page() {
1042        let mut score = score_with_measures(3);
1043        score.parts[0].staves[0].measures[0].page_break = true;
1044        let result = compute_print_layout(
1045            &score,
1046            &PrintConfig {
1047                measures_per_system: 3,
1048                systems_per_page: Some(8),
1049                ..PrintConfig::default()
1050            },
1051        )
1052        .expect("valid print config");
1053        assert_eq!(result.pages.len(), 2);
1054        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
1055        assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
1056        assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
1057        assert_eq!(
1058            result.pages[0].systems[0].break_reason,
1059            BreakReason::ExplicitPageBreak
1060        );
1061    }
1062
1063    #[test]
1064    fn keep_together_range_is_not_split_across_systems() {
1065        let score = score_with_measures(5);
1066        let result = compute_print_layout(
1067            &score,
1068            &PrintConfig {
1069                measures_per_system: 3,
1070                systems_per_page: Some(8),
1071                keep_together: vec![KeepTogetherRange {
1072                    first_measure: 1,
1073                    last_measure: 2,
1074                }],
1075                ..PrintConfig::default()
1076            },
1077        )
1078        .expect("valid keep-together range");
1079        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
1080        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
1081        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
1082    }
1083
1084    #[test]
1085    fn first_system_measure_capacity_is_preserved_in_print_layout() {
1086        let score = score_with_measures(5);
1087        let result = compute_print_layout(
1088            &score,
1089            &PrintConfig {
1090                measures_per_system: 3,
1091                first_system_measures: Some(1),
1092                systems_per_page: Some(8),
1093                ..PrintConfig::default()
1094            },
1095        )
1096        .expect("valid first-system capacity");
1097        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
1098        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
1099        assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
1100    }
1101
1102    #[test]
1103    fn pickup_policy_isolates_a_partial_first_measure() {
1104        let mut score = score_with_measures(4);
1105        score.parts[0].staves[0].measures[0].voices[0] =
1106            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
1107        let result = compute_print_layout(
1108            &score,
1109            &PrintConfig {
1110                measures_per_system: 3,
1111                pickup_policy: PickupPolicy::DetectFirstMeasure,
1112                systems_per_page: Some(8),
1113                ..PrintConfig::default()
1114            },
1115        )
1116        .expect("valid pickup policy");
1117        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
1118        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
1119    }
1120
1121    #[test]
1122    fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
1123        let mut score = score_with_measures(4);
1124        score.parts[0].staves[0].measures[0].voices[0] =
1125            vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
1126        let result = compute_print_layout(
1127            &score,
1128            &PrintConfig {
1129                measures_per_system: 3,
1130                systems_per_page: Some(8),
1131                ..PrintConfig::default()
1132            },
1133        )
1134        .expect("valid automatic pickup policy");
1135        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
1136        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
1137    }
1138
1139    #[test]
1140    fn system_exposes_physical_span_for_multi_rest_slot() {
1141        let mut score = score_with_measures(6);
1142        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
1143        let result = compute_print_layout(&score, &PrintConfig::default())
1144            .expect("valid multi-rest print layout");
1145        assert_eq!(
1146            result.pages[0].systems[0].measure_spans[1],
1147            MeasureSpan {
1148                first_measure: 1,
1149                last_measure: 3,
1150            }
1151        );
1152    }
1153
1154    #[test]
1155    fn multirest_width_drives_system_breaking_without_splitting() {
1156        let mut score = score_with_measures(5);
1157        score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
1158        let result = compute_print_layout(
1159            &score,
1160            &PrintConfig {
1161                measures_per_system: 2,
1162                pickup_policy: PickupPolicy::Preserve,
1163                systems_per_page: Some(8),
1164                ..PrintConfig::default()
1165            },
1166        )
1167        .expect("valid multi-rest pagination");
1168        assert_eq!(
1169            result.pages[0]
1170                .systems
1171                .iter()
1172                .map(|system| system.measure_indices.clone())
1173                .collect::<Vec<_>>(),
1174            vec![vec![0], vec![1], vec![2, 3], vec![4]]
1175        );
1176        assert_eq!(
1177            result.pages[0].systems[1].measure_spans[0],
1178            MeasureSpan {
1179                first_measure: 1,
1180                last_measure: 3,
1181            }
1182        );
1183    }
1184
1185    #[test]
1186    fn system_exposes_cross_system_span_segments() {
1187        let mut score = score_with_measures(4);
1188        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1189        start.slur_start = true;
1190        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1191        end.slur_end = true;
1192        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
1193        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
1194        let result = compute_print_layout(
1195            &score,
1196            &PrintConfig {
1197                measures_per_system: 2,
1198                pickup_policy: PickupPolicy::Preserve,
1199                systems_per_page: Some(8),
1200                ..PrintConfig::default()
1201            },
1202        )
1203        .expect("valid cross-system span layout");
1204        assert_eq!(
1205            result.pages[0].systems[0].span_segments,
1206            vec![SpanSegment {
1207                span_index: 0,
1208                starts_here: true,
1209                ends_here: false,
1210            }]
1211        );
1212        assert_eq!(
1213            result.pages[0].systems[1].span_segments,
1214            vec![SpanSegment {
1215                span_index: 0,
1216                starts_here: false,
1217                ends_here: true,
1218            }]
1219        );
1220    }
1221
1222    #[test]
1223    fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
1224        let mut score = score_with_measures(4);
1225        let measures = &mut score.parts[0].staves[0].measures;
1226        measures[0].barline_right = Barline::RepeatEnd;
1227        measures[1].barline_left = Barline::RepeatStart;
1228        measures[2].volta = Some(acorde_core::VoltaBracket {
1229            number: 1,
1230            kind: "begin".to_string(),
1231        });
1232        measures[2].navigation = Some("ToCoda".to_string());
1233        measures[2].rehearsal = Some("B".to_string());
1234        let result = compute_print_layout(
1235            &score,
1236            &PrintConfig {
1237                measures_per_system: 2,
1238                systems_per_page: Some(8),
1239                ..PrintConfig::default()
1240            },
1241        )
1242        .expect("valid measure mark layout");
1243        assert_eq!(
1244            result.pages[0].systems[0].measure_marks,
1245            vec![
1246                MeasureMark {
1247                    measure_index: 0,
1248                    repeat_start: false,
1249                    repeat_end: true,
1250                    volta_number: None,
1251                    volta_kind: None,
1252                    navigation: None,
1253                    rehearsal: None,
1254                },
1255                MeasureMark {
1256                    measure_index: 1,
1257                    repeat_start: true,
1258                    repeat_end: false,
1259                    volta_number: None,
1260                    volta_kind: None,
1261                    navigation: None,
1262                    rehearsal: None,
1263                },
1264            ]
1265        );
1266        assert_eq!(
1267            result.pages[0].systems[1].measure_marks,
1268            vec![MeasureMark {
1269                measure_index: 2,
1270                repeat_start: false,
1271                repeat_end: false,
1272                volta_number: Some(1),
1273                volta_kind: Some("begin".to_string()),
1274                navigation: Some("ToCoda".to_string()),
1275                rehearsal: Some("B".to_string()),
1276            }]
1277        );
1278    }
1279
1280    #[test]
1281    fn page_aggregates_cross_system_span_ownership() {
1282        let mut score = score_with_measures(4);
1283        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1284        start.slur_start = true;
1285        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1286        end.slur_end = true;
1287        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
1288        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
1289        let result = compute_print_layout(
1290            &score,
1291            &PrintConfig {
1292                measures_per_system: 2,
1293                pickup_policy: PickupPolicy::Preserve,
1294                systems_per_page: Some(1),
1295                ..PrintConfig::default()
1296            },
1297        )
1298        .expect("valid page span layout");
1299        assert_eq!(
1300            result.pages[0].span_segments,
1301            vec![PageSpanSegment {
1302                span_index: 0,
1303                starts_here: true,
1304                ends_here: false,
1305            }]
1306        );
1307        assert_eq!(
1308            result.pages[1].span_segments,
1309            vec![PageSpanSegment {
1310                span_index: 0,
1311                starts_here: false,
1312                ends_here: true,
1313            }]
1314        );
1315    }
1316
1317    #[test]
1318    fn page_artifact_measure_span_borrows_system_spans() {
1319        let mut score = score_with_measures(4);
1320        let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
1321        start.slur_start = true;
1322        let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
1323        end.slur_end = true;
1324        score.parts[0].staves[0].measures[0].voices[0] = vec![start];
1325        score.parts[0].staves[0].measures[3].voices[0] = vec![end];
1326        let result = compute_print_layout(
1327            &score,
1328            &PrintConfig {
1329                measures_per_system: 2,
1330                pickup_policy: PickupPolicy::Preserve,
1331                systems_per_page: Some(1),
1332                ..PrintConfig::default()
1333            },
1334        )
1335        .expect("valid page artifact");
1336        let first = result
1337            .page(PageAddress { page_index: 0 })
1338            .expect("first page");
1339        assert_eq!(
1340            first.measure_span(),
1341            Some(MeasureSpan {
1342                first_measure: 0,
1343                last_measure: 1,
1344            })
1345        );
1346        assert!(first.has_span_continuation());
1347        assert!(result.page(PageAddress { page_index: 99 }).is_none());
1348    }
1349
1350    #[test]
1351    fn notation_policy_keeps_volta_range_in_one_system() {
1352        let mut score = score_with_measures(4);
1353        score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
1354            number: 1,
1355            kind: "begin".to_string(),
1356        });
1357        score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
1358            number: 1,
1359            kind: "end".to_string(),
1360        });
1361        let result = compute_print_layout(
1362            &score,
1363            &PrintConfig {
1364                measures_per_system: 2,
1365                systems_per_page: Some(8),
1366                notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
1367                ..PrintConfig::default()
1368            },
1369        )
1370        .expect("valid volta-preserving layout");
1371        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
1372        assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
1373        assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
1374    }
1375
1376    #[test]
1377    fn notation_policy_keeps_repeat_section_on_one_page() {
1378        let mut score = score_with_measures(5);
1379        score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
1380        score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
1381        let result = compute_print_layout(
1382            &score,
1383            &PrintConfig {
1384                measures_per_system: 2,
1385                systems_per_page: Some(2),
1386                notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
1387                ..PrintConfig::default()
1388            },
1389        )
1390        .expect("valid repeat-preserving layout");
1391        assert_eq!(result.pages[0].systems.len(), 1);
1392        assert_eq!(result.pages[1].systems.len(), 2);
1393        assert_eq!(
1394            result.pages[1]
1395                .systems
1396                .iter()
1397                .flat_map(|system| system.measure_indices.iter().copied())
1398                .collect::<Vec<_>>(),
1399            vec![2, 3, 4]
1400        );
1401    }
1402
1403    #[test]
1404    fn balance_policy_avoids_single_system_final_page() {
1405        let score = score_with_measures(5);
1406        let result = compute_print_layout(
1407            &score,
1408            &PrintConfig {
1409                measures_per_system: 1,
1410                systems_per_page: Some(4),
1411                final_page_policy: FinalPagePolicy::Balance,
1412                ..PrintConfig::default()
1413            },
1414        )
1415        .expect("valid balanced print config");
1416        assert_eq!(result.pages.len(), 2);
1417        assert_eq!(result.pages[0].systems.len(), 3);
1418        assert_eq!(result.pages[1].systems.len(), 2);
1419    }
1420
1421    #[test]
1422    fn balance_policy_preserves_explicit_page_breaks() {
1423        let mut score = score_with_measures(5);
1424        score.parts[0].staves[0].measures[1].page_break = true;
1425        let result = compute_print_layout(
1426            &score,
1427            &PrintConfig {
1428                measures_per_system: 1,
1429                systems_per_page: Some(4),
1430                final_page_policy: FinalPagePolicy::Balance,
1431                ..PrintConfig::default()
1432            },
1433        )
1434        .expect("valid explicit-break print config");
1435        assert_eq!(result.pages[0].systems.len(), 2);
1436        assert_eq!(result.pages[1].systems.len(), 3);
1437    }
1438
1439    #[test]
1440    fn keep_together_rejects_ranges_larger_than_system_capacity() {
1441        let score = score_with_measures(4);
1442        let error = compute_print_layout(
1443            &score,
1444            &PrintConfig {
1445                measures_per_system: 2,
1446                keep_together: vec![KeepTogetherRange {
1447                    first_measure: 0,
1448                    last_measure: 2,
1449                }],
1450                ..PrintConfig::default()
1451            },
1452        )
1453        .expect_err("range must fit in one system");
1454        assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1455    }
1456
1457    #[test]
1458    fn keep_together_rejects_explicit_break_inside_range() {
1459        let mut score = score_with_measures(4);
1460        score.parts[0].staves[0].measures[1].system_break = true;
1461        let error = compute_print_layout(
1462            &score,
1463            &PrintConfig {
1464                measures_per_system: 3,
1465                keep_together: vec![KeepTogetherRange {
1466                    first_measure: 0,
1467                    last_measure: 2,
1468                }],
1469                ..PrintConfig::default()
1470            },
1471        )
1472        .expect_err("explicit break must win");
1473        assert_eq!(
1474            error,
1475            PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
1476        );
1477    }
1478
1479    #[test]
1480    fn rejects_margins_that_leave_no_page_area() {
1481        let score = score_with_measures(1);
1482        let error = compute_print_layout(
1483            &score,
1484            &PrintConfig {
1485                margin_left_mm: 200.0,
1486                ..PrintConfig::default()
1487            },
1488        )
1489        .expect_err("invalid page area");
1490        assert_eq!(error, PrintLayoutError::NoUsablePageArea);
1491    }
1492
1493    #[test]
1494    fn safe_area_reduces_content_and_bleed_is_exposed() {
1495        let score = score_with_measures(1);
1496        let result = compute_print_layout(
1497            &score,
1498            &PrintConfig {
1499                bleed_top_mm: 3.0,
1500                bleed_right_mm: 3.0,
1501                bleed_bottom_mm: 3.0,
1502                bleed_left_mm: 3.0,
1503                safe_top_mm: 5.0,
1504                safe_right_mm: 6.0,
1505                safe_bottom_mm: 7.0,
1506                safe_left_mm: 8.0,
1507                ..PrintConfig::default()
1508            },
1509        )
1510        .expect("valid print config");
1511        let page = &result.pages[0];
1512        assert_eq!(result.contract_version, 15);
1513        assert_eq!(page.bleed_left_mm, 3.0);
1514        assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
1515        assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
1516        assert_eq!(page.systems[0].top_mm, 21.0);
1517    }
1518
1519    #[test]
1520    fn scale_changes_system_height_and_page_capacity() {
1521        let score = score_with_measures(10);
1522        let result = compute_print_layout(
1523            &score,
1524            &PrintConfig {
1525                scale: 2.0,
1526                measures_per_system: 1,
1527                systems_per_page: None,
1528                ..PrintConfig::default()
1529            },
1530        )
1531        .expect("valid print config");
1532        assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
1533        assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
1534        assert_eq!(result.pages.len(), 2);
1535    }
1536
1537    #[test]
1538    fn rejects_non_positive_scale() {
1539        let score = score_with_measures(1);
1540        let error = compute_print_layout(
1541            &score,
1542            &PrintConfig {
1543                scale: 0.0,
1544                ..PrintConfig::default()
1545            },
1546        )
1547        .expect_err("invalid scale");
1548        assert_eq!(error, PrintLayoutError::InvalidScale);
1549    }
1550
1551    #[test]
1552    fn page_numbering_is_configurable() {
1553        let score = score_with_measures(5);
1554        let numbered = compute_print_layout(
1555            &score,
1556            &PrintConfig {
1557                measures_per_system: 1,
1558                systems_per_page: Some(2),
1559                ..PrintConfig::default()
1560            },
1561        )
1562        .expect("valid print config");
1563        assert_eq!(numbered.pages[0].page_number, Some(1));
1564        assert_eq!(numbered.pages[1].page_number, Some(2));
1565
1566        let unnumbered = compute_print_layout(
1567            &score,
1568            &PrintConfig {
1569                page_numbering: PageNumbering::None,
1570                measures_per_system: 1,
1571                systems_per_page: Some(2),
1572                ..PrintConfig::default()
1573            },
1574        )
1575        .expect("valid print config");
1576        assert!(
1577            unnumbered
1578                .pages
1579                .iter()
1580                .all(|page| page.page_number.is_none())
1581        );
1582    }
1583
1584    #[test]
1585    fn print_color_and_crop_policies_are_exposed_per_page() {
1586        let score = score_with_measures(1);
1587        let result = compute_print_layout(
1588            &score,
1589            &PrintConfig {
1590                color_policy: PrintColorPolicy::Preserve,
1591                crop_mark_policy: CropMarkPolicy::BleedEdges,
1592                ..PrintConfig::default()
1593            },
1594        )
1595        .expect("valid print config");
1596        let page = &result.pages[0];
1597        assert_eq!(result.contract_version, 15);
1598        assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
1599        assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
1600    }
1601
1602    #[test]
1603    fn glyph_resource_policy_is_exposed_per_page() {
1604        let score = score_with_measures(1);
1605        let result = compute_print_layout(
1606            &score,
1607            &PrintConfig {
1608                glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
1609                ..PrintConfig::default()
1610            },
1611        )
1612        .expect("valid print config");
1613        assert_eq!(
1614            result.pages[0].glyph_resources,
1615            GlyphResourcePolicy::HostProvided("music-font-v1".into())
1616        );
1617    }
1618}