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