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