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