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