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