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