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