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