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 = 25;
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 GlyphResourceRequired,
814 GlyphOverflow {
816 left: bool,
817 top: bool,
818 right: bool,
819 bottom: bool,
820 },
821 SpanContinuation {
823 span_index: usize,
824 starts_here: bool,
825 ends_here: bool,
826 },
827}
828
829impl PageLayout {
830 pub fn measure_span(&self) -> Option<MeasureSpan> {
832 let mut spans = self
833 .systems
834 .iter()
835 .flat_map(|system| system.measure_spans.iter().copied());
836 let first = spans.next()?;
837 Some(spans.fold(first, |range, span| MeasureSpan {
838 first_measure: range.first_measure.min(span.first_measure),
839 last_measure: range.last_measure.max(span.last_measure),
840 }))
841 }
842
843 pub fn has_span_continuation(&self) -> bool {
845 self.span_segments
846 .iter()
847 .any(|segment| !segment.starts_here || !segment.ends_here)
848 }
849
850 pub fn artifact_diagnostics(
856 &self,
857 glyph_extents: Option<GlyphExtents>,
858 ) -> Vec<PageArtifactDiagnostic> {
859 let mut diagnostics = Vec::new();
860 if matches!(self.glyph_resources, GlyphResourcePolicy::HostProvided(_)) {
861 diagnostics.push(PageArtifactDiagnostic::GlyphResourceRequired);
862 }
863 if let Some(extents) = glyph_extents {
864 let overflow = PageArtifactDiagnostic::GlyphOverflow {
865 left: extents.left_mm < 0.0,
866 top: extents.top_mm < 0.0,
867 right: extents.right_mm > self.content_width_mm,
868 bottom: extents.bottom_mm > self.content_height_mm,
869 };
870 if let PageArtifactDiagnostic::GlyphOverflow {
871 left,
872 top,
873 right,
874 bottom,
875 } = overflow
876 && (left || top || right || bottom)
877 {
878 diagnostics.push(overflow);
879 }
880 }
881 diagnostics.extend(
882 self.span_segments
883 .iter()
884 .filter(|segment| !segment.starts_here || !segment.ends_here)
885 .map(|segment| PageArtifactDiagnostic::SpanContinuation {
886 span_index: segment.span_index,
887 starts_here: segment.starts_here,
888 ends_here: segment.ends_here,
889 }),
890 );
891 diagnostics
892 }
893}
894
895#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
897pub struct PrintLayoutResult {
898 pub contract_version: u16,
899 pub pages: Vec<PageLayout>,
900}
901
902impl PrintLayoutResult {
903 pub fn validate(&self) -> Result<(), PrintLayoutError> {
908 if self.contract_version != PRINT_LAYOUT_CONTRACT_VERSION {
909 return Err(PrintLayoutError::UnsupportedContractVersion {
910 found: self.contract_version,
911 });
912 }
913 let mut expected_system_index = 0;
914 let mut previous_page_number = None;
915 let mut numbered_pages = None;
916 for (page_index, page) in self.pages.iter().enumerate() {
917 if page.page_index != page_index || page.address.page_index != page_index {
918 return Err(PrintLayoutError::InvalidPageAddress { page_index });
919 }
920 if !page.width_mm.is_finite()
921 || !page.height_mm.is_finite()
922 || page.width_mm <= 0.0
923 || page.height_mm <= 0.0
924 || !page.content_width_mm.is_finite()
925 || !page.content_height_mm.is_finite()
926 || page.content_width_mm <= 0.0
927 || page.content_height_mm <= 0.0
928 || page.content_width_mm > page.width_mm
929 || page.content_height_mm > page.height_mm
930 || !page.bleed_top_mm.is_finite()
931 || !page.bleed_right_mm.is_finite()
932 || !page.bleed_bottom_mm.is_finite()
933 || !page.bleed_left_mm.is_finite()
934 || page.bleed_top_mm < 0.0
935 || page.bleed_right_mm < 0.0
936 || page.bleed_bottom_mm < 0.0
937 || page.bleed_left_mm < 0.0
938 {
939 return Err(PrintLayoutError::InvalidPageGeometry { page_index });
940 }
941 let is_title_break = page.break_reason == BreakReason::TitlePage;
942 if is_title_break != page.publication.is_title_page
943 || (is_title_break && (page_index != 0 || !page.systems.is_empty()))
944 {
945 return Err(PrintLayoutError::InvalidTitlePage { page_index });
946 }
947 match page.page_number {
948 Some(page_number)
949 if page_number == 0
950 || numbered_pages == Some(false)
951 || page_index.checked_add(1) != Some(page_number)
952 || previous_page_number.is_some_and(|previous| page_number <= previous) =>
953 {
954 return Err(PrintLayoutError::InvalidPageNumber { page_index });
955 }
956 Some(page_number) => {
957 numbered_pages = Some(true);
958 previous_page_number = Some(page_number);
959 }
960 None if numbered_pages == Some(true) => {
961 return Err(PrintLayoutError::InvalidPageNumber { page_index });
962 }
963 None => numbered_pages = Some(false),
964 }
965 for (index_on_page, system) in page.systems.iter().enumerate() {
966 if system.page_index != page_index
967 || system.address.page_index != page_index
968 || system.address.index_on_page != index_on_page
969 || system.system_index != expected_system_index
970 || system.address.system_index != expected_system_index
971 {
972 return Err(PrintLayoutError::InvalidSystemAddress {
973 page_index,
974 index_on_page,
975 system_index: expected_system_index,
976 });
977 }
978 if !system.top_mm.is_finite()
979 || system.top_mm < 0.0
980 || !system.height_mm.is_finite()
981 || system.height_mm <= 0.0
982 {
983 return Err(PrintLayoutError::InvalidSystemGeometry {
984 page_index,
985 index_on_page,
986 });
987 }
988 expected_system_index += 1;
989 }
990 }
991 Ok(())
992 }
993
994 pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
996 self.pages
997 .get(address.page_index)
998 .filter(|page| page.address == address)
999 }
1000
1001 pub fn export_page_artifacts(&self) -> Result<Vec<PageArtifact>, PrintLayoutError> {
1007 self.validate()?;
1008 Ok(self
1009 .pages
1010 .iter()
1011 .map(|page| PageArtifact {
1012 address: page.address,
1013 page_index: page.page_index,
1014 page_number: page.page_number,
1015 width_mm: page.width_mm,
1016 height_mm: page.height_mm,
1017 content_width_mm: page.content_width_mm,
1018 content_height_mm: page.content_height_mm,
1019 measure_span: page.measure_span(),
1020 diagnostics: page.artifact_diagnostics(None),
1021 layout: page.clone(),
1022 })
1023 .collect())
1024 }
1025}
1026
1027#[derive(Debug, thiserror::Error, PartialEq)]
1028pub enum PrintLayoutError {
1029 #[error("paper dimensions must be finite and greater than zero")]
1030 InvalidPaperDimensions,
1031 #[error("margins must be finite and non-negative")]
1032 InvalidMargins,
1033 #[error("system height must be finite and greater than zero")]
1034 InvalidSystemHeight,
1035 #[error("print scale must be finite and greater than zero")]
1036 InvalidScale,
1037 #[error("margins leave no usable page area")]
1038 NoUsablePageArea,
1039 #[error("keep-together range is outside the score or reversed")]
1040 InvalidKeepTogetherRange,
1041 #[error("keep-together range exceeds the measures-per-system capacity")]
1042 KeepTogetherExceedsSystemCapacity,
1043 #[error("keep-together range conflicts with an explicit system or page break")]
1044 KeepTogetherConflictsWithExplicitBreak,
1045 #[error("repeat section exceeds the systems-per-page capacity")]
1046 RepeatRangeExceedsPageCapacity,
1047 #[error("extracted part index is outside the score")]
1048 InvalidPartIndex,
1049 #[error("publication line height must be finite and greater than zero")]
1050 InvalidPublicationLineHeight,
1051 #[error("host-provided glyph resource key must not be empty")]
1052 InvalidGlyphResourceKey,
1053 #[error("unsupported print layout contract version {found}")]
1054 UnsupportedContractVersion { found: u16 },
1055 #[error("page {page_index} has an inconsistent stable address")]
1056 InvalidPageAddress { page_index: usize },
1057 #[error("page {page_index} has an invalid or non-monotonic page number")]
1058 InvalidPageNumber { page_index: usize },
1059 #[error("page {page_index} has inconsistent title-page metadata")]
1060 InvalidTitlePage { page_index: usize },
1061 #[error(
1062 "system {system_index} at page {page_index}, position {index_on_page} has an inconsistent stable address"
1063 )]
1064 InvalidSystemAddress {
1065 page_index: usize,
1066 index_on_page: usize,
1067 system_index: usize,
1068 },
1069 #[error("page {page_index} has invalid physical geometry")]
1070 InvalidPageGeometry { page_index: usize },
1071 #[error("system at page {page_index}, position {index_on_page} has invalid physical geometry")]
1072 InvalidSystemGeometry {
1073 page_index: usize,
1074 index_on_page: usize,
1075 },
1076}
1077
1078fn apply_keep_together(
1079 score: &Score,
1080 mut rows: Vec<crate::RowLayout>,
1081 ranges: &[KeepTogetherRange],
1082 capacity: usize,
1083) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
1084 let measure_count = score
1085 .parts
1086 .first()
1087 .and_then(|part| part.staves.first())
1088 .map(|staff| staff.measures.len())
1089 .unwrap_or(0);
1090 for range in ranges {
1091 let length = range
1092 .last_measure
1093 .checked_sub(range.first_measure)
1094 .and_then(|length| length.checked_add(1));
1095 if range.first_measure > range.last_measure || range.last_measure >= measure_count {
1096 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1097 }
1098 if length.is_none_or(|length| length > capacity) {
1099 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1100 }
1101 for measure_index in range.first_measure..range.last_measure {
1102 let has_break = score
1103 .parts
1104 .iter()
1105 .flat_map(|part| part.staves.iter())
1106 .filter_map(|staff| staff.measures.get(measure_index))
1107 .any(|measure| measure.system_break || measure.page_break);
1108 if has_break {
1109 return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
1110 }
1111 }
1112
1113 let mut split_rows = Vec::with_capacity(rows.len() + 2);
1117 for row in rows {
1118 let mut cuts = vec![0, row.measure_indices.len()];
1119 if let Some(position) = row
1120 .measure_indices
1121 .iter()
1122 .position(|&index| index == range.first_measure)
1123 {
1124 cuts.push(position);
1125 }
1126 if let Some(position) = row
1127 .measure_indices
1128 .iter()
1129 .position(|&index| index == range.last_measure)
1130 {
1131 cuts.push(position + 1);
1132 }
1133 cuts.sort_unstable();
1134 cuts.dedup();
1135 for window in cuts.windows(2) {
1136 if window[0] < window[1] {
1137 split_rows.push(crate::RowLayout {
1138 measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
1139 });
1140 }
1141 }
1142 }
1143 rows = split_rows;
1144
1145 let first_row = rows
1146 .iter()
1147 .position(|row| row.measure_indices.contains(&range.first_measure));
1148 let last_row = rows
1149 .iter()
1150 .position(|row| row.measure_indices.contains(&range.last_measure));
1151 let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
1152 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1153 };
1154
1155 if first_row != last_row {
1156 let merged: Vec<usize> = rows[first_row..=last_row]
1157 .iter()
1158 .flat_map(|row| row.measure_indices.iter().copied())
1159 .collect();
1160 if merged.len() > capacity {
1161 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1162 }
1163 rows.splice(
1164 first_row..=last_row,
1165 [crate::RowLayout {
1166 measure_indices: merged,
1167 }],
1168 );
1169 }
1170
1171 let row_index = rows
1172 .iter()
1173 .position(|row| row.measure_indices.contains(&range.first_measure))
1174 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1175 let row = rows.remove(row_index);
1176 let start = row
1177 .measure_indices
1178 .iter()
1179 .position(|&index| index == range.first_measure)
1180 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1181 let end = row
1182 .measure_indices
1183 .iter()
1184 .position(|&index| index == range.last_measure)
1185 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1186 let mut replacement = Vec::new();
1187 if start > 0 {
1188 replacement.push(crate::RowLayout {
1189 measure_indices: row.measure_indices[..start].to_vec(),
1190 });
1191 }
1192 replacement.push(crate::RowLayout {
1193 measure_indices: row.measure_indices[start..=end].to_vec(),
1194 });
1195 if end + 1 < row.measure_indices.len() {
1196 replacement.push(crate::RowLayout {
1197 measure_indices: row.measure_indices[end + 1..].to_vec(),
1198 });
1199 }
1200 rows.splice(row_index..row_index, replacement);
1201 }
1202 Ok(rows)
1203}
1204
1205fn has_first_measure_pickup(score: &Score) -> bool {
1206 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1207 return false;
1208 };
1209 let Some(measure) = staff.measures.first() else {
1210 return false;
1211 };
1212 let expected = measure
1213 .time_sig
1214 .as_ref()
1215 .unwrap_or(&score.settings.time_signature)
1216 .total_beats();
1217 let actual = measure
1218 .voices
1219 .iter()
1220 .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
1221 .fold(0.0, f64::max);
1222 actual > 1e-9 && actual + 1e-9 < expected
1223}
1224
1225fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
1226 let measure_count = score
1227 .parts
1228 .first()
1229 .and_then(|part| part.staves.first())
1230 .map(|staff| staff.measures.len())
1231 .unwrap_or(0);
1232 measure_indices
1233 .iter()
1234 .filter_map(|&first_measure| {
1235 if first_measure >= measure_count {
1236 return None;
1237 }
1238 let count = score
1239 .parts
1240 .iter()
1241 .flat_map(|part| part.staves.iter())
1242 .filter_map(|staff| staff.measures.get(first_measure))
1243 .filter_map(|measure| measure.multi_rest_count)
1244 .map(usize::from)
1245 .max()
1246 .unwrap_or(1)
1247 .max(1);
1248 Some(MeasureSpan {
1249 first_measure,
1250 last_measure: first_measure
1251 .saturating_add(count.saturating_sub(1))
1252 .min(measure_count.saturating_sub(1)),
1253 })
1254 })
1255 .collect()
1256}
1257
1258fn span_bounds(span: &SpanMark) -> (usize, usize) {
1259 match span {
1260 SpanMark::Hairpin { start, end, .. }
1261 | SpanMark::Ottava { start, end, .. }
1262 | SpanMark::Pedal { start, end }
1263 | SpanMark::Slur { start, end }
1264 | SpanMark::TrillLine { start, end }
1265 | SpanMark::Glissando { start, end } => (
1266 start.measure.min(end.measure),
1267 start.measure.max(end.measure),
1268 ),
1269 }
1270}
1271
1272fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
1273 let (Some(&first_measure), Some(&last_measure)) =
1274 (measure_indices.first(), measure_indices.last())
1275 else {
1276 return Vec::new();
1277 };
1278 spans
1279 .iter()
1280 .enumerate()
1281 .filter_map(|(span_index, span)| {
1282 let (start_measure, end_measure) = span_bounds(span);
1283 (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
1284 span_index,
1285 starts_here: (first_measure..=last_measure).contains(&start_measure),
1286 ends_here: (first_measure..=last_measure).contains(&end_measure),
1287 })
1288 })
1289 .collect()
1290}
1291
1292fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
1293 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1294 return Vec::new();
1295 };
1296 measure_indices
1297 .iter()
1298 .filter_map(|&measure_index| {
1299 let measure = staff.measures.get(measure_index)?;
1300 let repeat_start = matches!(
1301 measure.barline_left,
1302 Barline::RepeatStart | Barline::RepeatBoth
1303 );
1304 let repeat_end = matches!(
1305 measure.barline_right,
1306 Barline::RepeatEnd | Barline::RepeatBoth
1307 );
1308 let has_mark = repeat_start
1309 || repeat_end
1310 || measure.volta.is_some()
1311 || measure.navigation.is_some()
1312 || measure.rehearsal.is_some();
1313 has_mark.then(|| MeasureMark {
1314 measure_index,
1315 repeat_start,
1316 repeat_end,
1317 volta_number: measure.volta.as_ref().map(|volta| volta.number),
1318 volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
1319 navigation: measure.navigation.clone(),
1320 rehearsal: measure.rehearsal.clone(),
1321 })
1322 })
1323 .collect()
1324}
1325
1326fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1327 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1328 return Vec::new();
1329 };
1330 let mut ranges = Vec::new();
1331 let mut start = None;
1332 for (index, measure) in staff.measures.iter().enumerate() {
1333 let Some(volta) = measure.volta.as_ref() else {
1334 continue;
1335 };
1336 if matches!(volta.kind.as_str(), "begin" | "begin_end") {
1337 start = Some(index);
1338 }
1339 if matches!(volta.kind.as_str(), "end" | "begin_end")
1340 && let Some(first_measure) = start.take()
1341 {
1342 ranges.push(KeepTogetherRange {
1343 first_measure,
1344 last_measure: index,
1345 });
1346 }
1347 }
1348 ranges
1349}
1350
1351fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
1352 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
1353 return Vec::new();
1354 };
1355 let mut ranges = Vec::new();
1356 let mut start = None;
1357 for (index, measure) in staff.measures.iter().enumerate() {
1358 if matches!(
1359 measure.barline_left,
1360 Barline::RepeatStart | Barline::RepeatBoth
1361 ) {
1362 start = Some(index);
1363 }
1364 if matches!(
1365 measure.barline_right,
1366 Barline::RepeatEnd | Barline::RepeatBoth
1367 ) {
1368 ranges.push(KeepTogetherRange {
1369 first_measure: start.take().unwrap_or(0),
1370 last_measure: index,
1371 });
1372 }
1373 }
1374 ranges
1375}
1376
1377fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
1378 repeat_ranges(score)
1379 .into_iter()
1380 .filter_map(|range| {
1381 let first = rows
1382 .iter()
1383 .position(|row| row.measure_indices.contains(&range.first_measure))?;
1384 let last = rows
1385 .iter()
1386 .position(|row| row.measure_indices.contains(&range.last_measure))?;
1387 Some((first, last))
1388 })
1389 .collect()
1390}
1391
1392fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
1393 let mut segments = Vec::new();
1394 for system in systems {
1395 for segment in &system.span_segments {
1396 if let Some(existing) = segments
1397 .iter_mut()
1398 .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
1399 {
1400 existing.ends_here |= segment.ends_here;
1401 } else {
1402 segments.push(PageSpanSegment {
1403 span_index: segment.span_index,
1404 starts_here: segment.starts_here,
1405 ends_here: segment.ends_here,
1406 });
1407 }
1408 }
1409 }
1410 segments
1411}
1412
1413fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
1414 systems
1415 .iter()
1416 .flat_map(|system| system.measure_marks.iter().cloned())
1417 .collect()
1418}
1419
1420fn page_publication(
1421 score: &Score,
1422 measure_score: &Score,
1423 config: &PrintConfig,
1424 systems: &[SystemLayout],
1425 is_title_page: bool,
1426 page_number: Option<usize>,
1427) -> PagePublication {
1428 let metadata = &score.metadata;
1429 let part_labels = if config.publication.show_part_names {
1430 let parts = match config.part_layout {
1431 PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
1432 PartLayoutPolicy::ExtractedPart { part_index } => score
1433 .parts
1434 .get(part_index)
1435 .into_iter()
1436 .enumerate()
1437 .map(|(index, part)| (part_index + index, part))
1438 .collect(),
1439 };
1440 parts
1441 .into_iter()
1442 .map(|(part_index, part)| PartLabel {
1443 part_index,
1444 name: part.name.clone(),
1445 short_name: part.short_name.clone(),
1446 })
1447 .collect()
1448 } else {
1449 Vec::new()
1450 };
1451 let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
1452 score
1453 .part_groups
1454 .iter()
1455 .map(|group| PartGroupMark {
1456 first_part: group.first_part,
1457 last_part: group.last_part,
1458 symbol: group.symbol.clone(),
1459 barlines_connect: group.barlines_connect,
1460 })
1461 .collect()
1462 } else {
1463 Vec::new()
1464 };
1465 let measure_numbers = if config.publication.show_measure_numbers {
1466 let staff = measure_score
1467 .parts
1468 .first()
1469 .and_then(|part| part.staves.first());
1470 systems
1471 .iter()
1472 .flat_map(|system| system.measure_indices.iter().copied())
1473 .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
1474 .map(|measure| measure.number)
1475 .collect()
1476 } else {
1477 Vec::new()
1478 };
1479 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1480 let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
1481 (paper_height, paper_width)
1482 } else {
1483 (paper_width, paper_height)
1484 };
1485 let mut text_blocks = Vec::new();
1486 if !is_title_page
1487 && let Some(text) = config
1488 .publication
1489 .header_text
1490 .as_ref()
1491 .or(config.publication.running_title.as_ref())
1492 {
1493 text_blocks.push(PublicationTextBlock {
1494 role: PublicationTextRole::Header,
1495 text: text.clone(),
1496 x_mm: config.margin_left_mm + config.safe_left_mm,
1497 y_mm: config.margin_top_mm,
1498 width_mm: page_width
1499 - config.margin_left_mm
1500 - config.margin_right_mm
1501 - config.safe_left_mm
1502 - config.safe_right_mm,
1503 height_mm: config.publication.line_height_mm,
1504 alignment: config.publication.header_alignment,
1505 });
1506 }
1507 if let Some(text) = config.publication.footer_text.as_ref() {
1508 text_blocks.push(PublicationTextBlock {
1509 role: PublicationTextRole::Footer,
1510 text: text.clone(),
1511 x_mm: config.margin_left_mm + config.safe_left_mm,
1512 y_mm: page_height - config.margin_bottom_mm,
1513 width_mm: page_width
1514 - config.margin_left_mm
1515 - config.margin_right_mm
1516 - config.safe_left_mm
1517 - config.safe_right_mm,
1518 height_mm: config.publication.line_height_mm,
1519 alignment: config.publication.footer_alignment,
1520 });
1521 }
1522 if config.publication.page_number_in_footer {
1523 if let Some(page_number) = page_number {
1524 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
1525 let (page_width, page_height) =
1526 if matches!(config.orientation, PageOrientation::Landscape) {
1527 (paper_height, paper_width)
1528 } else {
1529 (paper_width, paper_height)
1530 };
1531 text_blocks.push(PublicationTextBlock {
1532 role: PublicationTextRole::Footer,
1533 text: page_number.to_string(),
1534 x_mm: config.margin_left_mm + config.safe_left_mm,
1535 y_mm: page_height - config.margin_bottom_mm,
1536 width_mm: page_width
1537 - config.margin_left_mm
1538 - config.margin_right_mm
1539 - config.safe_left_mm
1540 - config.safe_right_mm,
1541 height_mm: config.publication.line_height_mm,
1542 alignment: config.publication.footer_alignment,
1543 });
1544 }
1545 }
1546 if is_title_page {
1547 let content_height = page_height
1548 - config.margin_top_mm
1549 - config.margin_bottom_mm
1550 - config.safe_top_mm
1551 - config.safe_bottom_mm;
1552 let title_x = config.margin_left_mm + config.safe_left_mm;
1553 let title_width = page_width
1554 - config.margin_left_mm
1555 - config.margin_right_mm
1556 - config.safe_left_mm
1557 - config.safe_right_mm;
1558 let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
1559 if !metadata.title.trim().is_empty() {
1560 text_blocks.push(PublicationTextBlock {
1561 role: PublicationTextRole::Title,
1562 text: metadata.title.clone(),
1563 x_mm: title_x,
1564 y_mm: title_y,
1565 width_mm: title_width,
1566 height_mm: config.publication.line_height_mm,
1567 alignment: config.publication.title_alignment,
1568 });
1569 }
1570 if !metadata.movement_title.trim().is_empty() {
1571 text_blocks.push(PublicationTextBlock {
1572 role: PublicationTextRole::Subtitle,
1573 text: metadata.movement_title.clone(),
1574 x_mm: title_x,
1575 y_mm: title_y + config.publication.line_height_mm * 2.5,
1576 width_mm: title_width,
1577 height_mm: config.publication.line_height_mm,
1578 alignment: config.publication.title_alignment,
1579 });
1580 }
1581 let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
1582 (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
1583 format!("{composer} / {lyricist}")
1584 }
1585 (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
1586 (_, lyricist) => lyricist.to_string(),
1587 };
1588 if !credit.is_empty() {
1589 text_blocks.push(PublicationTextBlock {
1590 role: PublicationTextRole::Credit,
1591 text: credit,
1592 x_mm: title_x,
1593 y_mm: title_y + config.publication.line_height_mm * 5.0,
1594 width_mm: title_width,
1595 height_mm: config.publication.line_height_mm,
1596 alignment: config.publication.title_alignment,
1597 });
1598 }
1599 if !metadata.copyright.trim().is_empty() {
1600 text_blocks.push(PublicationTextBlock {
1601 role: PublicationTextRole::Copyright,
1602 text: metadata.copyright.clone(),
1603 x_mm: title_x,
1604 y_mm: page_height - config.margin_bottom_mm,
1605 width_mm: title_width,
1606 height_mm: config.publication.line_height_mm,
1607 alignment: config.publication.title_alignment,
1608 });
1609 }
1610 }
1611 PagePublication {
1612 is_title_page,
1613 title: metadata.title.clone(),
1614 movement_title: metadata.movement_title.clone(),
1615 composer: metadata.composer.clone(),
1616 lyricist: metadata.lyricist.clone(),
1617 copyright: metadata.copyright.clone(),
1618 running_title: config.publication.running_title.clone(),
1619 part_labels,
1620 part_groups,
1621 measure_numbers,
1622 text_blocks,
1623 }
1624}
1625
1626fn score_for_part_layout(
1627 score: &Score,
1628 policy: PartLayoutPolicy,
1629) -> Result<Score, PrintLayoutError> {
1630 let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
1631 return Ok(score.clone());
1632 };
1633 let Some(part) = score.parts.get(part_index) else {
1634 return Err(PrintLayoutError::InvalidPartIndex);
1635 };
1636 let mut selected = score.clone();
1637 selected.parts = vec![part.clone()];
1638 selected.part_groups.clear();
1639 Ok(selected)
1640}
1641
1642pub fn compute_print_layout(
1644 score: &Score,
1645 config: &PrintConfig,
1646) -> Result<PrintLayoutResult, PrintLayoutError> {
1647 let layout_score = score_for_part_layout(score, config.part_layout)?;
1648 let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
1649 if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
1650 return Err(PrintLayoutError::InvalidPaperDimensions);
1651 }
1652 if matches!(config.orientation, PageOrientation::Landscape) {
1653 std::mem::swap(&mut width_mm, &mut height_mm);
1654 }
1655
1656 let margins = [
1657 config.margin_top_mm,
1658 config.margin_right_mm,
1659 config.margin_bottom_mm,
1660 config.margin_left_mm,
1661 config.bleed_top_mm,
1662 config.bleed_right_mm,
1663 config.bleed_bottom_mm,
1664 config.bleed_left_mm,
1665 config.safe_top_mm,
1666 config.safe_right_mm,
1667 config.safe_bottom_mm,
1668 config.safe_left_mm,
1669 ];
1670 if margins
1671 .iter()
1672 .any(|value| !value.is_finite() || *value < 0.0)
1673 {
1674 return Err(PrintLayoutError::InvalidMargins);
1675 }
1676 if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
1677 return Err(PrintLayoutError::InvalidSystemHeight);
1678 }
1679 if !config.scale.is_finite() || config.scale <= 0.0 {
1680 return Err(PrintLayoutError::InvalidScale);
1681 }
1682 let scaled_system_height_mm = config.system_height_mm * config.scale;
1683 if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
1684 return Err(PrintLayoutError::InvalidScale);
1685 }
1686 if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
1687 return Err(PrintLayoutError::InvalidPublicationLineHeight);
1688 }
1689 if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
1690 {
1691 return Err(PrintLayoutError::InvalidGlyphResourceKey);
1692 }
1693 if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
1694 return Err(PrintLayoutError::InvalidPublicationLineHeight);
1695 }
1696
1697 let content_width_mm = width_mm
1698 - config.margin_left_mm
1699 - config.margin_right_mm
1700 - config.safe_left_mm
1701 - config.safe_right_mm;
1702 let content_height_mm = height_mm
1703 - config.margin_top_mm
1704 - config.margin_bottom_mm
1705 - config.safe_top_mm
1706 - config.safe_bottom_mm;
1707 if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
1708 return Err(PrintLayoutError::NoUsablePageArea);
1709 }
1710
1711 let systems_per_page = config
1712 .systems_per_page
1713 .unwrap_or_else(|| {
1714 (content_height_mm / scaled_system_height_mm)
1715 .floor()
1716 .max(1.0) as usize
1717 })
1718 .max(1);
1719 let layout = compute_layout(
1720 &layout_score,
1721 &LayoutConfig {
1722 measures_per_row: config.measures_per_system.max(1),
1723 first_row_measures: config.first_system_measures.or_else(|| {
1724 (matches!(
1725 config.pickup_policy,
1726 PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
1727 ) && has_first_measure_pickup(&layout_score))
1728 .then_some(1)
1729 }),
1730 ..LayoutConfig::default()
1731 },
1732 );
1733
1734 let mut keep_together = config.keep_together.clone();
1735 if matches!(
1736 config.notation_break_policy,
1737 NotationBreakPolicy::KeepVoltaTogether
1738 ) {
1739 keep_together.extend(volta_ranges(&layout_score));
1740 }
1741 let rows = apply_keep_together(
1742 &layout_score,
1743 layout.rows,
1744 &keep_together,
1745 config.measures_per_system.max(1),
1746 )?;
1747
1748 let has_explicit_page_break = rows.iter().any(|row| {
1749 row.measure_indices.last().is_some_and(|&measure_index| {
1750 layout_score
1751 .parts
1752 .iter()
1753 .flat_map(|part| part.staves.iter())
1754 .filter_map(|staff| staff.measures.get(measure_index))
1755 .any(|measure| measure.page_break)
1756 })
1757 });
1758 let repeat_system_ranges = if matches!(
1759 config.notation_break_policy,
1760 NotationBreakPolicy::KeepRepeatsTogether
1761 ) {
1762 repeat_system_ranges(&layout_score, &rows)
1763 } else {
1764 Vec::new()
1765 };
1766 if repeat_system_ranges
1767 .iter()
1768 .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
1769 {
1770 return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
1771 }
1772 let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
1773 && !has_explicit_page_break
1774 && systems_per_page > 1
1775 && rows.len() > systems_per_page
1776 && repeat_system_ranges.is_empty()
1777 {
1778 let page_count = rows.len().div_ceil(systems_per_page);
1779 let base = rows.len() / page_count;
1780 let remainder = rows.len() % page_count;
1781 (0..page_count)
1782 .map(|index| base + usize::from(index < remainder))
1783 .collect::<Vec<_>>()
1784 } else {
1785 Vec::new()
1786 };
1787
1788 let mut pages = Vec::new();
1789 let mut page_systems = Vec::new();
1790 let mut page_index = 0;
1791 for (system_index, row) in rows.iter().enumerate() {
1792 let repeat_starts_here = repeat_system_ranges
1793 .iter()
1794 .any(|(first, _)| *first == system_index);
1795 if repeat_starts_here && !page_systems.is_empty() {
1796 pages.push(PageLayout {
1797 address: PageAddress { page_index },
1798 page_index,
1799 page_number: match config.page_numbering {
1800 PageNumbering::None => None,
1801 PageNumbering::OneBased => Some(page_index + 1),
1802 },
1803 color_policy: config.color_policy,
1804 crop_mark_policy: config.crop_mark_policy,
1805 glyph_resources: config.glyph_resources.clone(),
1806 publication: page_publication(
1807 score,
1808 &layout_score,
1809 config,
1810 &page_systems,
1811 false,
1812 match config.page_numbering {
1813 PageNumbering::None => None,
1814 PageNumbering::OneBased => Some(page_index + 1),
1815 },
1816 ),
1817 width_mm,
1818 height_mm,
1819 content_width_mm,
1820 content_height_mm,
1821 bleed_top_mm: config.bleed_top_mm,
1822 bleed_right_mm: config.bleed_right_mm,
1823 bleed_bottom_mm: config.bleed_bottom_mm,
1824 bleed_left_mm: config.bleed_left_mm,
1825 span_segments: page_span_segments(&page_systems),
1826 measure_marks: page_measure_marks(&page_systems),
1827 systems: std::mem::take(&mut page_systems),
1828 break_reason: BreakReason::PageCapacity,
1829 });
1830 page_index += 1;
1831 }
1832 let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
1833 layout_score
1834 .parts
1835 .iter()
1836 .flat_map(|part| part.staves.iter())
1837 .filter_map(|staff| staff.measures.get(measure_index))
1838 .any(|measure| measure.page_break)
1839 });
1840 let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
1841 layout_score
1842 .parts
1843 .iter()
1844 .flat_map(|part| part.staves.iter())
1845 .filter_map(|staff| staff.measures.get(measure_index))
1846 .any(|measure| measure.system_break)
1847 });
1848 let is_last_system = system_index + 1 == rows.len();
1849 let break_reason = if explicit_page_break {
1850 BreakReason::ExplicitPageBreak
1851 } else if explicit_system_break {
1852 BreakReason::ExplicitSystemBreak
1853 } else if is_last_system {
1854 BreakReason::EndOfScore
1855 } else {
1856 BreakReason::MeasureCapacity
1857 };
1858 let system = SystemLayout {
1859 address: SystemAddress {
1860 system_index,
1861 page_index,
1862 index_on_page: page_systems.len(),
1863 },
1864 system_index,
1865 page_index,
1866 measure_indices: row.measure_indices.clone(),
1867 measure_spans: measure_spans(&layout_score, &row.measure_indices),
1868 span_segments: span_segments(&layout.spans, &row.measure_indices),
1869 measure_marks: measure_marks(&layout_score, &row.measure_indices),
1870 top_mm: config.margin_top_mm
1871 + config.safe_top_mm
1872 + page_systems.len() as f32 * scaled_system_height_mm,
1873 height_mm: scaled_system_height_mm,
1874 break_reason,
1875 };
1876 page_systems.push(system);
1877
1878 let page_capacity = page_capacities
1879 .get(page_index)
1880 .copied()
1881 .unwrap_or(systems_per_page);
1882 let page_is_full = page_systems.len() >= page_capacity;
1883 if page_is_full || explicit_page_break {
1884 let page_break_reason = if explicit_page_break {
1885 BreakReason::ExplicitPageBreak
1886 } else if is_last_system {
1887 BreakReason::EndOfScore
1888 } else {
1889 BreakReason::PageCapacity
1890 };
1891 pages.push(PageLayout {
1892 address: PageAddress { page_index },
1893 page_index,
1894 page_number: match config.page_numbering {
1895 PageNumbering::None => None,
1896 PageNumbering::OneBased => Some(page_index + 1),
1897 },
1898 color_policy: config.color_policy,
1899 crop_mark_policy: config.crop_mark_policy,
1900 glyph_resources: config.glyph_resources.clone(),
1901 publication: page_publication(
1902 score,
1903 &layout_score,
1904 config,
1905 &page_systems,
1906 false,
1907 match config.page_numbering {
1908 PageNumbering::None => None,
1909 PageNumbering::OneBased => Some(page_index + 1),
1910 },
1911 ),
1912 width_mm,
1913 height_mm,
1914 content_width_mm,
1915 content_height_mm,
1916 bleed_top_mm: config.bleed_top_mm,
1917 bleed_right_mm: config.bleed_right_mm,
1918 bleed_bottom_mm: config.bleed_bottom_mm,
1919 bleed_left_mm: config.bleed_left_mm,
1920 span_segments: page_span_segments(&page_systems),
1921 measure_marks: page_measure_marks(&page_systems),
1922 systems: std::mem::take(&mut page_systems),
1923 break_reason: page_break_reason,
1924 });
1925 page_index += 1;
1926 }
1927 }
1928 if !page_systems.is_empty() || pages.is_empty() {
1929 pages.push(PageLayout {
1930 address: PageAddress { page_index },
1931 page_index,
1932 page_number: match config.page_numbering {
1933 PageNumbering::None => None,
1934 PageNumbering::OneBased => Some(page_index + 1),
1935 },
1936 color_policy: config.color_policy,
1937 crop_mark_policy: config.crop_mark_policy,
1938 glyph_resources: config.glyph_resources.clone(),
1939 publication: page_publication(
1940 score,
1941 &layout_score,
1942 config,
1943 &page_systems,
1944 false,
1945 match config.page_numbering {
1946 PageNumbering::None => None,
1947 PageNumbering::OneBased => Some(page_index + 1),
1948 },
1949 ),
1950 width_mm,
1951 height_mm,
1952 content_width_mm,
1953 content_height_mm,
1954 bleed_top_mm: config.bleed_top_mm,
1955 bleed_right_mm: config.bleed_right_mm,
1956 bleed_bottom_mm: config.bleed_bottom_mm,
1957 bleed_left_mm: config.bleed_left_mm,
1958 span_segments: page_span_segments(&page_systems),
1959 measure_marks: page_measure_marks(&page_systems),
1960 systems: page_systems,
1961 break_reason: BreakReason::EndOfScore,
1962 });
1963 }
1964
1965 if config.publication.title_page {
1966 for page in &mut pages {
1967 page.page_index += 1;
1968 page.address.page_index = page.page_index;
1969 page.page_number = match config.page_numbering {
1970 PageNumbering::None => None,
1971 PageNumbering::OneBased => Some(page.page_index + 1),
1972 };
1973 for system in &mut page.systems {
1974 system.page_index += 1;
1975 system.address.page_index = system.page_index;
1976 }
1977 page.publication = page_publication(
1978 score,
1979 &layout_score,
1980 config,
1981 &page.systems,
1982 false,
1983 page.page_number,
1984 );
1985 }
1986 pages.insert(
1987 0,
1988 PageLayout {
1989 address: PageAddress { page_index: 0 },
1990 page_index: 0,
1991 page_number: match config.page_numbering {
1992 PageNumbering::None => None,
1993 PageNumbering::OneBased => Some(1),
1994 },
1995 color_policy: config.color_policy,
1996 crop_mark_policy: config.crop_mark_policy,
1997 glyph_resources: config.glyph_resources.clone(),
1998 publication: page_publication(
1999 score,
2000 &layout_score,
2001 config,
2002 &[],
2003 true,
2004 match config.page_numbering {
2005 PageNumbering::None => None,
2006 PageNumbering::OneBased => Some(1),
2007 },
2008 ),
2009 width_mm,
2010 height_mm,
2011 content_width_mm,
2012 content_height_mm,
2013 bleed_top_mm: config.bleed_top_mm,
2014 bleed_right_mm: config.bleed_right_mm,
2015 bleed_bottom_mm: config.bleed_bottom_mm,
2016 bleed_left_mm: config.bleed_left_mm,
2017 span_segments: Vec::new(),
2018 measure_marks: Vec::new(),
2019 systems: Vec::new(),
2020 break_reason: BreakReason::TitlePage,
2021 },
2022 );
2023 }
2024
2025 Ok(PrintLayoutResult {
2026 contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
2027 pages,
2028 })
2029}
2030
2031#[cfg(test)]
2032mod tests {
2033 use super::*;
2034 use acorde_core::{
2035 Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score, Staff, Step,
2036 };
2037
2038 fn score_with_measures(count: usize) -> Score {
2039 let mut score = Score::default();
2040 let mut part = Part::new("Piano", "Pno.");
2041 let mut staff = Staff::new(Clef::Treble);
2042 staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
2043 part.staves = vec![staff];
2044 score.parts = vec![part];
2045 score
2046 }
2047
2048 #[test]
2049 fn publication_metadata_accepts_legacy_partial_json() {
2050 let publication: PagePublication =
2051 serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
2052 .expect("legacy publication metadata should deserialize");
2053
2054 assert!(publication.is_title_page);
2055 assert_eq!(publication.title, "Legacy score");
2056 assert!(publication.movement_title.is_empty());
2057 assert!(publication.part_labels.is_empty());
2058 assert!(publication.part_groups.is_empty());
2059 assert!(publication.text_blocks.is_empty());
2060 }
2061
2062 #[test]
2063 fn layout_validation_rejects_unsupported_contract_version() {
2064 let score = score_with_measures(1);
2065 let mut result =
2066 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2067 result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
2068
2069 assert_eq!(
2070 result.validate(),
2071 Err(PrintLayoutError::UnsupportedContractVersion {
2072 found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
2073 })
2074 );
2075 }
2076
2077 #[test]
2078 fn paginates_rows_and_preserves_measure_indices() {
2079 let score = score_with_measures(5);
2080 let result = compute_print_layout(
2081 &score,
2082 &PrintConfig {
2083 measures_per_system: 2,
2084 systems_per_page: Some(2),
2085 ..PrintConfig::default()
2086 },
2087 )
2088 .expect("valid print config");
2089 assert_eq!(result.pages.len(), 2);
2090 assert_eq!(
2091 result.pages[0]
2092 .systems
2093 .iter()
2094 .map(|s| s.measure_indices.clone())
2095 .collect::<Vec<_>>(),
2096 vec![vec![0, 1], vec![2, 3]]
2097 );
2098 assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
2099 assert_eq!(result.pages[1].systems[0].page_index, 1);
2100 assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
2101 assert_eq!(
2102 result.pages[1].systems[0].break_reason,
2103 BreakReason::EndOfScore
2104 );
2105 assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
2106 }
2107
2108 #[test]
2109 fn forced_page_break_starts_next_system_on_next_page() {
2110 let mut score = score_with_measures(3);
2111 score.parts[0].staves[0].measures[0].page_break = true;
2112 let result = compute_print_layout(
2113 &score,
2114 &PrintConfig {
2115 measures_per_system: 3,
2116 systems_per_page: Some(8),
2117 ..PrintConfig::default()
2118 },
2119 )
2120 .expect("valid print config");
2121 assert_eq!(result.pages.len(), 2);
2122 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2123 assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
2124 assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
2125 assert_eq!(
2126 result.pages[0].systems[0].break_reason,
2127 BreakReason::ExplicitPageBreak
2128 );
2129 }
2130
2131 #[test]
2132 fn keep_together_range_is_not_split_across_systems() {
2133 let score = score_with_measures(5);
2134 let result = compute_print_layout(
2135 &score,
2136 &PrintConfig {
2137 measures_per_system: 3,
2138 systems_per_page: Some(8),
2139 keep_together: vec![KeepTogetherRange {
2140 first_measure: 1,
2141 last_measure: 2,
2142 }],
2143 ..PrintConfig::default()
2144 },
2145 )
2146 .expect("valid keep-together range");
2147 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2148 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2149 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
2150 }
2151
2152 #[test]
2153 fn first_system_measure_capacity_is_preserved_in_print_layout() {
2154 let score = score_with_measures(5);
2155 let result = compute_print_layout(
2156 &score,
2157 &PrintConfig {
2158 measures_per_system: 3,
2159 first_system_measures: Some(1),
2160 systems_per_page: Some(8),
2161 ..PrintConfig::default()
2162 },
2163 )
2164 .expect("valid first-system capacity");
2165 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2166 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2167 assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
2168 }
2169
2170 #[test]
2171 fn pickup_policy_isolates_a_partial_first_measure() {
2172 let mut score = score_with_measures(4);
2173 score.parts[0].staves[0].measures[0].voices[0] =
2174 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2175 let result = compute_print_layout(
2176 &score,
2177 &PrintConfig {
2178 measures_per_system: 3,
2179 pickup_policy: PickupPolicy::DetectFirstMeasure,
2180 systems_per_page: Some(8),
2181 ..PrintConfig::default()
2182 },
2183 )
2184 .expect("valid pickup policy");
2185 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2186 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2187 }
2188
2189 #[test]
2190 fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
2191 let mut score = score_with_measures(4);
2192 score.parts[0].staves[0].measures[0].voices[0] =
2193 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
2194 let result = compute_print_layout(
2195 &score,
2196 &PrintConfig {
2197 measures_per_system: 3,
2198 systems_per_page: Some(8),
2199 ..PrintConfig::default()
2200 },
2201 )
2202 .expect("valid automatic pickup policy");
2203 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2204 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
2205 }
2206
2207 #[test]
2208 fn system_exposes_physical_span_for_multi_rest_slot() {
2209 let mut score = score_with_measures(6);
2210 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2211 let result = compute_print_layout(&score, &PrintConfig::default())
2212 .expect("valid multi-rest print layout");
2213 assert_eq!(
2214 result.pages[0].systems[0].measure_spans[1],
2215 MeasureSpan {
2216 first_measure: 1,
2217 last_measure: 3,
2218 }
2219 );
2220 }
2221
2222 #[test]
2223 fn multirest_width_drives_system_breaking_without_splitting() {
2224 let mut score = score_with_measures(5);
2225 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
2226 let result = compute_print_layout(
2227 &score,
2228 &PrintConfig {
2229 measures_per_system: 2,
2230 pickup_policy: PickupPolicy::Preserve,
2231 systems_per_page: Some(8),
2232 ..PrintConfig::default()
2233 },
2234 )
2235 .expect("valid multi-rest pagination");
2236 assert_eq!(
2237 result.pages[0]
2238 .systems
2239 .iter()
2240 .map(|system| system.measure_indices.clone())
2241 .collect::<Vec<_>>(),
2242 vec![vec![0], vec![1], vec![2, 3], vec![4]]
2243 );
2244 assert_eq!(
2245 result.pages[0].systems[1].measure_spans[0],
2246 MeasureSpan {
2247 first_measure: 1,
2248 last_measure: 3,
2249 }
2250 );
2251 }
2252
2253 #[test]
2254 fn system_exposes_cross_system_span_segments() {
2255 let mut score = score_with_measures(4);
2256 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2257 start.slur_start = true;
2258 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2259 end.slur_end = true;
2260 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2261 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2262 let result = compute_print_layout(
2263 &score,
2264 &PrintConfig {
2265 measures_per_system: 2,
2266 pickup_policy: PickupPolicy::Preserve,
2267 systems_per_page: Some(8),
2268 ..PrintConfig::default()
2269 },
2270 )
2271 .expect("valid cross-system span layout");
2272 assert_eq!(
2273 result.pages[0].systems[0].span_segments,
2274 vec![SpanSegment {
2275 span_index: 0,
2276 starts_here: true,
2277 ends_here: false,
2278 }]
2279 );
2280 assert_eq!(
2281 result.pages[0].systems[1].span_segments,
2282 vec![SpanSegment {
2283 span_index: 0,
2284 starts_here: false,
2285 ends_here: true,
2286 }]
2287 );
2288 }
2289
2290 #[test]
2291 fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
2292 let mut score = score_with_measures(4);
2293 let measures = &mut score.parts[0].staves[0].measures;
2294 measures[0].barline_right = Barline::RepeatEnd;
2295 measures[1].barline_left = Barline::RepeatStart;
2296 measures[2].volta = Some(acorde_core::VoltaBracket {
2297 number: 1,
2298 kind: "begin".to_string(),
2299 });
2300 measures[2].navigation = Some("ToCoda".to_string());
2301 measures[2].rehearsal = Some("B".to_string());
2302 let result = compute_print_layout(
2303 &score,
2304 &PrintConfig {
2305 measures_per_system: 2,
2306 systems_per_page: Some(8),
2307 ..PrintConfig::default()
2308 },
2309 )
2310 .expect("valid measure mark layout");
2311 assert_eq!(
2312 result.pages[0].systems[0].measure_marks,
2313 vec![
2314 MeasureMark {
2315 measure_index: 0,
2316 repeat_start: false,
2317 repeat_end: true,
2318 volta_number: None,
2319 volta_kind: None,
2320 navigation: None,
2321 rehearsal: None,
2322 },
2323 MeasureMark {
2324 measure_index: 1,
2325 repeat_start: true,
2326 repeat_end: false,
2327 volta_number: None,
2328 volta_kind: None,
2329 navigation: None,
2330 rehearsal: None,
2331 },
2332 ]
2333 );
2334 assert_eq!(
2335 result.pages[0].systems[1].measure_marks,
2336 vec![MeasureMark {
2337 measure_index: 2,
2338 repeat_start: false,
2339 repeat_end: false,
2340 volta_number: Some(1),
2341 volta_kind: Some("begin".to_string()),
2342 navigation: Some("ToCoda".to_string()),
2343 rehearsal: Some("B".to_string()),
2344 }]
2345 );
2346 }
2347
2348 #[test]
2349 fn page_aggregates_cross_system_span_ownership() {
2350 let mut score = score_with_measures(4);
2351 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2352 start.slur_start = true;
2353 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2354 end.slur_end = true;
2355 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2356 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2357 let result = compute_print_layout(
2358 &score,
2359 &PrintConfig {
2360 measures_per_system: 2,
2361 pickup_policy: PickupPolicy::Preserve,
2362 systems_per_page: Some(1),
2363 ..PrintConfig::default()
2364 },
2365 )
2366 .expect("valid page span layout");
2367 assert_eq!(
2368 result.pages[0].span_segments,
2369 vec![PageSpanSegment {
2370 span_index: 0,
2371 starts_here: true,
2372 ends_here: false,
2373 }]
2374 );
2375 assert_eq!(
2376 result.pages[1].span_segments,
2377 vec![PageSpanSegment {
2378 span_index: 0,
2379 starts_here: false,
2380 ends_here: true,
2381 }]
2382 );
2383 }
2384
2385 #[test]
2386 fn page_artifact_measure_span_borrows_system_spans() {
2387 let mut score = score_with_measures(4);
2388 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2389 start.slur_start = true;
2390 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2391 end.slur_end = true;
2392 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2393 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2394 let result = compute_print_layout(
2395 &score,
2396 &PrintConfig {
2397 measures_per_system: 2,
2398 pickup_policy: PickupPolicy::Preserve,
2399 systems_per_page: Some(1),
2400 ..PrintConfig::default()
2401 },
2402 )
2403 .expect("valid page artifact");
2404 let first = result
2405 .page(PageAddress { page_index: 0 })
2406 .expect("first page");
2407 assert_eq!(
2408 first.measure_span(),
2409 Some(MeasureSpan {
2410 first_measure: 0,
2411 last_measure: 1,
2412 })
2413 );
2414 assert!(first.has_span_continuation());
2415 assert!(result.page(PageAddress { page_index: 99 }).is_none());
2416 assert!(result.validate().is_ok());
2417 }
2418
2419 #[test]
2420 fn export_page_artifacts_reports_host_glyph_resource_requirement() {
2421 let result = compute_print_layout(
2422 &score_with_measures(1),
2423 &PrintConfig {
2424 glyph_resources: GlyphResourcePolicy::HostProvided("licensed-font-v1".into()),
2425 ..PrintConfig::default()
2426 },
2427 )
2428 .expect("valid host resource policy");
2429
2430 let artifacts = result
2431 .export_page_artifacts()
2432 .expect("host resource requirement is a diagnostic");
2433 assert_eq!(
2434 artifacts[0].diagnostics,
2435 vec![PageArtifactDiagnostic::GlyphResourceRequired]
2436 );
2437 assert_eq!(
2438 artifacts[0].layout.glyph_resources,
2439 GlyphResourcePolicy::HostProvided("licensed-font-v1".into())
2440 );
2441 }
2442
2443 #[test]
2444 fn page_artifact_diagnostics_report_glyph_overflow_sides() {
2445 let result = compute_print_layout(&score_with_measures(1), &PrintConfig::default())
2446 .expect("valid print layout");
2447 let page = &result.pages[0];
2448 assert_eq!(
2449 page.artifact_diagnostics(Some(GlyphExtents {
2450 left_mm: -1.0,
2451 top_mm: -2.0,
2452 right_mm: page.content_width_mm + 3.0,
2453 bottom_mm: page.content_height_mm + 4.0,
2454 })),
2455 vec![PageArtifactDiagnostic::GlyphOverflow {
2456 left: true,
2457 top: true,
2458 right: true,
2459 bottom: true,
2460 }]
2461 );
2462 assert!(page.artifact_diagnostics(None).is_empty());
2463 }
2464
2465 #[test]
2466 fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
2467 let mut score = score_with_measures(4);
2468 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
2469 start.slur_start = true;
2470 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
2471 end.slur_end = true;
2472 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
2473 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
2474 let result = compute_print_layout(
2475 &score,
2476 &PrintConfig {
2477 measures_per_system: 2,
2478 pickup_policy: PickupPolicy::Preserve,
2479 systems_per_page: Some(1),
2480 ..PrintConfig::default()
2481 },
2482 )
2483 .expect("valid print config");
2484
2485 let artifacts = result
2486 .export_page_artifacts()
2487 .expect("valid page artifacts");
2488 assert_eq!(artifacts.len(), 2);
2489 assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
2490 assert_eq!(artifacts[1].page_index, 1);
2491 assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
2492 assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
2493 assert_eq!(
2494 artifacts[0].measure_span,
2495 Some(MeasureSpan {
2496 first_measure: 0,
2497 last_measure: 1,
2498 })
2499 );
2500 assert_eq!(
2501 artifacts[0].diagnostics,
2502 vec![PageArtifactDiagnostic::SpanContinuation {
2503 span_index: 0,
2504 starts_here: true,
2505 ends_here: false,
2506 }]
2507 );
2508 assert_eq!(
2509 artifacts[1].diagnostics,
2510 vec![PageArtifactDiagnostic::SpanContinuation {
2511 span_index: 0,
2512 starts_here: false,
2513 ends_here: true,
2514 }]
2515 );
2516 }
2517
2518 #[test]
2519 fn export_page_artifacts_rejects_invalid_serialized_layout() {
2520 let score = score_with_measures(1);
2521 let mut result =
2522 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2523 result.pages[0].width_mm = f32::NAN;
2524
2525 assert!(matches!(
2526 result.export_page_artifacts(),
2527 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2528 ));
2529 }
2530
2531 #[test]
2532 fn page_lookup_rejects_mismatched_serialized_address() {
2533 let score = score_with_measures(1);
2534 let mut result =
2535 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2536 result.pages[0].address = PageAddress { page_index: 7 };
2537
2538 assert!(result.page(PageAddress { page_index: 0 }).is_none());
2539 assert_eq!(
2540 result.validate(),
2541 Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
2542 );
2543 }
2544
2545 #[test]
2546 fn layout_validation_rejects_mismatched_system_address() {
2547 let score = score_with_measures(2);
2548 let mut result = compute_print_layout(
2549 &score,
2550 &PrintConfig {
2551 measures_per_system: 1,
2552 ..PrintConfig::default()
2553 },
2554 )
2555 .expect("valid print config");
2556 result.pages[0].systems[0].address.index_on_page = 4;
2557
2558 assert_eq!(
2559 result.validate(),
2560 Err(PrintLayoutError::InvalidSystemAddress {
2561 page_index: 0,
2562 index_on_page: 0,
2563 system_index: 0,
2564 })
2565 );
2566 }
2567
2568 #[test]
2569 fn layout_validation_rejects_non_monotonic_page_number() {
2570 let score = score_with_measures(2);
2571 let mut result = compute_print_layout(
2572 &score,
2573 &PrintConfig {
2574 measures_per_system: 1,
2575 page_numbering: PageNumbering::OneBased,
2576 systems_per_page: Some(1),
2577 ..PrintConfig::default()
2578 },
2579 )
2580 .expect("valid print config");
2581 result.pages[1].page_number = Some(1);
2582
2583 assert_eq!(
2584 result.validate(),
2585 Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
2586 );
2587 }
2588
2589 #[test]
2590 fn layout_validation_rejects_inconsistent_title_page_metadata() {
2591 let score = score_with_measures(1);
2592 let mut result =
2593 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2594 result.pages[0].publication.is_title_page = true;
2595
2596 assert_eq!(
2597 result.validate(),
2598 Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
2599 );
2600 }
2601
2602 #[test]
2603 fn layout_validation_rejects_non_finite_page_geometry() {
2604 let score = score_with_measures(1);
2605 let mut result =
2606 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2607 result.pages[0].width_mm = f32::NAN;
2608
2609 assert_eq!(
2610 result.validate(),
2611 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2612 );
2613 }
2614
2615 #[test]
2616 fn layout_validation_rejects_non_positive_system_geometry() {
2617 let score = score_with_measures(1);
2618 let mut result =
2619 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2620 result.pages[0].systems[0].height_mm = 0.0;
2621
2622 assert_eq!(
2623 result.validate(),
2624 Err(PrintLayoutError::InvalidSystemGeometry {
2625 page_index: 0,
2626 index_on_page: 0,
2627 })
2628 );
2629 }
2630
2631 #[test]
2632 fn layout_validation_rejects_content_larger_than_page() {
2633 let score = score_with_measures(1);
2634 let mut result =
2635 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
2636 result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
2637
2638 assert_eq!(
2639 result.validate(),
2640 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
2641 );
2642 }
2643
2644 #[test]
2645 fn notation_policy_keeps_volta_range_in_one_system() {
2646 let mut score = score_with_measures(4);
2647 score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
2648 number: 1,
2649 kind: "begin".to_string(),
2650 });
2651 score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
2652 number: 1,
2653 kind: "end".to_string(),
2654 });
2655 let result = compute_print_layout(
2656 &score,
2657 &PrintConfig {
2658 measures_per_system: 2,
2659 systems_per_page: Some(8),
2660 notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
2661 ..PrintConfig::default()
2662 },
2663 )
2664 .expect("valid volta-preserving layout");
2665 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
2666 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
2667 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
2668 }
2669
2670 #[test]
2671 fn notation_policy_keeps_repeat_section_on_one_page() {
2672 let mut score = score_with_measures(5);
2673 score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
2674 score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
2675 let result = compute_print_layout(
2676 &score,
2677 &PrintConfig {
2678 measures_per_system: 2,
2679 systems_per_page: Some(2),
2680 notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
2681 ..PrintConfig::default()
2682 },
2683 )
2684 .expect("valid repeat-preserving layout");
2685 assert_eq!(result.pages[0].systems.len(), 1);
2686 assert_eq!(result.pages[1].systems.len(), 2);
2687 assert_eq!(
2688 result.pages[1]
2689 .systems
2690 .iter()
2691 .flat_map(|system| system.measure_indices.iter().copied())
2692 .collect::<Vec<_>>(),
2693 vec![2, 3, 4]
2694 );
2695 }
2696
2697 #[test]
2698 fn balance_policy_avoids_single_system_final_page() {
2699 let score = score_with_measures(5);
2700 let result = compute_print_layout(
2701 &score,
2702 &PrintConfig {
2703 measures_per_system: 1,
2704 systems_per_page: Some(4),
2705 final_page_policy: FinalPagePolicy::Balance,
2706 ..PrintConfig::default()
2707 },
2708 )
2709 .expect("valid balanced print config");
2710 assert_eq!(result.pages.len(), 2);
2711 assert_eq!(result.pages[0].systems.len(), 3);
2712 assert_eq!(result.pages[1].systems.len(), 2);
2713 }
2714
2715 #[test]
2716 fn balance_policy_preserves_explicit_page_breaks() {
2717 let mut score = score_with_measures(5);
2718 score.parts[0].staves[0].measures[1].page_break = true;
2719 let result = compute_print_layout(
2720 &score,
2721 &PrintConfig {
2722 measures_per_system: 1,
2723 systems_per_page: Some(4),
2724 final_page_policy: FinalPagePolicy::Balance,
2725 ..PrintConfig::default()
2726 },
2727 )
2728 .expect("valid explicit-break print config");
2729 assert_eq!(result.pages[0].systems.len(), 2);
2730 assert_eq!(result.pages[1].systems.len(), 3);
2731 }
2732
2733 #[test]
2734 fn keep_together_rejects_ranges_larger_than_system_capacity() {
2735 let score = score_with_measures(4);
2736 let error = compute_print_layout(
2737 &score,
2738 &PrintConfig {
2739 measures_per_system: 2,
2740 keep_together: vec![KeepTogetherRange {
2741 first_measure: 0,
2742 last_measure: 2,
2743 }],
2744 ..PrintConfig::default()
2745 },
2746 )
2747 .expect_err("range must fit in one system");
2748 assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
2749 }
2750
2751 #[test]
2752 fn keep_together_rejects_explicit_break_inside_range() {
2753 let mut score = score_with_measures(4);
2754 score.parts[0].staves[0].measures[1].system_break = true;
2755 let error = compute_print_layout(
2756 &score,
2757 &PrintConfig {
2758 measures_per_system: 3,
2759 keep_together: vec![KeepTogetherRange {
2760 first_measure: 0,
2761 last_measure: 2,
2762 }],
2763 ..PrintConfig::default()
2764 },
2765 )
2766 .expect_err("explicit break must win");
2767 assert_eq!(
2768 error,
2769 PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
2770 );
2771 }
2772
2773 #[test]
2774 fn rejects_margins_that_leave_no_page_area() {
2775 let score = score_with_measures(1);
2776 let error = compute_print_layout(
2777 &score,
2778 &PrintConfig {
2779 margin_left_mm: 200.0,
2780 ..PrintConfig::default()
2781 },
2782 )
2783 .expect_err("invalid page area");
2784 assert_eq!(error, PrintLayoutError::NoUsablePageArea);
2785 }
2786
2787 #[test]
2788 fn safe_area_reduces_content_and_bleed_is_exposed() {
2789 let score = score_with_measures(1);
2790 let result = compute_print_layout(
2791 &score,
2792 &PrintConfig {
2793 bleed_top_mm: 3.0,
2794 bleed_right_mm: 3.0,
2795 bleed_bottom_mm: 3.0,
2796 bleed_left_mm: 3.0,
2797 safe_top_mm: 5.0,
2798 safe_right_mm: 6.0,
2799 safe_bottom_mm: 7.0,
2800 safe_left_mm: 8.0,
2801 ..PrintConfig::default()
2802 },
2803 )
2804 .expect("valid print config");
2805 let page = &result.pages[0];
2806 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
2807 assert_eq!(page.bleed_left_mm, 3.0);
2808 assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
2809 assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
2810 assert_eq!(page.systems[0].top_mm, 21.0);
2811 }
2812
2813 #[test]
2814 fn scale_changes_system_height_and_page_capacity() {
2815 let score = score_with_measures(10);
2816 let result = compute_print_layout(
2817 &score,
2818 &PrintConfig {
2819 scale: 2.0,
2820 measures_per_system: 1,
2821 systems_per_page: None,
2822 ..PrintConfig::default()
2823 },
2824 )
2825 .expect("valid print config");
2826 assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
2827 assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
2828 assert_eq!(result.pages.len(), 2);
2829 }
2830
2831 #[test]
2832 fn rejects_non_positive_scale() {
2833 let score = score_with_measures(1);
2834 let error = compute_print_layout(
2835 &score,
2836 &PrintConfig {
2837 scale: 0.0,
2838 ..PrintConfig::default()
2839 },
2840 )
2841 .expect_err("invalid scale");
2842 assert_eq!(error, PrintLayoutError::InvalidScale);
2843 }
2844
2845 #[test]
2846 fn page_numbering_is_configurable() {
2847 let score = score_with_measures(5);
2848 let numbered = compute_print_layout(
2849 &score,
2850 &PrintConfig {
2851 measures_per_system: 1,
2852 systems_per_page: Some(2),
2853 ..PrintConfig::default()
2854 },
2855 )
2856 .expect("valid print config");
2857 assert_eq!(numbered.pages[0].page_number, Some(1));
2858 assert_eq!(numbered.pages[1].page_number, Some(2));
2859
2860 let unnumbered = compute_print_layout(
2861 &score,
2862 &PrintConfig {
2863 page_numbering: PageNumbering::None,
2864 measures_per_system: 1,
2865 systems_per_page: Some(2),
2866 ..PrintConfig::default()
2867 },
2868 )
2869 .expect("valid print config");
2870 assert!(
2871 unnumbered
2872 .pages
2873 .iter()
2874 .all(|page| page.page_number.is_none())
2875 );
2876 }
2877
2878 #[test]
2879 fn rejects_invalid_publication_line_height() {
2880 let score = score_with_measures(1);
2881 let error = compute_print_layout(
2882 &score,
2883 &PrintConfig {
2884 publication: PublicationConfig {
2885 line_height_mm: 0.0,
2886 ..PublicationConfig::default()
2887 },
2888 ..PrintConfig::default()
2889 },
2890 )
2891 .expect_err("invalid publication line height");
2892 assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
2893 }
2894
2895 #[test]
2896 fn rejects_empty_host_glyph_resource_key() {
2897 let score = score_with_measures(1);
2898 let error = compute_print_layout(
2899 &score,
2900 &PrintConfig {
2901 glyph_resources: GlyphResourcePolicy::HostProvided(" ".into()),
2902 ..PrintConfig::default()
2903 },
2904 )
2905 .expect_err("empty host resource key");
2906 assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
2907 }
2908
2909 #[test]
2910 fn print_color_and_crop_policies_are_exposed_per_page() {
2911 let score = score_with_measures(1);
2912 let result = compute_print_layout(
2913 &score,
2914 &PrintConfig {
2915 color_policy: PrintColorPolicy::Preserve,
2916 crop_mark_policy: CropMarkPolicy::BleedEdges,
2917 ..PrintConfig::default()
2918 },
2919 )
2920 .expect("valid print config");
2921 let page = &result.pages[0];
2922 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
2923 assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
2924 assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
2925 }
2926
2927 #[test]
2928 fn glyph_resource_policy_is_exposed_per_page() {
2929 let score = score_with_measures(1);
2930 let result = compute_print_layout(
2931 &score,
2932 &PrintConfig {
2933 glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
2934 ..PrintConfig::default()
2935 },
2936 )
2937 .expect("valid print config");
2938 assert_eq!(
2939 result.pages[0].glyph_resources,
2940 GlyphResourcePolicy::HostProvided("music-font-v1".into())
2941 );
2942 }
2943
2944 #[test]
2945 fn publication_metadata_is_deterministic_and_page_scoped() {
2946 let mut score = score_with_measures(3);
2947 score.metadata.title = "Suite".into();
2948 score.metadata.movement_title = "I. Prelude".into();
2949 score.metadata.composer = "Composer".into();
2950 score.metadata.copyright = "© 2026 Composer".into();
2951 score.metadata.lyricist = "Lyricist".into();
2952 score.metadata.copyright = "Copyright".into();
2953 score.parts.push(Part::new("Strings", "Str."));
2954 score.part_groups.push(PartGroup {
2955 first_part: 0,
2956 last_part: 1,
2957 symbol: PartGroupSymbol::Bracket,
2958 barlines_connect: true,
2959 });
2960 for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
2961 measure.number = (index + 1) as u32;
2962 }
2963 let result = compute_print_layout(
2964 &score,
2965 &PrintConfig {
2966 measures_per_system: 2,
2967 systems_per_page: Some(1),
2968 publication: PublicationConfig {
2969 running_title: Some("Suite — Composer".into()),
2970 header_text: Some("Suite".into()),
2971 footer_text: Some("Copyright".into()),
2972 page_number_in_footer: true,
2973 header_alignment: PublicationTextAlignment::Center,
2974 footer_alignment: PublicationTextAlignment::Right,
2975 ..PublicationConfig::default()
2976 },
2977 ..PrintConfig::default()
2978 },
2979 )
2980 .expect("valid print config");
2981 assert_eq!(result.pages[0].publication.title, "Suite");
2982 assert_eq!(
2983 result.pages[0].publication.running_title.as_deref(),
2984 Some("Suite — Composer")
2985 );
2986 assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
2987 assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
2988 assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
2989 assert_eq!(result.pages[0].publication.part_groups.len(), 1);
2990 assert_eq!(
2991 result.pages[0].publication.part_groups[0].symbol,
2992 PartGroupSymbol::Bracket
2993 );
2994 assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
2995 assert_eq!(
2996 result.pages[0].publication.text_blocks[0].role,
2997 PublicationTextRole::Header
2998 );
2999 assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
3000 assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
3001 assert_eq!(
3002 result.pages[0].publication.text_blocks[1].role,
3003 PublicationTextRole::Footer
3004 );
3005 assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
3006 assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
3007 assert_eq!(
3008 result.pages[0].publication.text_blocks[0].alignment,
3009 PublicationTextAlignment::Center
3010 );
3011 assert_eq!(
3012 result.pages[0].publication.text_blocks[1].alignment,
3013 PublicationTextAlignment::Right
3014 );
3015 let artifacts = result
3016 .export_page_artifacts()
3017 .expect("publication pages export without host resources");
3018 assert_eq!(artifacts.len(), result.pages.len());
3019 assert_eq!(artifacts[0].layout.publication, result.pages[0].publication);
3020 assert!(
3021 artifacts
3022 .iter()
3023 .all(|artifact| artifact.diagnostics.is_empty())
3024 );
3025 }
3026
3027 #[test]
3028 fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
3029 let mut score = score_with_measures(2);
3030 let mut part = Part::new("Flute", "Fl.");
3031 let mut staff = Staff::new(Clef::Treble);
3032 staff.measures = vec![Measure::empty(4, 4); 5];
3033 part.staves = vec![staff];
3034 score.parts.push(part);
3035
3036 let extracted = compute_print_layout(
3037 &score,
3038 &PrintConfig {
3039 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
3040 measures_per_system: 2,
3041 systems_per_page: Some(1),
3042 ..PrintConfig::default()
3043 },
3044 )
3045 .expect("valid extracted part");
3046 assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
3047 assert_eq!(extracted.pages.len(), 3);
3048 assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
3049
3050 let error = compute_print_layout(
3051 &score,
3052 &PrintConfig {
3053 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
3054 ..PrintConfig::default()
3055 },
3056 )
3057 .expect_err("missing extracted part");
3058 assert_eq!(error, PrintLayoutError::InvalidPartIndex);
3059 }
3060
3061 #[test]
3062 fn title_page_is_inserted_without_consuming_music_page_capacity() {
3063 let mut score = score_with_measures(3);
3064 score.metadata.title = "Suite".into();
3065 score.metadata.movement_title = "I. Prelude".into();
3066 score.metadata.composer = "Composer".into();
3067 score.metadata.copyright = "© 2026 Composer".into();
3068 let result = compute_print_layout(
3069 &score,
3070 &PrintConfig {
3071 systems_per_page: Some(1),
3072 measures_per_system: 2,
3073 publication: PublicationConfig {
3074 title_page: true,
3075 ..PublicationConfig::default()
3076 },
3077 ..PrintConfig::default()
3078 },
3079 )
3080 .expect("valid title page config");
3081 assert_eq!(result.pages.len(), 3);
3082 assert!(result.pages[0].systems.is_empty());
3083 assert!(result.pages[0].publication.is_title_page);
3084 assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
3085 assert_eq!(result.pages[0].page_number, Some(1));
3086 assert_eq!(result.pages[1].page_number, Some(2));
3087 assert_eq!(result.pages[1].systems[0].page_index, 1);
3088 assert!(!result.pages[1].publication.is_title_page);
3089 assert!(result.validate().is_ok());
3090 assert_eq!(
3091 result.pages[0]
3092 .publication
3093 .text_blocks
3094 .iter()
3095 .map(|block| block.role)
3096 .collect::<Vec<_>>(),
3097 vec![
3098 PublicationTextRole::Title,
3099 PublicationTextRole::Subtitle,
3100 PublicationTextRole::Credit,
3101 PublicationTextRole::Copyright
3102 ]
3103 );
3104 }
3105
3106 #[test]
3107 fn print_presets_are_versioned_and_select_the_expected_scope() {
3108 assert_eq!(PrintPreset::A4Score.schema_version(), 1);
3109 assert_eq!(
3110 PrintPreset::A4Score.config().part_layout,
3111 PartLayoutPolicy::FullScore
3112 );
3113 assert_eq!(
3114 PrintPreset::LetterPart { part_index: 2 }
3115 .config()
3116 .part_layout,
3117 PartLayoutPolicy::ExtractedPart { part_index: 2 }
3118 );
3119 assert_eq!(
3120 PrintPreset::LetterScore.config().paper_size,
3121 PaperSize::Letter
3122 );
3123 assert!(
3124 PrintPreset::A4Score
3125 .config_with_title_page(true)
3126 .publication
3127 .title_page
3128 );
3129 assert!(!PrintPreset::A4Score.config().publication.title_page);
3130 assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 1);
3131 }
3132
3133 #[test]
3134 fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
3135 let metrics = GlyphMetrics {
3136 advance_mm: 4.0,
3137 left_mm: -1.0,
3138 top_mm: -2.0,
3139 width_mm: 2.0,
3140 height_mm: 4.0,
3141 };
3142 let mut placements = vec![
3143 GlyphPlacement {
3144 resource_key: "high".into(),
3145 metrics,
3146 x_mm: 10.0,
3147 y_mm: 20.0,
3148 priority: 10,
3149 },
3150 GlyphPlacement {
3151 resource_key: "low".into(),
3152 metrics,
3153 x_mm: 10.0,
3154 y_mm: 20.0,
3155 priority: 1,
3156 },
3157 ];
3158 let moved = resolve_glyph_collisions(&mut placements, 1.0);
3159 assert_eq!(moved, 1);
3160 assert_eq!(placements[0].y_mm, 20.0);
3161 assert_eq!(placements[1].y_mm, 25.0);
3162 }
3163
3164 #[test]
3165 fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
3166 let metrics = GlyphMetrics {
3167 advance_mm: 4.0,
3168 left_mm: -1.0,
3169 top_mm: -1.0,
3170 width_mm: 2.0,
3171 height_mm: 2.0,
3172 };
3173 let mut placements = vec![
3174 GlyphPlacement {
3175 resource_key: "high".into(),
3176 metrics,
3177 x_mm: 10.0,
3178 y_mm: 20.0,
3179 priority: 10,
3180 },
3181 GlyphPlacement {
3182 resource_key: "low".into(),
3183 metrics,
3184 x_mm: 10.0,
3185 y_mm: 0.0,
3186 priority: 1,
3187 },
3188 ];
3189 assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
3190 assert_eq!(placements[1].y_mm, 0.0);
3191 }
3192
3193 #[test]
3194 fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
3195 let mut placements = vec![GlyphPlacement {
3196 resource_key: "test".into(),
3197 metrics: GlyphMetrics {
3198 advance_mm: 1.0,
3199 left_mm: 0.0,
3200 top_mm: 0.0,
3201 width_mm: 1.0,
3202 height_mm: 1.0,
3203 },
3204 x_mm: 0.0,
3205 y_mm: 0.0,
3206 priority: 0,
3207 }];
3208 assert_eq!(validate_glyph_placements(&placements), Ok(()));
3209 placements[0].x_mm = f32::NAN;
3210 assert_eq!(
3211 validate_glyph_placements(&placements),
3212 Err(GlyphPlacementError::NonFinite { index: 0 })
3213 );
3214 placements[0].x_mm = 0.0;
3215 placements[0].metrics.width_mm = -1.0;
3216 assert_eq!(
3217 validate_glyph_placements(&placements),
3218 Err(GlyphPlacementError::NegativeExtent { index: 0 })
3219 );
3220 }
3221
3222 #[test]
3223 fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
3224 let metrics = GlyphMetrics {
3225 advance_mm: 4.0,
3226 left_mm: -1.0,
3227 top_mm: -1.0,
3228 width_mm: 2.0,
3229 height_mm: 2.0,
3230 };
3231 let mut placements = vec![
3232 GlyphPlacement {
3233 resource_key: "high".into(),
3234 metrics,
3235 x_mm: 10.0,
3236 y_mm: 20.0,
3237 priority: 10,
3238 },
3239 GlyphPlacement {
3240 resource_key: "low".into(),
3241 metrics,
3242 x_mm: 10.0,
3243 y_mm: 20.0,
3244 priority: 1,
3245 },
3246 GlyphPlacement {
3247 resource_key: "far".into(),
3248 metrics,
3249 x_mm: 10.0,
3250 y_mm: 30.0,
3251 priority: 1,
3252 },
3253 ];
3254 assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
3255 assert_eq!(placements[0].x_mm, 10.0);
3256 assert_eq!(placements[1].x_mm, 13.0);
3257 assert_eq!(placements[2].x_mm, 10.0);
3258 }
3259
3260 #[test]
3261 fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
3262 let metrics = GlyphMetrics {
3263 advance_mm: 1.0,
3264 left_mm: 0.0,
3265 top_mm: 0.0,
3266 width_mm: 1.0,
3267 height_mm: 1.0,
3268 };
3269 let mut placements = vec![
3270 GlyphPlacement {
3271 resource_key: "second".into(),
3272 metrics,
3273 x_mm: 20.0,
3274 y_mm: 0.0,
3275 priority: 0,
3276 },
3277 GlyphPlacement {
3278 resource_key: "first".into(),
3279 metrics,
3280 x_mm: 10.0,
3281 y_mm: 0.0,
3282 priority: 0,
3283 },
3284 GlyphPlacement {
3285 resource_key: "third".into(),
3286 metrics,
3287 x_mm: 30.0,
3288 y_mm: 0.0,
3289 priority: 0,
3290 },
3291 ];
3292 assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
3293 assert_eq!(placements[0].x_mm, 23.0);
3294 assert_eq!(placements[1].x_mm, 10.0);
3295 assert_eq!(placements[2].x_mm, 36.0);
3296 assert_eq!(
3297 distribute_glyph_spacing(&mut placements, f32::NAN),
3298 Err(GlyphPlacementError::NonFiniteSpacing)
3299 );
3300 let before = placements.clone();
3301 assert_eq!(
3302 distribute_glyph_spacing(&mut placements, f32::MAX),
3303 Err(GlyphPlacementError::NonFiniteSpacing)
3304 );
3305 assert_eq!(placements, before);
3306 }
3307
3308 #[test]
3309 fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
3310 let mut placement = GlyphPlacement {
3311 resource_key: " ".into(),
3312 metrics: GlyphMetrics {
3313 advance_mm: 1.0,
3314 left_mm: 0.0,
3315 top_mm: 0.0,
3316 width_mm: 1.0,
3317 height_mm: 1.0,
3318 },
3319 x_mm: 0.0,
3320 y_mm: 0.0,
3321 priority: 0,
3322 };
3323 assert_eq!(
3324 validate_glyph_placements(&[placement.clone()]),
3325 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3326 );
3327 placement.resource_key = "glyph".into();
3328 placement.metrics.advance_mm = -1.0;
3329 assert_eq!(
3330 validate_glyph_placements(&[placement]),
3331 Err(GlyphPlacementError::NegativeAdvance { index: 0 })
3332 );
3333 }
3334
3335 #[test]
3336 fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
3337 let mut placements = vec![GlyphPlacement {
3338 resource_key: String::new(),
3339 metrics: GlyphMetrics {
3340 advance_mm: 1.0,
3341 left_mm: 0.0,
3342 top_mm: 0.0,
3343 width_mm: 1.0,
3344 height_mm: 1.0,
3345 },
3346 x_mm: 0.0,
3347 y_mm: 0.0,
3348 priority: 0,
3349 }];
3350 assert_eq!(
3351 resolve_glyph_collisions_checked(&mut placements, 1.0),
3352 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3353 );
3354 assert_eq!(
3355 resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
3356 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
3357 );
3358 assert_eq!(placements[0].x_mm, 0.0);
3359 assert_eq!(placements[0].y_mm, 0.0);
3360 }
3361
3362 #[test]
3363 fn checked_collision_resolvers_reject_non_finite_gap() {
3364 let metrics = GlyphMetrics {
3365 advance_mm: 1.0,
3366 left_mm: 0.0,
3367 top_mm: 0.0,
3368 width_mm: 1.0,
3369 height_mm: 1.0,
3370 };
3371 let original = vec![GlyphPlacement {
3372 resource_key: "glyph".into(),
3373 metrics,
3374 x_mm: 0.0,
3375 y_mm: 0.0,
3376 priority: 0,
3377 }];
3378 let mut vertical = original.clone();
3379 assert_eq!(
3380 resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
3381 Err(GlyphPlacementError::NonFiniteSpacing)
3382 );
3383 assert_eq!(vertical, original);
3384
3385 let mut horizontal = original.clone();
3386 assert_eq!(
3387 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
3388 Err(GlyphPlacementError::NonFiniteSpacing)
3389 );
3390 assert_eq!(horizontal, original);
3391 }
3392
3393 #[test]
3394 fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
3395 let metrics = GlyphMetrics {
3396 advance_mm: 1.0,
3397 left_mm: 0.0,
3398 top_mm: 0.0,
3399 width_mm: f32::MAX / 2.0,
3400 height_mm: f32::MAX / 2.0,
3401 };
3402 let original = vec![
3403 GlyphPlacement {
3404 resource_key: "high".into(),
3405 metrics,
3406 x_mm: 0.0,
3407 y_mm: 0.0,
3408 priority: 1,
3409 },
3410 GlyphPlacement {
3411 resource_key: "low".into(),
3412 metrics,
3413 x_mm: 0.0,
3414 y_mm: 0.0,
3415 priority: 0,
3416 },
3417 ];
3418 let mut vertical = original.clone();
3419 assert_eq!(
3420 resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
3421 Err(GlyphPlacementError::NonFinite { index: 1 })
3422 );
3423 assert_eq!(vertical, original);
3424
3425 let mut horizontal = original.clone();
3426 assert_eq!(
3427 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
3428 Err(GlyphPlacementError::NonFinite { index: 1 })
3429 );
3430 assert_eq!(horizontal, original);
3431 }
3432
3433 #[test]
3434 fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
3435 let metrics = GlyphMetrics {
3436 advance_mm: 1.0,
3437 left_mm: -1.0,
3438 top_mm: -2.0,
3439 width_mm: 3.0,
3440 height_mm: 4.0,
3441 };
3442 let placements = vec![
3443 GlyphPlacement {
3444 resource_key: "a".into(),
3445 metrics,
3446 x_mm: 10.0,
3447 y_mm: 20.0,
3448 priority: 0,
3449 },
3450 GlyphPlacement {
3451 resource_key: "b".into(),
3452 metrics,
3453 x_mm: 30.0,
3454 y_mm: 5.0,
3455 priority: 0,
3456 },
3457 ];
3458 assert_eq!(
3459 glyph_extents(&placements),
3460 Ok(Some(GlyphExtents {
3461 left_mm: 9.0,
3462 top_mm: 3.0,
3463 right_mm: 32.0,
3464 bottom_mm: 22.0,
3465 }))
3466 );
3467 let extents = glyph_extents(&placements).unwrap().unwrap();
3468 assert_eq!(extents.width_mm(), 23.0);
3469 assert_eq!(extents.height_mm(), 19.0);
3470 assert_eq!(glyph_extents(&[]), Ok(None));
3471 }
3472
3473 #[test]
3474 fn glyph_extents_reject_derived_bound_overflow() {
3475 let placements = [GlyphPlacement {
3476 resource_key: "edge".into(),
3477 metrics: GlyphMetrics {
3478 advance_mm: 1.0,
3479 left_mm: 0.0,
3480 top_mm: 0.0,
3481 width_mm: f32::MAX,
3482 height_mm: 1.0,
3483 },
3484 x_mm: f32::MAX,
3485 y_mm: 0.0,
3486 priority: 0,
3487 }];
3488 assert_eq!(
3489 glyph_extents(&placements),
3490 Err(GlyphPlacementError::NonFinite { index: 0 })
3491 );
3492 }
3493}