1use crate::{LayoutConfig, SpanMark, compute_layout};
2use acorde_core::{Barline, PartGroupSymbol, 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
30#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
32pub struct GlyphExtents {
33 pub left_mm: f32,
34 pub top_mm: f32,
35 pub right_mm: f32,
36 pub bottom_mm: f32,
37}
38
39impl GlyphExtents {
40 pub fn width_mm(self) -> f32 {
42 self.right_mm - self.left_mm
43 }
44
45 pub fn height_mm(self) -> f32 {
47 self.bottom_mm - self.top_mm
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
53pub enum GlyphPlacementError {
54 #[error("glyph placement {index} contains non-finite geometry")]
55 NonFinite { index: usize },
56 #[error("glyph placement {index} has a negative bounding-box extent")]
57 NegativeExtent { index: usize },
58 #[error("glyph spacing is non-finite or overflows")]
59 NonFiniteSpacing,
60 #[error("glyph placement {index} has an empty resource key")]
61 EmptyResourceKey { index: usize },
62 #[error("glyph placement {index} has a negative advance")]
63 NegativeAdvance { index: usize },
64}
65
66pub fn validate_glyph_placements(placements: &[GlyphPlacement]) -> Result<(), GlyphPlacementError> {
68 for (index, placement) in placements.iter().enumerate() {
69 if placement.resource_key.trim().is_empty() {
70 return Err(GlyphPlacementError::EmptyResourceKey { index });
71 }
72 let values = [
73 placement.metrics.advance_mm,
74 placement.metrics.left_mm,
75 placement.metrics.top_mm,
76 placement.metrics.width_mm,
77 placement.metrics.height_mm,
78 placement.x_mm,
79 placement.y_mm,
80 ];
81 if values.iter().any(|value| !value.is_finite()) {
82 return Err(GlyphPlacementError::NonFinite { index });
83 }
84 if placement.metrics.width_mm < 0.0 || placement.metrics.height_mm < 0.0 {
85 return Err(GlyphPlacementError::NegativeExtent { index });
86 }
87 if placement.metrics.advance_mm < 0.0 {
88 return Err(GlyphPlacementError::NegativeAdvance { index });
89 }
90 }
91 Ok(())
92}
93
94pub fn glyph_extents(
96 placements: &[GlyphPlacement],
97) -> Result<Option<GlyphExtents>, GlyphPlacementError> {
98 validate_glyph_placements(placements)?;
99 let Some(first) = placements.first() else {
100 return Ok(None);
101 };
102 let (first_left, first_right) = horizontal_bounds(first);
103 let (first_top, first_bottom) = vertical_bounds(first);
104 if [first_left, first_right, first_top, first_bottom]
105 .iter()
106 .any(|value| !value.is_finite())
107 {
108 return Err(GlyphPlacementError::NonFinite { index: 0 });
109 }
110 let mut extents = GlyphExtents {
111 left_mm: first_left,
112 top_mm: first_top,
113 right_mm: first_right,
114 bottom_mm: first_bottom,
115 };
116 for (index, placement) in placements.iter().enumerate().skip(1) {
117 let (left, right) = horizontal_bounds(placement);
118 let (top, bottom) = vertical_bounds(placement);
119 if [left, right, top, bottom]
120 .iter()
121 .any(|value| !value.is_finite())
122 {
123 return Err(GlyphPlacementError::NonFinite { index });
124 }
125 extents.left_mm = extents.left_mm.min(left);
126 extents.top_mm = extents.top_mm.min(top);
127 extents.right_mm = extents.right_mm.max(right);
128 extents.bottom_mm = extents.bottom_mm.max(bottom);
129 }
130 Ok(Some(extents))
131}
132
133pub fn distribute_glyph_spacing(
135 placements: &mut [GlyphPlacement],
136 extra_mm: f32,
137) -> Result<usize, GlyphPlacementError> {
138 validate_glyph_placements(placements)?;
139 if !extra_mm.is_finite() {
140 return Err(GlyphPlacementError::NonFiniteSpacing);
141 }
142 if extra_mm <= 0.0 || placements.len() < 2 {
143 return Ok(0);
144 }
145 let mut order: Vec<usize> = (0..placements.len()).collect();
146 order.sort_by(|&left, &right| {
147 placements[left]
148 .x_mm
149 .total_cmp(&placements[right].x_mm)
150 .then(left.cmp(&right))
151 });
152 let denominator = (order.len() - 1) as f32;
153 let mut shifts = Vec::with_capacity(order.len().saturating_sub(1));
154 for (rank, &index) in order.iter().enumerate().skip(1) {
155 let shift = extra_mm * rank as f32 / denominator;
156 if !shift.is_finite() || !(placements[index].x_mm + shift).is_finite() {
157 return Err(GlyphPlacementError::NonFiniteSpacing);
158 }
159 shifts.push((index, shift));
160 }
161 let mut moved = 0;
162 for (index, shift) in shifts {
163 placements[index].x_mm += shift;
164 if shift > f32::EPSILON {
165 moved += 1;
166 }
167 }
168 Ok(moved)
169}
170
171pub fn resolve_glyph_collisions(placements: &mut [GlyphPlacement], gap_mm: f32) -> usize {
177 let gap_mm = if gap_mm.is_finite() {
178 gap_mm.max(0.0)
179 } else {
180 0.0
181 };
182 let mut order: Vec<usize> = (0..placements.len()).collect();
183 order.sort_by_key(|&index| (std::cmp::Reverse(placements[index].priority), index));
184 let mut moved = 0;
185 for position in 0..order.len() {
186 let index = order[position];
187 let (left, right) = horizontal_bounds(&placements[index]);
188 let mut next_y = placements[index].y_mm;
189 for &previous in &order[..position] {
190 let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
191 if right <= previous_left || previous_right <= left {
192 continue;
193 }
194 let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
195 let current_top = next_y + placements[index].metrics.top_mm;
196 let current_bottom = current_top + placements[index].metrics.height_mm;
197 if current_bottom <= previous_top || previous_bottom <= current_top {
198 continue;
199 }
200 if current_top < previous_bottom + gap_mm {
201 next_y = previous_bottom + gap_mm - placements[index].metrics.top_mm;
202 }
203 }
204 if (next_y - placements[index].y_mm).abs() > f32::EPSILON {
205 placements[index].y_mm = next_y;
206 moved += 1;
207 }
208 }
209 moved
210}
211
212pub fn resolve_glyph_collisions_checked(
214 placements: &mut [GlyphPlacement],
215 gap_mm: f32,
216) -> Result<usize, GlyphPlacementError> {
217 validate_glyph_placements(placements)?;
218 if !gap_mm.is_finite() {
219 return Err(GlyphPlacementError::NonFiniteSpacing);
220 }
221 let mut candidate = placements.to_vec();
222 let moved = resolve_glyph_collisions(&mut candidate, gap_mm);
223 glyph_extents(&candidate)?;
224 placements.clone_from_slice(&candidate);
225 Ok(moved)
226}
227
228pub fn resolve_glyph_horizontal_collisions(
233 placements: &mut [GlyphPlacement],
234 gap_mm: f32,
235) -> usize {
236 let gap_mm = if gap_mm.is_finite() {
237 gap_mm.max(0.0)
238 } else {
239 0.0
240 };
241 let mut order: Vec<usize> = (0..placements.len()).collect();
242 order.sort_by_key(|&index| (std::cmp::Reverse(placements[index].priority), index));
243 let mut moved = 0;
244 for position in 0..order.len() {
245 let index = order[position];
246 let original_x = placements[index].x_mm;
247 let mut next_x = original_x;
248 for &previous in &order[..position] {
249 let current = GlyphPlacement {
250 x_mm: next_x,
251 ..placements[index].clone()
252 };
253 let (left, right) = horizontal_bounds(¤t);
254 let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
255 let (top, bottom) = vertical_bounds(¤t);
256 let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
257 if right <= previous_left
258 || previous_right <= left
259 || bottom <= previous_top
260 || previous_bottom <= top
261 {
262 continue;
263 }
264 next_x = previous_right + gap_mm - placements[index].metrics.left_mm;
265 }
266 if (next_x - original_x).abs() > f32::EPSILON {
267 placements[index].x_mm = next_x;
268 moved += 1;
269 }
270 }
271 moved
272}
273
274pub fn resolve_glyph_horizontal_collisions_checked(
276 placements: &mut [GlyphPlacement],
277 gap_mm: f32,
278) -> Result<usize, GlyphPlacementError> {
279 validate_glyph_placements(placements)?;
280 if !gap_mm.is_finite() {
281 return Err(GlyphPlacementError::NonFiniteSpacing);
282 }
283 let mut candidate = placements.to_vec();
284 let moved = resolve_glyph_horizontal_collisions(&mut candidate, gap_mm);
285 glyph_extents(&candidate)?;
286 placements.clone_from_slice(&candidate);
287 Ok(moved)
288}
289
290fn horizontal_bounds(placement: &GlyphPlacement) -> (f32, f32) {
291 (
292 placement.x_mm + placement.metrics.left_mm,
293 placement.x_mm + placement.metrics.left_mm + placement.metrics.width_mm,
294 )
295}
296
297fn vertical_bottom(placement: &GlyphPlacement) -> f32 {
298 placement.y_mm + placement.metrics.top_mm + placement.metrics.height_mm
299}
300
301fn vertical_bounds(placement: &GlyphPlacement) -> (f32, f32) {
302 (
303 placement.y_mm + placement.metrics.top_mm,
304 vertical_bottom(placement),
305 )
306}
307
308#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
310pub enum PaperSize {
311 A4,
312 Letter,
313 Legal,
314 Custom { width_mm: f32, height_mm: f32 },
315}
316
317impl PaperSize {
318 fn dimensions_mm(self) -> (f32, f32) {
319 match self {
320 Self::A4 => (210.0, 297.0),
321 Self::Letter => (215.9, 279.4),
322 Self::Legal => (215.9, 355.6),
323 Self::Custom {
324 width_mm,
325 height_mm,
326 } => (width_mm, height_mm),
327 }
328 }
329}
330
331#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
333pub enum PageOrientation {
334 Portrait,
335 Landscape,
336}
337
338#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
340pub enum PageNumbering {
341 None,
342 OneBased,
343}
344
345#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
347pub enum FinalPagePolicy {
348 #[default]
350 AllowSingleSystem,
351 Balance,
353}
354
355#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
357pub enum PickupPolicy {
358 #[default]
360 Auto,
361 Preserve,
363 DetectFirstMeasure,
365}
366
367#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
369pub enum NotationBreakPolicy {
370 #[default]
372 Preserve,
373 KeepVoltaTogether,
375 KeepRepeatsTogether,
377}
378
379#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
381pub enum PrintColorPolicy {
382 #[default]
383 Monochrome,
384 Preserve,
385}
386
387#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
389pub enum CropMarkPolicy {
390 #[default]
391 None,
392 BleedEdges,
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
397pub enum GlyphResourcePolicy {
398 #[default]
400 BuiltInVector,
401 HostProvided(String),
403}
404
405#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
407pub enum PartLayoutPolicy {
408 #[default]
410 FullScore,
411 ExtractedPart { part_index: usize },
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
417#[serde(default)]
418pub struct PublicationConfig {
419 pub title_page: bool,
421 pub running_title: Option<String>,
423 pub show_part_names: bool,
424 pub show_measure_numbers: bool,
425 pub header_text: Option<String>,
427 pub footer_text: Option<String>,
429 pub page_number_in_footer: bool,
431 pub header_alignment: PublicationTextAlignment,
432 pub footer_alignment: PublicationTextAlignment,
433 pub title_alignment: PublicationTextAlignment,
434 pub line_height_mm: f32,
436}
437
438impl Default for PublicationConfig {
439 fn default() -> Self {
440 Self {
441 title_page: false,
442 running_title: None,
443 show_part_names: true,
444 show_measure_numbers: true,
445 header_text: None,
446 footer_text: None,
447 page_number_in_footer: false,
448 header_alignment: PublicationTextAlignment::Left,
449 footer_alignment: PublicationTextAlignment::Left,
450 title_alignment: PublicationTextAlignment::Center,
451 line_height_mm: 4.0,
452 }
453 }
454}
455
456#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
458pub enum PublicationTextRole {
459 Header,
460 Footer,
461 Title,
462 Subtitle,
463 Credit,
464 Copyright,
465}
466
467#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
469pub enum PublicationTextAlignment {
470 #[default]
471 Left,
472 Center,
473 Right,
474}
475
476#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
478pub struct PublicationTextBlock {
479 pub role: PublicationTextRole,
480 pub text: String,
481 pub x_mm: f32,
482 pub y_mm: f32,
483 pub width_mm: f32,
484 pub height_mm: f32,
485 #[serde(default)]
486 pub alignment: PublicationTextAlignment,
487}
488
489#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
491pub struct PartLabel {
492 pub part_index: usize,
493 pub name: String,
494 pub short_name: String,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
499pub struct PartGroupMark {
500 pub first_part: usize,
501 pub last_part: usize,
502 pub symbol: PartGroupSymbol,
503 pub barlines_connect: bool,
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
508#[serde(default)]
509pub struct PagePublication {
510 #[serde(default)]
511 pub is_title_page: bool,
512 pub title: String,
513 pub movement_title: String,
514 pub composer: String,
515 pub lyricist: String,
516 pub copyright: String,
517 pub running_title: Option<String>,
518 pub part_labels: Vec<PartLabel>,
519 #[serde(default)]
520 pub part_groups: Vec<PartGroupMark>,
521 pub measure_numbers: Vec<u32>,
522 #[serde(default)]
523 pub text_blocks: Vec<PublicationTextBlock>,
524}
525
526#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
532pub struct KeepTogetherRange {
533 pub first_measure: usize,
534 pub last_measure: usize,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
542#[serde(default)]
543pub struct PrintConfig {
544 pub paper_size: PaperSize,
545 pub orientation: PageOrientation,
546 pub margin_top_mm: f32,
547 pub margin_right_mm: f32,
548 pub margin_bottom_mm: f32,
549 pub margin_left_mm: f32,
550 pub bleed_top_mm: f32,
551 pub bleed_right_mm: f32,
552 pub bleed_bottom_mm: f32,
553 pub bleed_left_mm: f32,
554 pub safe_top_mm: f32,
555 pub safe_right_mm: f32,
556 pub safe_bottom_mm: f32,
557 pub safe_left_mm: f32,
558 pub system_height_mm: f32,
559 pub scale: f32,
561 pub measures_per_system: usize,
562 #[serde(default)]
564 pub first_system_measures: Option<usize>,
565 #[serde(default)]
566 pub pickup_policy: PickupPolicy,
567 #[serde(default)]
568 pub notation_break_policy: NotationBreakPolicy,
569 pub systems_per_page: Option<usize>,
572 pub page_numbering: PageNumbering,
573 #[serde(default)]
574 pub final_page_policy: FinalPagePolicy,
575 #[serde(default)]
576 pub color_policy: PrintColorPolicy,
577 #[serde(default)]
578 pub crop_mark_policy: CropMarkPolicy,
579 #[serde(default)]
580 pub glyph_resources: GlyphResourcePolicy,
581 #[serde(default)]
582 pub publication: PublicationConfig,
583 #[serde(default)]
584 pub part_layout: PartLayoutPolicy,
585 #[serde(default)]
587 pub keep_together: Vec<KeepTogetherRange>,
588}
589
590impl Default for PrintConfig {
591 fn default() -> Self {
592 Self {
593 paper_size: PaperSize::A4,
594 orientation: PageOrientation::Portrait,
595 margin_top_mm: 16.0,
596 margin_right_mm: 14.0,
597 margin_bottom_mm: 16.0,
598 margin_left_mm: 14.0,
599 bleed_top_mm: 0.0,
600 bleed_right_mm: 0.0,
601 bleed_bottom_mm: 0.0,
602 bleed_left_mm: 0.0,
603 safe_top_mm: 0.0,
604 safe_right_mm: 0.0,
605 safe_bottom_mm: 0.0,
606 safe_left_mm: 0.0,
607 system_height_mm: 24.0,
608 scale: 1.0,
609 measures_per_system: 4,
610 first_system_measures: None,
611 pickup_policy: PickupPolicy::Auto,
612 notation_break_policy: NotationBreakPolicy::Preserve,
613 systems_per_page: None,
614 page_numbering: PageNumbering::OneBased,
615 final_page_policy: FinalPagePolicy::AllowSingleSystem,
616 color_policy: PrintColorPolicy::Monochrome,
617 crop_mark_policy: CropMarkPolicy::None,
618 glyph_resources: GlyphResourcePolicy::BuiltInVector,
619 publication: PublicationConfig::default(),
620 part_layout: PartLayoutPolicy::FullScore,
621 keep_together: Vec::new(),
622 }
623 }
624}
625
626pub const PRINT_PRESET_SCHEMA_VERSION: u16 = 1;
628pub const PRINT_LAYOUT_CONTRACT_VERSION: u16 = 24;
630
631#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
633pub enum PrintPreset {
634 A4Score,
635 LetterScore,
636 A4Part { part_index: usize },
637 LetterPart { part_index: usize },
638}
639
640impl PrintPreset {
641 pub fn config(self) -> PrintConfig {
643 let (paper_size, part_layout) = match self {
644 Self::A4Score => (PaperSize::A4, PartLayoutPolicy::FullScore),
645 Self::LetterScore => (PaperSize::Letter, PartLayoutPolicy::FullScore),
646 Self::A4Part { part_index } => (
647 PaperSize::A4,
648 PartLayoutPolicy::ExtractedPart { part_index },
649 ),
650 Self::LetterPart { part_index } => (
651 PaperSize::Letter,
652 PartLayoutPolicy::ExtractedPart { part_index },
653 ),
654 };
655 PrintConfig {
656 paper_size,
657 part_layout,
658 ..PrintConfig::default()
659 }
660 }
661
662 pub fn config_with_title_page(self, title_page: bool) -> PrintConfig {
664 let mut config = self.config();
665 config.publication.title_page = title_page;
666 config
667 }
668
669 pub const fn schema_version(self) -> u16 {
671 PRINT_PRESET_SCHEMA_VERSION
672 }
673}
674
675#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
677pub struct SystemLayout {
678 pub address: SystemAddress,
679 pub system_index: usize,
680 pub page_index: usize,
681 pub measure_indices: Vec<usize>,
682 #[serde(default)]
684 pub measure_spans: Vec<MeasureSpan>,
685 #[serde(default)]
687 pub span_segments: Vec<SpanSegment>,
688 #[serde(default)]
690 pub measure_marks: Vec<MeasureMark>,
691 pub top_mm: f32,
692 pub height_mm: f32,
693 pub break_reason: BreakReason,
694}
695
696#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
698pub struct PageAddress {
699 pub page_index: usize,
700}
701
702#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
704pub struct SystemAddress {
705 pub system_index: usize,
706 pub page_index: usize,
707 pub index_on_page: usize,
708}
709
710#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
712pub struct MeasureSpan {
713 pub first_measure: usize,
714 pub last_measure: usize,
715}
716
717#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
719pub struct SpanSegment {
720 pub span_index: usize,
721 pub starts_here: bool,
722 pub ends_here: bool,
723}
724
725#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
727pub struct PageSpanSegment {
728 pub span_index: usize,
729 pub starts_here: bool,
730 pub ends_here: bool,
731}
732
733#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
738pub struct MeasureMark {
739 pub measure_index: usize,
740 pub repeat_start: bool,
741 pub repeat_end: bool,
742 pub volta_number: Option<u8>,
743 pub volta_kind: Option<String>,
744 pub navigation: Option<String>,
745 pub rehearsal: Option<String>,
746}
747
748#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
750pub enum BreakReason {
751 MeasureCapacity,
752 ExplicitSystemBreak,
753 ExplicitPageBreak,
754 PageCapacity,
755 EndOfScore,
756 TitlePage,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
761pub struct PageLayout {
762 pub address: PageAddress,
763 pub page_index: usize,
764 pub page_number: Option<usize>,
765 #[serde(default)]
766 pub color_policy: PrintColorPolicy,
767 #[serde(default)]
768 pub crop_mark_policy: CropMarkPolicy,
769 #[serde(default)]
770 pub glyph_resources: GlyphResourcePolicy,
771 #[serde(default)]
772 pub publication: PagePublication,
773 pub width_mm: f32,
774 pub height_mm: f32,
775 pub content_width_mm: f32,
776 pub content_height_mm: f32,
777 pub bleed_top_mm: f32,
778 pub bleed_right_mm: f32,
779 pub bleed_bottom_mm: f32,
780 pub bleed_left_mm: f32,
781 pub systems: Vec<SystemLayout>,
782 #[serde(default)]
784 pub span_segments: Vec<PageSpanSegment>,
785 #[serde(default)]
787 pub measure_marks: Vec<MeasureMark>,
788 pub break_reason: BreakReason,
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
796pub struct PageArtifact {
797 pub address: PageAddress,
798 pub page_index: usize,
799 pub page_number: Option<usize>,
800 pub width_mm: f32,
801 pub height_mm: f32,
802 pub content_width_mm: f32,
803 pub content_height_mm: f32,
804 pub measure_span: Option<MeasureSpan>,
805 pub diagnostics: Vec<PageArtifactDiagnostic>,
806 pub layout: PageLayout,
807}
808
809#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
811pub enum PageArtifactDiagnostic {
812 SpanContinuation {
814 span_index: usize,
815 starts_here: bool,
816 ends_here: bool,
817 },
818}
819
820impl PageLayout {
821 pub fn measure_span(&self) -> Option<MeasureSpan> {
823 let mut spans = self
824 .systems
825 .iter()
826 .flat_map(|system| system.measure_spans.iter().copied());
827 let first = spans.next()?;
828 Some(spans.fold(first, |range, span| MeasureSpan {
829 first_measure: range.first_measure.min(span.first_measure),
830 last_measure: range.last_measure.max(span.last_measure),
831 }))
832 }
833
834 pub fn has_span_continuation(&self) -> bool {
836 self.span_segments
837 .iter()
838 .any(|segment| !segment.starts_here || !segment.ends_here)
839 }
840}
841
842#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
844pub struct PrintLayoutResult {
845 pub contract_version: u16,
846 pub pages: Vec<PageLayout>,
847}
848
849impl PrintLayoutResult {
850 pub fn validate(&self) -> Result<(), PrintLayoutError> {
855 if self.contract_version != PRINT_LAYOUT_CONTRACT_VERSION {
856 return Err(PrintLayoutError::UnsupportedContractVersion {
857 found: self.contract_version,
858 });
859 }
860 let mut expected_system_index = 0;
861 let mut previous_page_number = None;
862 let mut numbered_pages = None;
863 for (page_index, page) in self.pages.iter().enumerate() {
864 if page.page_index != page_index || page.address.page_index != page_index {
865 return Err(PrintLayoutError::InvalidPageAddress { page_index });
866 }
867 if !page.width_mm.is_finite()
868 || !page.height_mm.is_finite()
869 || page.width_mm <= 0.0
870 || page.height_mm <= 0.0
871 || !page.content_width_mm.is_finite()
872 || !page.content_height_mm.is_finite()
873 || page.content_width_mm <= 0.0
874 || page.content_height_mm <= 0.0
875 || page.content_width_mm > page.width_mm
876 || page.content_height_mm > page.height_mm
877 || !page.bleed_top_mm.is_finite()
878 || !page.bleed_right_mm.is_finite()
879 || !page.bleed_bottom_mm.is_finite()
880 || !page.bleed_left_mm.is_finite()
881 || page.bleed_top_mm < 0.0
882 || page.bleed_right_mm < 0.0
883 || page.bleed_bottom_mm < 0.0
884 || page.bleed_left_mm < 0.0
885 {
886 return Err(PrintLayoutError::InvalidPageGeometry { page_index });
887 }
888 let is_title_break = page.break_reason == BreakReason::TitlePage;
889 if is_title_break != page.publication.is_title_page
890 || (is_title_break && (page_index != 0 || !page.systems.is_empty()))
891 {
892 return Err(PrintLayoutError::InvalidTitlePage { page_index });
893 }
894 match page.page_number {
895 Some(page_number)
896 if page_number == 0
897 || numbered_pages == Some(false)
898 || page_index.checked_add(1) != Some(page_number)
899 || previous_page_number.is_some_and(|previous| page_number <= previous) =>
900 {
901 return Err(PrintLayoutError::InvalidPageNumber { page_index });
902 }
903 Some(page_number) => {
904 numbered_pages = Some(true);
905 previous_page_number = Some(page_number);
906 }
907 None if numbered_pages == Some(true) => {
908 return Err(PrintLayoutError::InvalidPageNumber { page_index });
909 }
910 None => numbered_pages = Some(false),
911 }
912 for (index_on_page, system) in page.systems.iter().enumerate() {
913 if system.page_index != page_index
914 || system.address.page_index != page_index
915 || system.address.index_on_page != index_on_page
916 || system.system_index != expected_system_index
917 || system.address.system_index != expected_system_index
918 {
919 return Err(PrintLayoutError::InvalidSystemAddress {
920 page_index,
921 index_on_page,
922 system_index: expected_system_index,
923 });
924 }
925 if !system.top_mm.is_finite()
926 || system.top_mm < 0.0
927 || !system.height_mm.is_finite()
928 || system.height_mm <= 0.0
929 {
930 return Err(PrintLayoutError::InvalidSystemGeometry {
931 page_index,
932 index_on_page,
933 });
934 }
935 expected_system_index += 1;
936 }
937 }
938 Ok(())
939 }
940
941 pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
943 self.pages
944 .get(address.page_index)
945 .filter(|page| page.address == address)
946 }
947
948 pub fn export_page_artifacts(&self) -> Result<Vec<PageArtifact>, PrintLayoutError> {
954 self.validate()?;
955 Ok(self
956 .pages
957 .iter()
958 .map(|page| PageArtifact {
959 address: page.address,
960 page_index: page.page_index,
961 page_number: page.page_number,
962 width_mm: page.width_mm,
963 height_mm: page.height_mm,
964 content_width_mm: page.content_width_mm,
965 content_height_mm: page.content_height_mm,
966 measure_span: page.measure_span(),
967 diagnostics: page
968 .span_segments
969 .iter()
970 .filter(|segment| !segment.starts_here || !segment.ends_here)
971 .map(|segment| PageArtifactDiagnostic::SpanContinuation {
972 span_index: segment.span_index,
973 starts_here: segment.starts_here,
974 ends_here: segment.ends_here,
975 })
976 .collect(),
977 layout: page.clone(),
978 })
979 .collect())
980 }
981}
982
983#[derive(Debug, thiserror::Error, PartialEq)]
984pub enum PrintLayoutError {
985 #[error("paper dimensions must be finite and greater than zero")]
986 InvalidPaperDimensions,
987 #[error("margins must be finite and non-negative")]
988 InvalidMargins,
989 #[error("system height must be finite and greater than zero")]
990 InvalidSystemHeight,
991 #[error("print scale must be finite and greater than zero")]
992 InvalidScale,
993 #[error("margins leave no usable page area")]
994 NoUsablePageArea,
995 #[error("keep-together range is outside the score or reversed")]
996 InvalidKeepTogetherRange,
997 #[error("keep-together range exceeds the measures-per-system capacity")]
998 KeepTogetherExceedsSystemCapacity,
999 #[error("keep-together range conflicts with an explicit system or page break")]
1000 KeepTogetherConflictsWithExplicitBreak,
1001 #[error("repeat section exceeds the systems-per-page capacity")]
1002 RepeatRangeExceedsPageCapacity,
1003 #[error("extracted part index is outside the score")]
1004 InvalidPartIndex,
1005 #[error("publication line height must be finite and greater than zero")]
1006 InvalidPublicationLineHeight,
1007 #[error("host-provided glyph resource key must not be empty")]
1008 InvalidGlyphResourceKey,
1009 #[error("unsupported print layout contract version {found}")]
1010 UnsupportedContractVersion { found: u16 },
1011 #[error("page {page_index} has an inconsistent stable address")]
1012 InvalidPageAddress { page_index: usize },
1013 #[error("page {page_index} has an invalid or non-monotonic page number")]
1014 InvalidPageNumber { page_index: usize },
1015 #[error("page {page_index} has inconsistent title-page metadata")]
1016 InvalidTitlePage { page_index: usize },
1017 #[error(
1018 "system {system_index} at page {page_index}, position {index_on_page} has an inconsistent stable address"
1019 )]
1020 InvalidSystemAddress {
1021 page_index: usize,
1022 index_on_page: usize,
1023 system_index: usize,
1024 },
1025 #[error("page {page_index} has invalid physical geometry")]
1026 InvalidPageGeometry { page_index: usize },
1027 #[error("system at page {page_index}, position {index_on_page} has invalid physical geometry")]
1028 InvalidSystemGeometry {
1029 page_index: usize,
1030 index_on_page: usize,
1031 },
1032}
1033
1034fn apply_keep_together(
1035 score: &Score,
1036 mut rows: Vec<crate::RowLayout>,
1037 ranges: &[KeepTogetherRange],
1038 capacity: usize,
1039) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
1040 let measure_count = score
1041 .parts
1042 .first()
1043 .and_then(|part| part.staves.first())
1044 .map(|staff| staff.measures.len())
1045 .unwrap_or(0);
1046 for range in ranges {
1047 let length = range
1048 .last_measure
1049 .checked_sub(range.first_measure)
1050 .and_then(|length| length.checked_add(1));
1051 if range.first_measure > range.last_measure || range.last_measure >= measure_count {
1052 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1053 }
1054 if length.is_none_or(|length| length > capacity) {
1055 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1056 }
1057 for measure_index in range.first_measure..range.last_measure {
1058 let has_break = score
1059 .parts
1060 .iter()
1061 .flat_map(|part| part.staves.iter())
1062 .filter_map(|staff| staff.measures.get(measure_index))
1063 .any(|measure| measure.system_break || measure.page_break);
1064 if has_break {
1065 return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
1066 }
1067 }
1068
1069 let mut split_rows = Vec::with_capacity(rows.len() + 2);
1073 for row in rows {
1074 let mut cuts = vec![0, row.measure_indices.len()];
1075 if let Some(position) = row
1076 .measure_indices
1077 .iter()
1078 .position(|&index| index == range.first_measure)
1079 {
1080 cuts.push(position);
1081 }
1082 if let Some(position) = row
1083 .measure_indices
1084 .iter()
1085 .position(|&index| index == range.last_measure)
1086 {
1087 cuts.push(position + 1);
1088 }
1089 cuts.sort_unstable();
1090 cuts.dedup();
1091 for window in cuts.windows(2) {
1092 if window[0] < window[1] {
1093 split_rows.push(crate::RowLayout {
1094 measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
1095 });
1096 }
1097 }
1098 }
1099 rows = split_rows;
1100
1101 let first_row = rows
1102 .iter()
1103 .position(|row| row.measure_indices.contains(&range.first_measure));
1104 let last_row = rows
1105 .iter()
1106 .position(|row| row.measure_indices.contains(&range.last_measure));
1107 let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
1108 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1109 };
1110
1111 if first_row != last_row {
1112 let merged: Vec<usize> = rows[first_row..=last_row]
1113 .iter()
1114 .flat_map(|row| row.measure_indices.iter().copied())
1115 .collect();
1116 if merged.len() > capacity {
1117 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1118 }
1119 rows.splice(
1120 first_row..=last_row,
1121 [crate::RowLayout {
1122 measure_indices: merged,
1123 }],
1124 );
1125 }
1126
1127 let row_index = rows
1128 .iter()
1129 .position(|row| row.measure_indices.contains(&range.first_measure))
1130 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1131 let row = rows.remove(row_index);
1132 let start = row
1133 .measure_indices
1134 .iter()
1135 .position(|&index| index == range.first_measure)
1136 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1137 let end = row
1138 .measure_indices
1139 .iter()
1140 .position(|&index| index == range.last_measure)
1141 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1142 let mut replacement = Vec::new();
1143 if start > 0 {
1144 replacement.push(crate::RowLayout {
1145 measure_indices: row.measure_indices[..start].to_vec(),
1146 });
1147 }
1148 replacement.push(crate::RowLayout {
1149 measure_indices: row.measure_indices[start..=end].to_vec(),
1150 });
1151 if end + 1 < row.measure_indices.len() {
1152 replacement.push(crate::RowLayout {
1153 measure_indices: row.measure_indices[end + 1..].to_vec(),
1154 });
1155 }
1156 rows.splice(row_index..row_index, replacement);
1157 }
1158 Ok(rows)
1159}
1160
1161fn has_first_measure_pickup(score: &Score) -> bool {
1162 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1163 return false;
1164 };
1165 let Some(measure) = staff.measures.first() else {
1166 return false;
1167 };
1168 let expected = measure
1169 .time_sig
1170 .as_ref()
1171 .unwrap_or(&score.settings.time_signature)
1172 .total_beats();
1173 let actual = measure
1174 .voices
1175 .iter()
1176 .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
1177 .fold(0.0, f64::max);
1178 actual > 1e-9 && actual + 1e-9 < expected
1179}
1180
1181fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
1182 let measure_count = score
1183 .parts
1184 .first()
1185 .and_then(|part| part.staves.first())
1186 .map(|staff| staff.measures.len())
1187 .unwrap_or(0);
1188 measure_indices
1189 .iter()
1190 .filter_map(|&first_measure| {
1191 if first_measure >= measure_count {
1192 return None;
1193 }
1194 let count = score
1195 .parts
1196 .iter()
1197 .flat_map(|part| part.staves.iter())
1198 .filter_map(|staff| staff.measures.get(first_measure))
1199 .filter_map(|measure| measure.multi_rest_count)
1200 .map(usize::from)
1201 .max()
1202 .unwrap_or(1)
1203 .max(1);
1204 Some(MeasureSpan {
1205 first_measure,
1206 last_measure: first_measure
1207 .saturating_add(count.saturating_sub(1))
1208 .min(measure_count.saturating_sub(1)),
1209 })
1210 })
1211 .collect()
1212}
1213
1214fn span_bounds(span: &SpanMark) -> (usize, usize) {
1215 match span {
1216 SpanMark::Hairpin { start, end, .. }
1217 | SpanMark::Ottava { start, end, .. }
1218 | SpanMark::Pedal { start, end }
1219 | SpanMark::Slur { start, end }
1220 | SpanMark::TrillLine { start, end }
1221 | SpanMark::Glissando { start, end } => (
1222 start.measure.min(end.measure),
1223 start.measure.max(end.measure),
1224 ),
1225 }
1226}
1227
1228fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
1229 let (Some(&first_measure), Some(&last_measure)) =
1230 (measure_indices.first(), measure_indices.last())
1231 else {
1232 return Vec::new();
1233 };
1234 spans
1235 .iter()
1236 .enumerate()
1237 .filter_map(|(span_index, span)| {
1238 let (start_measure, end_measure) = span_bounds(span);
1239 (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
1240 span_index,
1241 starts_here: (first_measure..=last_measure).contains(&start_measure),
1242 ends_here: (first_measure..=last_measure).contains(&end_measure),
1243 })
1244 })
1245 .collect()
1246}
1247
1248fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
1249 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1250 return Vec::new();
1251 };
1252 measure_indices
1253 .iter()
1254 .filter_map(|&measure_index| {
1255 let measure = staff.measures.get(measure_index)?;
1256 let repeat_start = matches!(
1257 measure.barline_left,
1258 Barline::RepeatStart | Barline::RepeatBoth
1259 );
1260 let repeat_end = matches!(
1261 measure.barline_right,
1262 Barline::RepeatEnd | Barline::RepeatBoth
1263 );
1264 let has_mark = repeat_start
1265 || repeat_end
1266 || measure.volta.is_some()
1267 || measure.navigation.is_some()
1268 || measure.rehearsal.is_some();
1269 has_mark.then(|| MeasureMark {
1270 measure_index,
1271 repeat_start,
1272 repeat_end,
1273 volta_number: measure.volta.as_ref().map(|volta| volta.number),
1274 volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
1275 navigation: measure.navigation.clone(),
1276 rehearsal: measure.rehearsal.clone(),
1277 })
1278 })
1279 .collect()
1280}
1281
1282fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1283 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1284 return Vec::new();
1285 };
1286 let mut ranges = Vec::new();
1287 let mut start = None;
1288 for (index, measure) in staff.measures.iter().enumerate() {
1289 let Some(volta) = measure.volta.as_ref() else {
1290 continue;
1291 };
1292 if matches!(volta.kind.as_str(), "begin" | "begin_end") {
1293 start = Some(index);
1294 }
1295 if matches!(volta.kind.as_str(), "end" | "begin_end")
1296 && let Some(first_measure) = start.take()
1297 {
1298 ranges.push(KeepTogetherRange {
1299 first_measure,
1300 last_measure: index,
1301 });
1302 }
1303 }
1304 ranges
1305}
1306
1307fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1308 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1309 return Vec::new();
1310 };
1311 let mut ranges = Vec::new();
1312 let mut start = None;
1313 for (index, measure) in staff.measures.iter().enumerate() {
1314 if matches!(
1315 measure.barline_left,
1316 Barline::RepeatStart | Barline::RepeatBoth
1317 ) {
1318 start = Some(index);
1319 }
1320 if matches!(
1321 measure.barline_right,
1322 Barline::RepeatEnd | Barline::RepeatBoth
1323 ) {
1324 ranges.push(KeepTogetherRange {
1325 first_measure: start.take().unwrap_or(0),
1326 last_measure: index,
1327 });
1328 }
1329 }
1330 ranges
1331}
1332
1333fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
1334 repeat_ranges(score)
1335 .into_iter()
1336 .filter_map(|range| {
1337 let first = rows
1338 .iter()
1339 .position(|row| row.measure_indices.contains(&range.first_measure))?;
1340 let last = rows
1341 .iter()
1342 .position(|row| row.measure_indices.contains(&range.last_measure))?;
1343 Some((first, last))
1344 })
1345 .collect()
1346}
1347
1348fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
1349 let mut segments = Vec::new();
1350 for system in systems {
1351 for segment in &system.span_segments {
1352 if let Some(existing) = segments
1353 .iter_mut()
1354 .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
1355 {
1356 existing.ends_here |= segment.ends_here;
1357 } else {
1358 segments.push(PageSpanSegment {
1359 span_index: segment.span_index,
1360 starts_here: segment.starts_here,
1361 ends_here: segment.ends_here,
1362 });
1363 }
1364 }
1365 }
1366 segments
1367}
1368
1369fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
1370 systems
1371 .iter()
1372 .flat_map(|system| system.measure_marks.iter().cloned())
1373 .collect()
1374}
1375
1376fn page_publication(
1377 score: &Score,
1378 measure_score: &Score,
1379 config: &PrintConfig,
1380 systems: &[SystemLayout],
1381 is_title_page: bool,
1382 page_number: Option<usize>,
1383) -> PagePublication {
1384 let metadata = &score.metadata;
1385 let part_labels = if config.publication.show_part_names {
1386 let parts = match config.part_layout {
1387 PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
1388 PartLayoutPolicy::ExtractedPart { part_index } => score
1389 .parts
1390 .get(part_index)
1391 .into_iter()
1392 .enumerate()
1393 .map(|(index, part)| (part_index + index, part))
1394 .collect(),
1395 };
1396 parts
1397 .into_iter()
1398 .map(|(part_index, part)| PartLabel {
1399 part_index,
1400 name: part.name.clone(),
1401 short_name: part.short_name.clone(),
1402 })
1403 .collect()
1404 } else {
1405 Vec::new()
1406 };
1407 let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
1408 score
1409 .part_groups
1410 .iter()
1411 .map(|group| PartGroupMark {
1412 first_part: group.first_part,
1413 last_part: group.last_part,
1414 symbol: group.symbol.clone(),
1415 barlines_connect: group.barlines_connect,
1416 })
1417 .collect()
1418 } else {
1419 Vec::new()
1420 };
1421 let measure_numbers = if config.publication.show_measure_numbers {
1422 let staff = measure_score
1423 .parts
1424 .first()
1425 .and_then(|part| part.staves.first());
1426 systems
1427 .iter()
1428 .flat_map(|system| system.measure_indices.iter().copied())
1429 .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
1430 .map(|measure| measure.number)
1431 .collect()
1432 } else {
1433 Vec::new()
1434 };
1435 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1436 let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
1437 (paper_height, paper_width)
1438 } else {
1439 (paper_width, paper_height)
1440 };
1441 let mut text_blocks = Vec::new();
1442 if !is_title_page
1443 && let Some(text) = config
1444 .publication
1445 .header_text
1446 .as_ref()
1447 .or(config.publication.running_title.as_ref())
1448 {
1449 text_blocks.push(PublicationTextBlock {
1450 role: PublicationTextRole::Header,
1451 text: text.clone(),
1452 x_mm: config.margin_left_mm + config.safe_left_mm,
1453 y_mm: config.margin_top_mm,
1454 width_mm: page_width
1455 - config.margin_left_mm
1456 - config.margin_right_mm
1457 - config.safe_left_mm
1458 - config.safe_right_mm,
1459 height_mm: config.publication.line_height_mm,
1460 alignment: config.publication.header_alignment,
1461 });
1462 }
1463 if let Some(text) = config.publication.footer_text.as_ref() {
1464 text_blocks.push(PublicationTextBlock {
1465 role: PublicationTextRole::Footer,
1466 text: text.clone(),
1467 x_mm: config.margin_left_mm + config.safe_left_mm,
1468 y_mm: page_height - config.margin_bottom_mm,
1469 width_mm: page_width
1470 - config.margin_left_mm
1471 - config.margin_right_mm
1472 - config.safe_left_mm
1473 - config.safe_right_mm,
1474 height_mm: config.publication.line_height_mm,
1475 alignment: config.publication.footer_alignment,
1476 });
1477 }
1478 if config.publication.page_number_in_footer {
1479 if let Some(page_number) = page_number {
1480 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1481 let (page_width, page_height) =
1482 if matches!(config.orientation, PageOrientation::Landscape) {
1483 (paper_height, paper_width)
1484 } else {
1485 (paper_width, paper_height)
1486 };
1487 text_blocks.push(PublicationTextBlock {
1488 role: PublicationTextRole::Footer,
1489 text: page_number.to_string(),
1490 x_mm: config.margin_left_mm + config.safe_left_mm,
1491 y_mm: page_height - config.margin_bottom_mm,
1492 width_mm: page_width
1493 - config.margin_left_mm
1494 - config.margin_right_mm
1495 - config.safe_left_mm
1496 - config.safe_right_mm,
1497 height_mm: config.publication.line_height_mm,
1498 alignment: config.publication.footer_alignment,
1499 });
1500 }
1501 }
1502 if is_title_page {
1503 let content_height = page_height
1504 - config.margin_top_mm
1505 - config.margin_bottom_mm
1506 - config.safe_top_mm
1507 - config.safe_bottom_mm;
1508 let title_x = config.margin_left_mm + config.safe_left_mm;
1509 let title_width = page_width
1510 - config.margin_left_mm
1511 - config.margin_right_mm
1512 - config.safe_left_mm
1513 - config.safe_right_mm;
1514 let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
1515 if !metadata.title.trim().is_empty() {
1516 text_blocks.push(PublicationTextBlock {
1517 role: PublicationTextRole::Title,
1518 text: metadata.title.clone(),
1519 x_mm: title_x,
1520 y_mm: title_y,
1521 width_mm: title_width,
1522 height_mm: config.publication.line_height_mm,
1523 alignment: config.publication.title_alignment,
1524 });
1525 }
1526 if !metadata.movement_title.trim().is_empty() {
1527 text_blocks.push(PublicationTextBlock {
1528 role: PublicationTextRole::Subtitle,
1529 text: metadata.movement_title.clone(),
1530 x_mm: title_x,
1531 y_mm: title_y + config.publication.line_height_mm * 2.5,
1532 width_mm: title_width,
1533 height_mm: config.publication.line_height_mm,
1534 alignment: config.publication.title_alignment,
1535 });
1536 }
1537 let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
1538 (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
1539 format!("{composer} / {lyricist}")
1540 }
1541 (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
1542 (_, lyricist) => lyricist.to_string(),
1543 };
1544 if !credit.is_empty() {
1545 text_blocks.push(PublicationTextBlock {
1546 role: PublicationTextRole::Credit,
1547 text: credit,
1548 x_mm: title_x,
1549 y_mm: title_y + config.publication.line_height_mm * 5.0,
1550 width_mm: title_width,
1551 height_mm: config.publication.line_height_mm,
1552 alignment: config.publication.title_alignment,
1553 });
1554 }
1555 if !metadata.copyright.trim().is_empty() {
1556 text_blocks.push(PublicationTextBlock {
1557 role: PublicationTextRole::Copyright,
1558 text: metadata.copyright.clone(),
1559 x_mm: title_x,
1560 y_mm: page_height - config.margin_bottom_mm,
1561 width_mm: title_width,
1562 height_mm: config.publication.line_height_mm,
1563 alignment: config.publication.title_alignment,
1564 });
1565 }
1566 }
1567 PagePublication {
1568 is_title_page,
1569 title: metadata.title.clone(),
1570 movement_title: metadata.movement_title.clone(),
1571 composer: metadata.composer.clone(),
1572 lyricist: metadata.lyricist.clone(),
1573 copyright: metadata.copyright.clone(),
1574 running_title: config.publication.running_title.clone(),
1575 part_labels,
1576 part_groups,
1577 measure_numbers,
1578 text_blocks,
1579 }
1580}
1581
1582fn score_for_part_layout(
1583 score: &Score,
1584 policy: PartLayoutPolicy,
1585) -> Result<Score, PrintLayoutError> {
1586 let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
1587 return Ok(score.clone());
1588 };
1589 let Some(part) = score.parts.get(part_index) else {
1590 return Err(PrintLayoutError::InvalidPartIndex);
1591 };
1592 let mut selected = score.clone();
1593 selected.parts = vec![part.clone()];
1594 selected.part_groups.clear();
1595 Ok(selected)
1596}
1597
1598pub fn compute_print_layout(
1600 score: &Score,
1601 config: &PrintConfig,
1602) -> Result<PrintLayoutResult, PrintLayoutError> {
1603 let layout_score = score_for_part_layout(score, config.part_layout)?;
1604 let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
1605 if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
1606 return Err(PrintLayoutError::InvalidPaperDimensions);
1607 }
1608 if matches!(config.orientation, PageOrientation::Landscape) {
1609 std::mem::swap(&mut width_mm, &mut height_mm);
1610 }
1611
1612 let margins = [
1613 config.margin_top_mm,
1614 config.margin_right_mm,
1615 config.margin_bottom_mm,
1616 config.margin_left_mm,
1617 config.bleed_top_mm,
1618 config.bleed_right_mm,
1619 config.bleed_bottom_mm,
1620 config.bleed_left_mm,
1621 config.safe_top_mm,
1622 config.safe_right_mm,
1623 config.safe_bottom_mm,
1624 config.safe_left_mm,
1625 ];
1626 if margins
1627 .iter()
1628 .any(|value| !value.is_finite() || *value < 0.0)
1629 {
1630 return Err(PrintLayoutError::InvalidMargins);
1631 }
1632 if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
1633 return Err(PrintLayoutError::InvalidSystemHeight);
1634 }
1635 if !config.scale.is_finite() || config.scale <= 0.0 {
1636 return Err(PrintLayoutError::InvalidScale);
1637 }
1638 let scaled_system_height_mm = config.system_height_mm * config.scale;
1639 if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
1640 return Err(PrintLayoutError::InvalidScale);
1641 }
1642 if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
1643 return Err(PrintLayoutError::InvalidPublicationLineHeight);
1644 }
1645 if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
1646 {
1647 return Err(PrintLayoutError::InvalidGlyphResourceKey);
1648 }
1649 if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
1650 return Err(PrintLayoutError::InvalidPublicationLineHeight);
1651 }
1652
1653 let content_width_mm = width_mm
1654 - config.margin_left_mm
1655 - config.margin_right_mm
1656 - config.safe_left_mm
1657 - config.safe_right_mm;
1658 let content_height_mm = height_mm
1659 - config.margin_top_mm
1660 - config.margin_bottom_mm
1661 - config.safe_top_mm
1662 - config.safe_bottom_mm;
1663 if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
1664 return Err(PrintLayoutError::NoUsablePageArea);
1665 }
1666
1667 let systems_per_page = config
1668 .systems_per_page
1669 .unwrap_or_else(|| {
1670 (content_height_mm / scaled_system_height_mm)
1671 .floor()
1672 .max(1.0) as usize
1673 })
1674 .max(1);
1675 let layout = compute_layout(
1676 &layout_score,
1677 &LayoutConfig {
1678 measures_per_row: config.measures_per_system.max(1),
1679 first_row_measures: config.first_system_measures.or_else(|| {
1680 (matches!(
1681 config.pickup_policy,
1682 PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
1683 ) && has_first_measure_pickup(&layout_score))
1684 .then_some(1)
1685 }),
1686 ..LayoutConfig::default()
1687 },
1688 );
1689
1690 let mut keep_together = config.keep_together.clone();
1691 if matches!(
1692 config.notation_break_policy,
1693 NotationBreakPolicy::KeepVoltaTogether
1694 ) {
1695 keep_together.extend(volta_ranges(&layout_score));
1696 }
1697 let rows = apply_keep_together(
1698 &layout_score,
1699 layout.rows,
1700 &keep_together,
1701 config.measures_per_system.max(1),
1702 )?;
1703
1704 let has_explicit_page_break = rows.iter().any(|row| {
1705 row.measure_indices.last().is_some_and(|&measure_index| {
1706 layout_score
1707 .parts
1708 .iter()
1709 .flat_map(|part| part.staves.iter())
1710 .filter_map(|staff| staff.measures.get(measure_index))
1711 .any(|measure| measure.page_break)
1712 })
1713 });
1714 let repeat_system_ranges = if matches!(
1715 config.notation_break_policy,
1716 NotationBreakPolicy::KeepRepeatsTogether
1717 ) {
1718 repeat_system_ranges(&layout_score, &rows)
1719 } else {
1720 Vec::new()
1721 };
1722 if repeat_system_ranges
1723 .iter()
1724 .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
1725 {
1726 return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
1727 }
1728 let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
1729 && !has_explicit_page_break
1730 && systems_per_page > 1
1731 && rows.len() > systems_per_page
1732 && repeat_system_ranges.is_empty()
1733 {
1734 let page_count = rows.len().div_ceil(systems_per_page);
1735 let base = rows.len() / page_count;
1736 let remainder = rows.len() % page_count;
1737 (0..page_count)
1738 .map(|index| base + usize::from(index < remainder))
1739 .collect::<Vec<_>>()
1740 } else {
1741 Vec::new()
1742 };
1743
1744 let mut pages = Vec::new();
1745 let mut page_systems = Vec::new();
1746 let mut page_index = 0;
1747 for (system_index, row) in rows.iter().enumerate() {
1748 let repeat_starts_here = repeat_system_ranges
1749 .iter()
1750 .any(|(first, _)| *first == system_index);
1751 if repeat_starts_here && !page_systems.is_empty() {
1752 pages.push(PageLayout {
1753 address: PageAddress { page_index },
1754 page_index,
1755 page_number: match config.page_numbering {
1756 PageNumbering::None => None,
1757 PageNumbering::OneBased => Some(page_index + 1),
1758 },
1759 color_policy: config.color_policy,
1760 crop_mark_policy: config.crop_mark_policy,
1761 glyph_resources: config.glyph_resources.clone(),
1762 publication: page_publication(
1763 score,
1764 &layout_score,
1765 config,
1766 &page_systems,
1767 false,
1768 match config.page_numbering {
1769 PageNumbering::None => None,
1770 PageNumbering::OneBased => Some(page_index + 1),
1771 },
1772 ),
1773 width_mm,
1774 height_mm,
1775 content_width_mm,
1776 content_height_mm,
1777 bleed_top_mm: config.bleed_top_mm,
1778 bleed_right_mm: config.bleed_right_mm,
1779 bleed_bottom_mm: config.bleed_bottom_mm,
1780 bleed_left_mm: config.bleed_left_mm,
1781 span_segments: page_span_segments(&page_systems),
1782 measure_marks: page_measure_marks(&page_systems),
1783 systems: std::mem::take(&mut page_systems),
1784 break_reason: BreakReason::PageCapacity,
1785 });
1786 page_index += 1;
1787 }
1788 let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
1789 layout_score
1790 .parts
1791 .iter()
1792 .flat_map(|part| part.staves.iter())
1793 .filter_map(|staff| staff.measures.get(measure_index))
1794 .any(|measure| measure.page_break)
1795 });
1796 let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
1797 layout_score
1798 .parts
1799 .iter()
1800 .flat_map(|part| part.staves.iter())
1801 .filter_map(|staff| staff.measures.get(measure_index))
1802 .any(|measure| measure.system_break)
1803 });
1804 let is_last_system = system_index + 1 == rows.len();
1805 let break_reason = if explicit_page_break {
1806 BreakReason::ExplicitPageBreak
1807 } else if explicit_system_break {
1808 BreakReason::ExplicitSystemBreak
1809 } else if is_last_system {
1810 BreakReason::EndOfScore
1811 } else {
1812 BreakReason::MeasureCapacity
1813 };
1814 let system = SystemLayout {
1815 address: SystemAddress {
1816 system_index,
1817 page_index,
1818 index_on_page: page_systems.len(),
1819 },
1820 system_index,
1821 page_index,
1822 measure_indices: row.measure_indices.clone(),
1823 measure_spans: measure_spans(&layout_score, &row.measure_indices),
1824 span_segments: span_segments(&layout.spans, &row.measure_indices),
1825 measure_marks: measure_marks(&layout_score, &row.measure_indices),
1826 top_mm: config.margin_top_mm
1827 + config.safe_top_mm
1828 + page_systems.len() as f32 * scaled_system_height_mm,
1829 height_mm: scaled_system_height_mm,
1830 break_reason,
1831 };
1832 page_systems.push(system);
1833
1834 let page_capacity = page_capacities
1835 .get(page_index)
1836 .copied()
1837 .unwrap_or(systems_per_page);
1838 let page_is_full = page_systems.len() >= page_capacity;
1839 if page_is_full || explicit_page_break {
1840 let page_break_reason = if explicit_page_break {
1841 BreakReason::ExplicitPageBreak
1842 } else if is_last_system {
1843 BreakReason::EndOfScore
1844 } else {
1845 BreakReason::PageCapacity
1846 };
1847 pages.push(PageLayout {
1848 address: PageAddress { page_index },
1849 page_index,
1850 page_number: match config.page_numbering {
1851 PageNumbering::None => None,
1852 PageNumbering::OneBased => Some(page_index + 1),
1853 },
1854 color_policy: config.color_policy,
1855 crop_mark_policy: config.crop_mark_policy,
1856 glyph_resources: config.glyph_resources.clone(),
1857 publication: page_publication(
1858 score,
1859 &layout_score,
1860 config,
1861 &page_systems,
1862 false,
1863 match config.page_numbering {
1864 PageNumbering::None => None,
1865 PageNumbering::OneBased => Some(page_index + 1),
1866 },
1867 ),
1868 width_mm,
1869 height_mm,
1870 content_width_mm,
1871 content_height_mm,
1872 bleed_top_mm: config.bleed_top_mm,
1873 bleed_right_mm: config.bleed_right_mm,
1874 bleed_bottom_mm: config.bleed_bottom_mm,
1875 bleed_left_mm: config.bleed_left_mm,
1876 span_segments: page_span_segments(&page_systems),
1877 measure_marks: page_measure_marks(&page_systems),
1878 systems: std::mem::take(&mut page_systems),
1879 break_reason: page_break_reason,
1880 });
1881 page_index += 1;
1882 }
1883 }
1884 if !page_systems.is_empty() || pages.is_empty() {
1885 pages.push(PageLayout {
1886 address: PageAddress { page_index },
1887 page_index,
1888 page_number: match config.page_numbering {
1889 PageNumbering::None => None,
1890 PageNumbering::OneBased => Some(page_index + 1),
1891 },
1892 color_policy: config.color_policy,
1893 crop_mark_policy: config.crop_mark_policy,
1894 glyph_resources: config.glyph_resources.clone(),
1895 publication: page_publication(
1896 score,
1897 &layout_score,
1898 config,
1899 &page_systems,
1900 false,
1901 match config.page_numbering {
1902 PageNumbering::None => None,
1903 PageNumbering::OneBased => Some(page_index + 1),
1904 },
1905 ),
1906 width_mm,
1907 height_mm,
1908 content_width_mm,
1909 content_height_mm,
1910 bleed_top_mm: config.bleed_top_mm,
1911 bleed_right_mm: config.bleed_right_mm,
1912 bleed_bottom_mm: config.bleed_bottom_mm,
1913 bleed_left_mm: config.bleed_left_mm,
1914 span_segments: page_span_segments(&page_systems),
1915 measure_marks: page_measure_marks(&page_systems),
1916 systems: page_systems,
1917 break_reason: BreakReason::EndOfScore,
1918 });
1919 }
1920
1921 if config.publication.title_page {
1922 for page in &mut pages {
1923 page.page_index += 1;
1924 page.address.page_index = page.page_index;
1925 page.page_number = match config.page_numbering {
1926 PageNumbering::None => None,
1927 PageNumbering::OneBased => Some(page.page_index + 1),
1928 };
1929 for system in &mut page.systems {
1930 system.page_index += 1;
1931 system.address.page_index = system.page_index;
1932 }
1933 page.publication = page_publication(
1934 score,
1935 &layout_score,
1936 config,
1937 &page.systems,
1938 false,
1939 page.page_number,
1940 );
1941 }
1942 pages.insert(
1943 0,
1944 PageLayout {
1945 address: PageAddress { page_index: 0 },
1946 page_index: 0,
1947 page_number: match config.page_numbering {
1948 PageNumbering::None => None,
1949 PageNumbering::OneBased => Some(1),
1950 },
1951 color_policy: config.color_policy,
1952 crop_mark_policy: config.crop_mark_policy,
1953 glyph_resources: config.glyph_resources.clone(),
1954 publication: page_publication(
1955 score,
1956 &layout_score,
1957 config,
1958 &[],
1959 true,
1960 match config.page_numbering {
1961 PageNumbering::None => None,
1962 PageNumbering::OneBased => Some(1),
1963 },
1964 ),
1965 width_mm,
1966 height_mm,
1967 content_width_mm,
1968 content_height_mm,
1969 bleed_top_mm: config.bleed_top_mm,
1970 bleed_right_mm: config.bleed_right_mm,
1971 bleed_bottom_mm: config.bleed_bottom_mm,
1972 bleed_left_mm: config.bleed_left_mm,
1973 span_segments: Vec::new(),
1974 measure_marks: Vec::new(),
1975 systems: Vec::new(),
1976 break_reason: BreakReason::TitlePage,
1977 },
1978 );
1979 }
1980
1981 Ok(PrintLayoutResult {
1982 contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
1983 pages,
1984 })
1985}
1986
1987#[cfg(test)]
1988mod tests {
1989 use super::*;
1990 use acorde_core::{
1991 Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score, Staff, Step,
1992 };
1993
1994 fn score_with_measures(count: usize) -> Score {
1995 let mut score = Score::default();
1996 let mut part = Part::new("Piano", "Pno.");
1997 let mut staff = Staff::new(Clef::Treble);
1998 staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
1999 part.staves = vec![staff];
2000 score.parts = vec![part];
2001 score
2002 }
2003
2004 #[test]
2005 fn publication_metadata_accepts_legacy_partial_json() {
2006 let publication: PagePublication =
2007 serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
2008 .expect("legacy publication metadata should deserialize");
2009
2010 assert!(publication.is_title_page);
2011 assert_eq!(publication.title, "Legacy score");
2012 assert!(publication.movement_title.is_empty());
2013 assert!(publication.part_labels.is_empty());
2014 assert!(publication.part_groups.is_empty());
2015 assert!(publication.text_blocks.is_empty());
2016 }
2017
2018 #[test]
2019 fn layout_validation_rejects_unsupported_contract_version() {
2020 let score = score_with_measures(1);
2021 let mut result =
2022 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2023 result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
2024
2025 assert_eq!(
2026 result.validate(),
2027 Err(PrintLayoutError::UnsupportedContractVersion {
2028 found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
2029 })
2030 );
2031 }
2032
2033 #[test]
2034 fn paginates_rows_and_preserves_measure_indices() {
2035 let score = score_with_measures(5);
2036 let result = compute_print_layout(
2037 &score,
2038 &PrintConfig {
2039 measures_per_system: 2,
2040 systems_per_page: Some(2),
2041 ..PrintConfig::default()
2042 },
2043 )
2044 .expect("valid print config");
2045 assert_eq!(result.pages.len(), 2);
2046 assert_eq!(
2047 result.pages[0]
2048 .systems
2049 .iter()
2050 .map(|s| s.measure_indices.clone())
2051 .collect::<Vec<_>>(),
2052 vec![vec![0, 1], vec![2, 3]]
2053 );
2054 assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
2055 assert_eq!(result.pages[1].systems[0].page_index, 1);
2056 assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
2057 assert_eq!(
2058 result.pages[1].systems[0].break_reason,
2059 BreakReason::EndOfScore
2060 );
2061 assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
2062 }
2063
2064 #[test]
2065 fn forced_page_break_starts_next_system_on_next_page() {
2066 let mut score = score_with_measures(3);
2067 score.parts[0].staves[0].measures[0].page_break = true;
2068 let result = compute_print_layout(
2069 &score,
2070 &PrintConfig {
2071 measures_per_system: 3,
2072 systems_per_page: Some(8),
2073 ..PrintConfig::default()
2074 },
2075 )
2076 .expect("valid print config");
2077 assert_eq!(result.pages.len(), 2);
2078 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2079 assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
2080 assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
2081 assert_eq!(
2082 result.pages[0].systems[0].break_reason,
2083 BreakReason::ExplicitPageBreak
2084 );
2085 }
2086
2087 #[test]
2088 fn keep_together_range_is_not_split_across_systems() {
2089 let score = score_with_measures(5);
2090 let result = compute_print_layout(
2091 &score,
2092 &PrintConfig {
2093 measures_per_system: 3,
2094 systems_per_page: Some(8),
2095 keep_together: vec![KeepTogetherRange {
2096 first_measure: 1,
2097 last_measure: 2,
2098 }],
2099 ..PrintConfig::default()
2100 },
2101 )
2102 .expect("valid keep-together range");
2103 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2104 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2105 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
2106 }
2107
2108 #[test]
2109 fn first_system_measure_capacity_is_preserved_in_print_layout() {
2110 let score = score_with_measures(5);
2111 let result = compute_print_layout(
2112 &score,
2113 &PrintConfig {
2114 measures_per_system: 3,
2115 first_system_measures: Some(1),
2116 systems_per_page: Some(8),
2117 ..PrintConfig::default()
2118 },
2119 )
2120 .expect("valid first-system capacity");
2121 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2122 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2123 assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
2124 }
2125
2126 #[test]
2127 fn pickup_policy_isolates_a_partial_first_measure() {
2128 let mut score = score_with_measures(4);
2129 score.parts[0].staves[0].measures[0].voices[0] =
2130 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2131 let result = compute_print_layout(
2132 &score,
2133 &PrintConfig {
2134 measures_per_system: 3,
2135 pickup_policy: PickupPolicy::DetectFirstMeasure,
2136 systems_per_page: Some(8),
2137 ..PrintConfig::default()
2138 },
2139 )
2140 .expect("valid pickup policy");
2141 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2142 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2143 }
2144
2145 #[test]
2146 fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
2147 let mut score = score_with_measures(4);
2148 score.parts[0].staves[0].measures[0].voices[0] =
2149 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2150 let result = compute_print_layout(
2151 &score,
2152 &PrintConfig {
2153 measures_per_system: 3,
2154 systems_per_page: Some(8),
2155 ..PrintConfig::default()
2156 },
2157 )
2158 .expect("valid automatic pickup policy");
2159 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2160 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2161 }
2162
2163 #[test]
2164 fn system_exposes_physical_span_for_multi_rest_slot() {
2165 let mut score = score_with_measures(6);
2166 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2167 let result = compute_print_layout(&score, &PrintConfig::default())
2168 .expect("valid multi-rest print layout");
2169 assert_eq!(
2170 result.pages[0].systems[0].measure_spans[1],
2171 MeasureSpan {
2172 first_measure: 1,
2173 last_measure: 3,
2174 }
2175 );
2176 }
2177
2178 #[test]
2179 fn multirest_width_drives_system_breaking_without_splitting() {
2180 let mut score = score_with_measures(5);
2181 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2182 let result = compute_print_layout(
2183 &score,
2184 &PrintConfig {
2185 measures_per_system: 2,
2186 pickup_policy: PickupPolicy::Preserve,
2187 systems_per_page: Some(8),
2188 ..PrintConfig::default()
2189 },
2190 )
2191 .expect("valid multi-rest pagination");
2192 assert_eq!(
2193 result.pages[0]
2194 .systems
2195 .iter()
2196 .map(|system| system.measure_indices.clone())
2197 .collect::<Vec<_>>(),
2198 vec![vec![0], vec![1], vec![2, 3], vec![4]]
2199 );
2200 assert_eq!(
2201 result.pages[0].systems[1].measure_spans[0],
2202 MeasureSpan {
2203 first_measure: 1,
2204 last_measure: 3,
2205 }
2206 );
2207 }
2208
2209 #[test]
2210 fn system_exposes_cross_system_span_segments() {
2211 let mut score = score_with_measures(4);
2212 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2213 start.slur_start = true;
2214 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2215 end.slur_end = true;
2216 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2217 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2218 let result = compute_print_layout(
2219 &score,
2220 &PrintConfig {
2221 measures_per_system: 2,
2222 pickup_policy: PickupPolicy::Preserve,
2223 systems_per_page: Some(8),
2224 ..PrintConfig::default()
2225 },
2226 )
2227 .expect("valid cross-system span layout");
2228 assert_eq!(
2229 result.pages[0].systems[0].span_segments,
2230 vec![SpanSegment {
2231 span_index: 0,
2232 starts_here: true,
2233 ends_here: false,
2234 }]
2235 );
2236 assert_eq!(
2237 result.pages[0].systems[1].span_segments,
2238 vec![SpanSegment {
2239 span_index: 0,
2240 starts_here: false,
2241 ends_here: true,
2242 }]
2243 );
2244 }
2245
2246 #[test]
2247 fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
2248 let mut score = score_with_measures(4);
2249 let measures = &mut score.parts[0].staves[0].measures;
2250 measures[0].barline_right = Barline::RepeatEnd;
2251 measures[1].barline_left = Barline::RepeatStart;
2252 measures[2].volta = Some(acorde_core::VoltaBracket {
2253 number: 1,
2254 kind: "begin".to_string(),
2255 });
2256 measures[2].navigation = Some("ToCoda".to_string());
2257 measures[2].rehearsal = Some("B".to_string());
2258 let result = compute_print_layout(
2259 &score,
2260 &PrintConfig {
2261 measures_per_system: 2,
2262 systems_per_page: Some(8),
2263 ..PrintConfig::default()
2264 },
2265 )
2266 .expect("valid measure mark layout");
2267 assert_eq!(
2268 result.pages[0].systems[0].measure_marks,
2269 vec![
2270 MeasureMark {
2271 measure_index: 0,
2272 repeat_start: false,
2273 repeat_end: true,
2274 volta_number: None,
2275 volta_kind: None,
2276 navigation: None,
2277 rehearsal: None,
2278 },
2279 MeasureMark {
2280 measure_index: 1,
2281 repeat_start: true,
2282 repeat_end: false,
2283 volta_number: None,
2284 volta_kind: None,
2285 navigation: None,
2286 rehearsal: None,
2287 },
2288 ]
2289 );
2290 assert_eq!(
2291 result.pages[0].systems[1].measure_marks,
2292 vec![MeasureMark {
2293 measure_index: 2,
2294 repeat_start: false,
2295 repeat_end: false,
2296 volta_number: Some(1),
2297 volta_kind: Some("begin".to_string()),
2298 navigation: Some("ToCoda".to_string()),
2299 rehearsal: Some("B".to_string()),
2300 }]
2301 );
2302 }
2303
2304 #[test]
2305 fn page_aggregates_cross_system_span_ownership() {
2306 let mut score = score_with_measures(4);
2307 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2308 start.slur_start = true;
2309 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2310 end.slur_end = true;
2311 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2312 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2313 let result = compute_print_layout(
2314 &score,
2315 &PrintConfig {
2316 measures_per_system: 2,
2317 pickup_policy: PickupPolicy::Preserve,
2318 systems_per_page: Some(1),
2319 ..PrintConfig::default()
2320 },
2321 )
2322 .expect("valid page span layout");
2323 assert_eq!(
2324 result.pages[0].span_segments,
2325 vec![PageSpanSegment {
2326 span_index: 0,
2327 starts_here: true,
2328 ends_here: false,
2329 }]
2330 );
2331 assert_eq!(
2332 result.pages[1].span_segments,
2333 vec![PageSpanSegment {
2334 span_index: 0,
2335 starts_here: false,
2336 ends_here: true,
2337 }]
2338 );
2339 }
2340
2341 #[test]
2342 fn page_artifact_measure_span_borrows_system_spans() {
2343 let mut score = score_with_measures(4);
2344 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2345 start.slur_start = true;
2346 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2347 end.slur_end = true;
2348 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2349 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2350 let result = compute_print_layout(
2351 &score,
2352 &PrintConfig {
2353 measures_per_system: 2,
2354 pickup_policy: PickupPolicy::Preserve,
2355 systems_per_page: Some(1),
2356 ..PrintConfig::default()
2357 },
2358 )
2359 .expect("valid page artifact");
2360 let first = result
2361 .page(PageAddress { page_index: 0 })
2362 .expect("first page");
2363 assert_eq!(
2364 first.measure_span(),
2365 Some(MeasureSpan {
2366 first_measure: 0,
2367 last_measure: 1,
2368 })
2369 );
2370 assert!(first.has_span_continuation());
2371 assert!(result.page(PageAddress { page_index: 99 }).is_none());
2372 assert!(result.validate().is_ok());
2373 }
2374
2375 #[test]
2376 fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
2377 let mut score = score_with_measures(4);
2378 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2379 start.slur_start = true;
2380 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2381 end.slur_end = true;
2382 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2383 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2384 let result = compute_print_layout(
2385 &score,
2386 &PrintConfig {
2387 measures_per_system: 2,
2388 systems_per_page: Some(1),
2389 ..PrintConfig::default()
2390 },
2391 )
2392 .expect("valid print config");
2393
2394 let artifacts = result
2395 .export_page_artifacts()
2396 .expect("valid page artifacts");
2397 assert_eq!(artifacts.len(), 2);
2398 assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
2399 assert_eq!(artifacts[1].page_index, 1);
2400 assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
2401 assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
2402 assert_eq!(
2403 artifacts[0].measure_span,
2404 Some(MeasureSpan {
2405 first_measure: 0,
2406 last_measure: 1,
2407 })
2408 );
2409 assert_eq!(
2410 artifacts[0].diagnostics,
2411 vec![PageArtifactDiagnostic::SpanContinuation {
2412 span_index: 0,
2413 starts_here: true,
2414 ends_here: false,
2415 }]
2416 );
2417 assert!(artifacts[1].diagnostics.is_empty());
2418 }
2419
2420 #[test]
2421 fn export_page_artifacts_rejects_invalid_serialized_layout() {
2422 let score = score_with_measures(1);
2423 let mut result =
2424 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2425 result.pages[0].width_mm = f32::NAN;
2426
2427 assert!(matches!(
2428 result.export_page_artifacts(),
2429 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2430 ));
2431 }
2432
2433 #[test]
2434 fn page_lookup_rejects_mismatched_serialized_address() {
2435 let score = score_with_measures(1);
2436 let mut result =
2437 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2438 result.pages[0].address = PageAddress { page_index: 7 };
2439
2440 assert!(result.page(PageAddress { page_index: 0 }).is_none());
2441 assert_eq!(
2442 result.validate(),
2443 Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
2444 );
2445 }
2446
2447 #[test]
2448 fn layout_validation_rejects_mismatched_system_address() {
2449 let score = score_with_measures(2);
2450 let mut result = compute_print_layout(
2451 &score,
2452 &PrintConfig {
2453 measures_per_system: 1,
2454 ..PrintConfig::default()
2455 },
2456 )
2457 .expect("valid print config");
2458 result.pages[0].systems[0].address.index_on_page = 4;
2459
2460 assert_eq!(
2461 result.validate(),
2462 Err(PrintLayoutError::InvalidSystemAddress {
2463 page_index: 0,
2464 index_on_page: 0,
2465 system_index: 0,
2466 })
2467 );
2468 }
2469
2470 #[test]
2471 fn layout_validation_rejects_non_monotonic_page_number() {
2472 let score = score_with_measures(2);
2473 let mut result = compute_print_layout(
2474 &score,
2475 &PrintConfig {
2476 measures_per_system: 1,
2477 page_numbering: PageNumbering::OneBased,
2478 systems_per_page: Some(1),
2479 ..PrintConfig::default()
2480 },
2481 )
2482 .expect("valid print config");
2483 result.pages[1].page_number = Some(1);
2484
2485 assert_eq!(
2486 result.validate(),
2487 Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
2488 );
2489 }
2490
2491 #[test]
2492 fn layout_validation_rejects_inconsistent_title_page_metadata() {
2493 let score = score_with_measures(1);
2494 let mut result =
2495 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2496 result.pages[0].publication.is_title_page = true;
2497
2498 assert_eq!(
2499 result.validate(),
2500 Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
2501 );
2502 }
2503
2504 #[test]
2505 fn layout_validation_rejects_non_finite_page_geometry() {
2506 let score = score_with_measures(1);
2507 let mut result =
2508 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2509 result.pages[0].width_mm = f32::NAN;
2510
2511 assert_eq!(
2512 result.validate(),
2513 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2514 );
2515 }
2516
2517 #[test]
2518 fn layout_validation_rejects_non_positive_system_geometry() {
2519 let score = score_with_measures(1);
2520 let mut result =
2521 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2522 result.pages[0].systems[0].height_mm = 0.0;
2523
2524 assert_eq!(
2525 result.validate(),
2526 Err(PrintLayoutError::InvalidSystemGeometry {
2527 page_index: 0,
2528 index_on_page: 0,
2529 })
2530 );
2531 }
2532
2533 #[test]
2534 fn layout_validation_rejects_content_larger_than_page() {
2535 let score = score_with_measures(1);
2536 let mut result =
2537 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2538 result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
2539
2540 assert_eq!(
2541 result.validate(),
2542 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2543 );
2544 }
2545
2546 #[test]
2547 fn notation_policy_keeps_volta_range_in_one_system() {
2548 let mut score = score_with_measures(4);
2549 score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
2550 number: 1,
2551 kind: "begin".to_string(),
2552 });
2553 score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
2554 number: 1,
2555 kind: "end".to_string(),
2556 });
2557 let result = compute_print_layout(
2558 &score,
2559 &PrintConfig {
2560 measures_per_system: 2,
2561 systems_per_page: Some(8),
2562 notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
2563 ..PrintConfig::default()
2564 },
2565 )
2566 .expect("valid volta-preserving layout");
2567 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2568 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2569 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
2570 }
2571
2572 #[test]
2573 fn notation_policy_keeps_repeat_section_on_one_page() {
2574 let mut score = score_with_measures(5);
2575 score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
2576 score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
2577 let result = compute_print_layout(
2578 &score,
2579 &PrintConfig {
2580 measures_per_system: 2,
2581 systems_per_page: Some(2),
2582 notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
2583 ..PrintConfig::default()
2584 },
2585 )
2586 .expect("valid repeat-preserving layout");
2587 assert_eq!(result.pages[0].systems.len(), 1);
2588 assert_eq!(result.pages[1].systems.len(), 2);
2589 assert_eq!(
2590 result.pages[1]
2591 .systems
2592 .iter()
2593 .flat_map(|system| system.measure_indices.iter().copied())
2594 .collect::<Vec<_>>(),
2595 vec![2, 3, 4]
2596 );
2597 }
2598
2599 #[test]
2600 fn balance_policy_avoids_single_system_final_page() {
2601 let score = score_with_measures(5);
2602 let result = compute_print_layout(
2603 &score,
2604 &PrintConfig {
2605 measures_per_system: 1,
2606 systems_per_page: Some(4),
2607 final_page_policy: FinalPagePolicy::Balance,
2608 ..PrintConfig::default()
2609 },
2610 )
2611 .expect("valid balanced print config");
2612 assert_eq!(result.pages.len(), 2);
2613 assert_eq!(result.pages[0].systems.len(), 3);
2614 assert_eq!(result.pages[1].systems.len(), 2);
2615 }
2616
2617 #[test]
2618 fn balance_policy_preserves_explicit_page_breaks() {
2619 let mut score = score_with_measures(5);
2620 score.parts[0].staves[0].measures[1].page_break = true;
2621 let result = compute_print_layout(
2622 &score,
2623 &PrintConfig {
2624 measures_per_system: 1,
2625 systems_per_page: Some(4),
2626 final_page_policy: FinalPagePolicy::Balance,
2627 ..PrintConfig::default()
2628 },
2629 )
2630 .expect("valid explicit-break print config");
2631 assert_eq!(result.pages[0].systems.len(), 2);
2632 assert_eq!(result.pages[1].systems.len(), 3);
2633 }
2634
2635 #[test]
2636 fn keep_together_rejects_ranges_larger_than_system_capacity() {
2637 let score = score_with_measures(4);
2638 let error = compute_print_layout(
2639 &score,
2640 &PrintConfig {
2641 measures_per_system: 2,
2642 keep_together: vec![KeepTogetherRange {
2643 first_measure: 0,
2644 last_measure: 2,
2645 }],
2646 ..PrintConfig::default()
2647 },
2648 )
2649 .expect_err("range must fit in one system");
2650 assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
2651 }
2652
2653 #[test]
2654 fn keep_together_rejects_explicit_break_inside_range() {
2655 let mut score = score_with_measures(4);
2656 score.parts[0].staves[0].measures[1].system_break = true;
2657 let error = compute_print_layout(
2658 &score,
2659 &PrintConfig {
2660 measures_per_system: 3,
2661 keep_together: vec![KeepTogetherRange {
2662 first_measure: 0,
2663 last_measure: 2,
2664 }],
2665 ..PrintConfig::default()
2666 },
2667 )
2668 .expect_err("explicit break must win");
2669 assert_eq!(
2670 error,
2671 PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
2672 );
2673 }
2674
2675 #[test]
2676 fn rejects_margins_that_leave_no_page_area() {
2677 let score = score_with_measures(1);
2678 let error = compute_print_layout(
2679 &score,
2680 &PrintConfig {
2681 margin_left_mm: 200.0,
2682 ..PrintConfig::default()
2683 },
2684 )
2685 .expect_err("invalid page area");
2686 assert_eq!(error, PrintLayoutError::NoUsablePageArea);
2687 }
2688
2689 #[test]
2690 fn safe_area_reduces_content_and_bleed_is_exposed() {
2691 let score = score_with_measures(1);
2692 let result = compute_print_layout(
2693 &score,
2694 &PrintConfig {
2695 bleed_top_mm: 3.0,
2696 bleed_right_mm: 3.0,
2697 bleed_bottom_mm: 3.0,
2698 bleed_left_mm: 3.0,
2699 safe_top_mm: 5.0,
2700 safe_right_mm: 6.0,
2701 safe_bottom_mm: 7.0,
2702 safe_left_mm: 8.0,
2703 ..PrintConfig::default()
2704 },
2705 )
2706 .expect("valid print config");
2707 let page = &result.pages[0];
2708 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
2709 assert_eq!(page.bleed_left_mm, 3.0);
2710 assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
2711 assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
2712 assert_eq!(page.systems[0].top_mm, 21.0);
2713 }
2714
2715 #[test]
2716 fn scale_changes_system_height_and_page_capacity() {
2717 let score = score_with_measures(10);
2718 let result = compute_print_layout(
2719 &score,
2720 &PrintConfig {
2721 scale: 2.0,
2722 measures_per_system: 1,
2723 systems_per_page: None,
2724 ..PrintConfig::default()
2725 },
2726 )
2727 .expect("valid print config");
2728 assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
2729 assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
2730 assert_eq!(result.pages.len(), 2);
2731 }
2732
2733 #[test]
2734 fn rejects_non_positive_scale() {
2735 let score = score_with_measures(1);
2736 let error = compute_print_layout(
2737 &score,
2738 &PrintConfig {
2739 scale: 0.0,
2740 ..PrintConfig::default()
2741 },
2742 )
2743 .expect_err("invalid scale");
2744 assert_eq!(error, PrintLayoutError::InvalidScale);
2745 }
2746
2747 #[test]
2748 fn page_numbering_is_configurable() {
2749 let score = score_with_measures(5);
2750 let numbered = compute_print_layout(
2751 &score,
2752 &PrintConfig {
2753 measures_per_system: 1,
2754 systems_per_page: Some(2),
2755 ..PrintConfig::default()
2756 },
2757 )
2758 .expect("valid print config");
2759 assert_eq!(numbered.pages[0].page_number, Some(1));
2760 assert_eq!(numbered.pages[1].page_number, Some(2));
2761
2762 let unnumbered = compute_print_layout(
2763 &score,
2764 &PrintConfig {
2765 page_numbering: PageNumbering::None,
2766 measures_per_system: 1,
2767 systems_per_page: Some(2),
2768 ..PrintConfig::default()
2769 },
2770 )
2771 .expect("valid print config");
2772 assert!(
2773 unnumbered
2774 .pages
2775 .iter()
2776 .all(|page| page.page_number.is_none())
2777 );
2778 }
2779
2780 #[test]
2781 fn rejects_invalid_publication_line_height() {
2782 let score = score_with_measures(1);
2783 let error = compute_print_layout(
2784 &score,
2785 &PrintConfig {
2786 publication: PublicationConfig {
2787 line_height_mm: 0.0,
2788 ..PublicationConfig::default()
2789 },
2790 ..PrintConfig::default()
2791 },
2792 )
2793 .expect_err("invalid publication line height");
2794 assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
2795 }
2796
2797 #[test]
2798 fn rejects_empty_host_glyph_resource_key() {
2799 let score = score_with_measures(1);
2800 let error = compute_print_layout(
2801 &score,
2802 &PrintConfig {
2803 glyph_resources: GlyphResourcePolicy::HostProvided(" ".into()),
2804 ..PrintConfig::default()
2805 },
2806 )
2807 .expect_err("empty host resource key");
2808 assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
2809 }
2810
2811 #[test]
2812 fn print_color_and_crop_policies_are_exposed_per_page() {
2813 let score = score_with_measures(1);
2814 let result = compute_print_layout(
2815 &score,
2816 &PrintConfig {
2817 color_policy: PrintColorPolicy::Preserve,
2818 crop_mark_policy: CropMarkPolicy::BleedEdges,
2819 ..PrintConfig::default()
2820 },
2821 )
2822 .expect("valid print config");
2823 let page = &result.pages[0];
2824 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
2825 assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
2826 assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
2827 }
2828
2829 #[test]
2830 fn glyph_resource_policy_is_exposed_per_page() {
2831 let score = score_with_measures(1);
2832 let result = compute_print_layout(
2833 &score,
2834 &PrintConfig {
2835 glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
2836 ..PrintConfig::default()
2837 },
2838 )
2839 .expect("valid print config");
2840 assert_eq!(
2841 result.pages[0].glyph_resources,
2842 GlyphResourcePolicy::HostProvided("music-font-v1".into())
2843 );
2844 }
2845
2846 #[test]
2847 fn publication_metadata_is_deterministic_and_page_scoped() {
2848 let mut score = score_with_measures(3);
2849 score.metadata.title = "Suite".into();
2850 score.metadata.movement_title = "I. Prelude".into();
2851 score.metadata.composer = "Composer".into();
2852 score.metadata.copyright = "© 2026 Composer".into();
2853 score.metadata.lyricist = "Lyricist".into();
2854 score.metadata.copyright = "Copyright".into();
2855 score.parts.push(Part::new("Strings", "Str."));
2856 score.part_groups.push(PartGroup {
2857 first_part: 0,
2858 last_part: 1,
2859 symbol: PartGroupSymbol::Bracket,
2860 barlines_connect: true,
2861 });
2862 for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
2863 measure.number = (index + 1) as u32;
2864 }
2865 let result = compute_print_layout(
2866 &score,
2867 &PrintConfig {
2868 measures_per_system: 2,
2869 systems_per_page: Some(1),
2870 publication: PublicationConfig {
2871 running_title: Some("Suite — Composer".into()),
2872 header_text: Some("Suite".into()),
2873 footer_text: Some("Copyright".into()),
2874 page_number_in_footer: true,
2875 header_alignment: PublicationTextAlignment::Center,
2876 footer_alignment: PublicationTextAlignment::Right,
2877 ..PublicationConfig::default()
2878 },
2879 ..PrintConfig::default()
2880 },
2881 )
2882 .expect("valid print config");
2883 assert_eq!(result.pages[0].publication.title, "Suite");
2884 assert_eq!(
2885 result.pages[0].publication.running_title.as_deref(),
2886 Some("Suite — Composer")
2887 );
2888 assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
2889 assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
2890 assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
2891 assert_eq!(result.pages[0].publication.part_groups.len(), 1);
2892 assert_eq!(
2893 result.pages[0].publication.part_groups[0].symbol,
2894 PartGroupSymbol::Bracket
2895 );
2896 assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
2897 assert_eq!(
2898 result.pages[0].publication.text_blocks[0].role,
2899 PublicationTextRole::Header
2900 );
2901 assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
2902 assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
2903 assert_eq!(
2904 result.pages[0].publication.text_blocks[1].role,
2905 PublicationTextRole::Footer
2906 );
2907 assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
2908 assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
2909 assert_eq!(
2910 result.pages[0].publication.text_blocks[0].alignment,
2911 PublicationTextAlignment::Center
2912 );
2913 assert_eq!(
2914 result.pages[0].publication.text_blocks[1].alignment,
2915 PublicationTextAlignment::Right
2916 );
2917 }
2918
2919 #[test]
2920 fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
2921 let mut score = score_with_measures(2);
2922 let mut part = Part::new("Flute", "Fl.");
2923 let mut staff = Staff::new(Clef::Treble);
2924 staff.measures = vec![Measure::empty(4, 4); 5];
2925 part.staves = vec![staff];
2926 score.parts.push(part);
2927
2928 let extracted = compute_print_layout(
2929 &score,
2930 &PrintConfig {
2931 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
2932 measures_per_system: 2,
2933 systems_per_page: Some(1),
2934 ..PrintConfig::default()
2935 },
2936 )
2937 .expect("valid extracted part");
2938 assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
2939 assert_eq!(extracted.pages.len(), 3);
2940 assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
2941
2942 let error = compute_print_layout(
2943 &score,
2944 &PrintConfig {
2945 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
2946 ..PrintConfig::default()
2947 },
2948 )
2949 .expect_err("missing extracted part");
2950 assert_eq!(error, PrintLayoutError::InvalidPartIndex);
2951 }
2952
2953 #[test]
2954 fn title_page_is_inserted_without_consuming_music_page_capacity() {
2955 let mut score = score_with_measures(3);
2956 score.metadata.title = "Suite".into();
2957 score.metadata.movement_title = "I. Prelude".into();
2958 score.metadata.composer = "Composer".into();
2959 score.metadata.copyright = "© 2026 Composer".into();
2960 let result = compute_print_layout(
2961 &score,
2962 &PrintConfig {
2963 systems_per_page: Some(1),
2964 measures_per_system: 2,
2965 publication: PublicationConfig {
2966 title_page: true,
2967 ..PublicationConfig::default()
2968 },
2969 ..PrintConfig::default()
2970 },
2971 )
2972 .expect("valid title page config");
2973 assert_eq!(result.pages.len(), 3);
2974 assert!(result.pages[0].systems.is_empty());
2975 assert!(result.pages[0].publication.is_title_page);
2976 assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
2977 assert_eq!(result.pages[0].page_number, Some(1));
2978 assert_eq!(result.pages[1].page_number, Some(2));
2979 assert_eq!(result.pages[1].systems[0].page_index, 1);
2980 assert!(!result.pages[1].publication.is_title_page);
2981 assert!(result.validate().is_ok());
2982 assert_eq!(
2983 result.pages[0]
2984 .publication
2985 .text_blocks
2986 .iter()
2987 .map(|block| block.role)
2988 .collect::<Vec<_>>(),
2989 vec![
2990 PublicationTextRole::Title,
2991 PublicationTextRole::Subtitle,
2992 PublicationTextRole::Credit,
2993 PublicationTextRole::Copyright
2994 ]
2995 );
2996 }
2997
2998 #[test]
2999 fn print_presets_are_versioned_and_select_the_expected_scope() {
3000 assert_eq!(PrintPreset::A4Score.schema_version(), 1);
3001 assert_eq!(
3002 PrintPreset::A4Score.config().part_layout,
3003 PartLayoutPolicy::FullScore
3004 );
3005 assert_eq!(
3006 PrintPreset::LetterPart { part_index: 2 }
3007 .config()
3008 .part_layout,
3009 PartLayoutPolicy::ExtractedPart { part_index: 2 }
3010 );
3011 assert_eq!(
3012 PrintPreset::LetterScore.config().paper_size,
3013 PaperSize::Letter
3014 );
3015 assert!(
3016 PrintPreset::A4Score
3017 .config_with_title_page(true)
3018 .publication
3019 .title_page
3020 );
3021 assert!(!PrintPreset::A4Score.config().publication.title_page);
3022 assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 1);
3023 }
3024
3025 #[test]
3026 fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
3027 let metrics = GlyphMetrics {
3028 advance_mm: 4.0,
3029 left_mm: -1.0,
3030 top_mm: -2.0,
3031 width_mm: 2.0,
3032 height_mm: 4.0,
3033 };
3034 let mut placements = vec![
3035 GlyphPlacement {
3036 resource_key: "high".into(),
3037 metrics,
3038 x_mm: 10.0,
3039 y_mm: 20.0,
3040 priority: 10,
3041 },
3042 GlyphPlacement {
3043 resource_key: "low".into(),
3044 metrics,
3045 x_mm: 10.0,
3046 y_mm: 20.0,
3047 priority: 1,
3048 },
3049 ];
3050 let moved = resolve_glyph_collisions(&mut placements, 1.0);
3051 assert_eq!(moved, 1);
3052 assert_eq!(placements[0].y_mm, 20.0);
3053 assert_eq!(placements[1].y_mm, 25.0);
3054 }
3055
3056 #[test]
3057 fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
3058 let metrics = GlyphMetrics {
3059 advance_mm: 4.0,
3060 left_mm: -1.0,
3061 top_mm: -1.0,
3062 width_mm: 2.0,
3063 height_mm: 2.0,
3064 };
3065 let mut placements = vec![
3066 GlyphPlacement {
3067 resource_key: "high".into(),
3068 metrics,
3069 x_mm: 10.0,
3070 y_mm: 20.0,
3071 priority: 10,
3072 },
3073 GlyphPlacement {
3074 resource_key: "low".into(),
3075 metrics,
3076 x_mm: 10.0,
3077 y_mm: 0.0,
3078 priority: 1,
3079 },
3080 ];
3081 assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
3082 assert_eq!(placements[1].y_mm, 0.0);
3083 }
3084
3085 #[test]
3086 fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
3087 let mut placements = vec![GlyphPlacement {
3088 resource_key: "test".into(),
3089 metrics: GlyphMetrics {
3090 advance_mm: 1.0,
3091 left_mm: 0.0,
3092 top_mm: 0.0,
3093 width_mm: 1.0,
3094 height_mm: 1.0,
3095 },
3096 x_mm: 0.0,
3097 y_mm: 0.0,
3098 priority: 0,
3099 }];
3100 assert_eq!(validate_glyph_placements(&placements), Ok(()));
3101 placements[0].x_mm = f32::NAN;
3102 assert_eq!(
3103 validate_glyph_placements(&placements),
3104 Err(GlyphPlacementError::NonFinite { index: 0 })
3105 );
3106 placements[0].x_mm = 0.0;
3107 placements[0].metrics.width_mm = -1.0;
3108 assert_eq!(
3109 validate_glyph_placements(&placements),
3110 Err(GlyphPlacementError::NegativeExtent { index: 0 })
3111 );
3112 }
3113
3114 #[test]
3115 fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
3116 let metrics = GlyphMetrics {
3117 advance_mm: 4.0,
3118 left_mm: -1.0,
3119 top_mm: -1.0,
3120 width_mm: 2.0,
3121 height_mm: 2.0,
3122 };
3123 let mut placements = vec![
3124 GlyphPlacement {
3125 resource_key: "high".into(),
3126 metrics,
3127 x_mm: 10.0,
3128 y_mm: 20.0,
3129 priority: 10,
3130 },
3131 GlyphPlacement {
3132 resource_key: "low".into(),
3133 metrics,
3134 x_mm: 10.0,
3135 y_mm: 20.0,
3136 priority: 1,
3137 },
3138 GlyphPlacement {
3139 resource_key: "far".into(),
3140 metrics,
3141 x_mm: 10.0,
3142 y_mm: 30.0,
3143 priority: 1,
3144 },
3145 ];
3146 assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
3147 assert_eq!(placements[0].x_mm, 10.0);
3148 assert_eq!(placements[1].x_mm, 13.0);
3149 assert_eq!(placements[2].x_mm, 10.0);
3150 }
3151
3152 #[test]
3153 fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
3154 let metrics = GlyphMetrics {
3155 advance_mm: 1.0,
3156 left_mm: 0.0,
3157 top_mm: 0.0,
3158 width_mm: 1.0,
3159 height_mm: 1.0,
3160 };
3161 let mut placements = vec![
3162 GlyphPlacement {
3163 resource_key: "second".into(),
3164 metrics,
3165 x_mm: 20.0,
3166 y_mm: 0.0,
3167 priority: 0,
3168 },
3169 GlyphPlacement {
3170 resource_key: "first".into(),
3171 metrics,
3172 x_mm: 10.0,
3173 y_mm: 0.0,
3174 priority: 0,
3175 },
3176 GlyphPlacement {
3177 resource_key: "third".into(),
3178 metrics,
3179 x_mm: 30.0,
3180 y_mm: 0.0,
3181 priority: 0,
3182 },
3183 ];
3184 assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
3185 assert_eq!(placements[0].x_mm, 23.0);
3186 assert_eq!(placements[1].x_mm, 10.0);
3187 assert_eq!(placements[2].x_mm, 36.0);
3188 assert_eq!(
3189 distribute_glyph_spacing(&mut placements, f32::NAN),
3190 Err(GlyphPlacementError::NonFiniteSpacing)
3191 );
3192 let before = placements.clone();
3193 assert_eq!(
3194 distribute_glyph_spacing(&mut placements, f32::MAX),
3195 Err(GlyphPlacementError::NonFiniteSpacing)
3196 );
3197 assert_eq!(placements, before);
3198 }
3199
3200 #[test]
3201 fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
3202 let mut placement = GlyphPlacement {
3203 resource_key: " ".into(),
3204 metrics: GlyphMetrics {
3205 advance_mm: 1.0,
3206 left_mm: 0.0,
3207 top_mm: 0.0,
3208 width_mm: 1.0,
3209 height_mm: 1.0,
3210 },
3211 x_mm: 0.0,
3212 y_mm: 0.0,
3213 priority: 0,
3214 };
3215 assert_eq!(
3216 validate_glyph_placements(&[placement.clone()]),
3217 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3218 );
3219 placement.resource_key = "glyph".into();
3220 placement.metrics.advance_mm = -1.0;
3221 assert_eq!(
3222 validate_glyph_placements(&[placement]),
3223 Err(GlyphPlacementError::NegativeAdvance { index: 0 })
3224 );
3225 }
3226
3227 #[test]
3228 fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
3229 let mut placements = vec![GlyphPlacement {
3230 resource_key: String::new(),
3231 metrics: GlyphMetrics {
3232 advance_mm: 1.0,
3233 left_mm: 0.0,
3234 top_mm: 0.0,
3235 width_mm: 1.0,
3236 height_mm: 1.0,
3237 },
3238 x_mm: 0.0,
3239 y_mm: 0.0,
3240 priority: 0,
3241 }];
3242 assert_eq!(
3243 resolve_glyph_collisions_checked(&mut placements, 1.0),
3244 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3245 );
3246 assert_eq!(
3247 resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
3248 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3249 );
3250 assert_eq!(placements[0].x_mm, 0.0);
3251 assert_eq!(placements[0].y_mm, 0.0);
3252 }
3253
3254 #[test]
3255 fn checked_collision_resolvers_reject_non_finite_gap() {
3256 let metrics = GlyphMetrics {
3257 advance_mm: 1.0,
3258 left_mm: 0.0,
3259 top_mm: 0.0,
3260 width_mm: 1.0,
3261 height_mm: 1.0,
3262 };
3263 let original = vec![GlyphPlacement {
3264 resource_key: "glyph".into(),
3265 metrics,
3266 x_mm: 0.0,
3267 y_mm: 0.0,
3268 priority: 0,
3269 }];
3270 let mut vertical = original.clone();
3271 assert_eq!(
3272 resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
3273 Err(GlyphPlacementError::NonFiniteSpacing)
3274 );
3275 assert_eq!(vertical, original);
3276
3277 let mut horizontal = original.clone();
3278 assert_eq!(
3279 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
3280 Err(GlyphPlacementError::NonFiniteSpacing)
3281 );
3282 assert_eq!(horizontal, original);
3283 }
3284
3285 #[test]
3286 fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
3287 let metrics = GlyphMetrics {
3288 advance_mm: 1.0,
3289 left_mm: 0.0,
3290 top_mm: 0.0,
3291 width_mm: f32::MAX / 2.0,
3292 height_mm: f32::MAX / 2.0,
3293 };
3294 let original = vec![
3295 GlyphPlacement {
3296 resource_key: "high".into(),
3297 metrics,
3298 x_mm: 0.0,
3299 y_mm: 0.0,
3300 priority: 1,
3301 },
3302 GlyphPlacement {
3303 resource_key: "low".into(),
3304 metrics,
3305 x_mm: 0.0,
3306 y_mm: 0.0,
3307 priority: 0,
3308 },
3309 ];
3310 let mut vertical = original.clone();
3311 assert_eq!(
3312 resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
3313 Err(GlyphPlacementError::NonFinite { index: 1 })
3314 );
3315 assert_eq!(vertical, original);
3316
3317 let mut horizontal = original.clone();
3318 assert_eq!(
3319 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
3320 Err(GlyphPlacementError::NonFinite { index: 1 })
3321 );
3322 assert_eq!(horizontal, original);
3323 }
3324
3325 #[test]
3326 fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
3327 let metrics = GlyphMetrics {
3328 advance_mm: 1.0,
3329 left_mm: -1.0,
3330 top_mm: -2.0,
3331 width_mm: 3.0,
3332 height_mm: 4.0,
3333 };
3334 let placements = vec![
3335 GlyphPlacement {
3336 resource_key: "a".into(),
3337 metrics,
3338 x_mm: 10.0,
3339 y_mm: 20.0,
3340 priority: 0,
3341 },
3342 GlyphPlacement {
3343 resource_key: "b".into(),
3344 metrics,
3345 x_mm: 30.0,
3346 y_mm: 5.0,
3347 priority: 0,
3348 },
3349 ];
3350 assert_eq!(
3351 glyph_extents(&placements),
3352 Ok(Some(GlyphExtents {
3353 left_mm: 9.0,
3354 top_mm: 3.0,
3355 right_mm: 32.0,
3356 bottom_mm: 22.0,
3357 }))
3358 );
3359 let extents = glyph_extents(&placements).unwrap().unwrap();
3360 assert_eq!(extents.width_mm(), 23.0);
3361 assert_eq!(extents.height_mm(), 19.0);
3362 assert_eq!(glyph_extents(&[]), Ok(None));
3363 }
3364
3365 #[test]
3366 fn glyph_extents_reject_derived_bound_overflow() {
3367 let placements = [GlyphPlacement {
3368 resource_key: "edge".into(),
3369 metrics: GlyphMetrics {
3370 advance_mm: 1.0,
3371 left_mm: 0.0,
3372 top_mm: 0.0,
3373 width_mm: f32::MAX,
3374 height_mm: 1.0,
3375 },
3376 x_mm: f32::MAX,
3377 y_mm: 0.0,
3378 priority: 0,
3379 }];
3380 assert_eq!(
3381 glyph_extents(&placements),
3382 Err(GlyphPlacementError::NonFinite { index: 0 })
3383 );
3384 }
3385}