1use crate::{LayoutConfig, SpanMark, compute_layout};
2use acorde_core::{Barline, NoteAddr, 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, Eq, Default)]
127pub enum GlyphCollisionDirection {
128 Fixed,
134 Right,
136 Left,
138 #[default]
140 Down,
141 Up,
143}
144
145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
147pub struct GlyphExtents {
148 pub left_mm: f32,
149 pub top_mm: f32,
150 pub right_mm: f32,
151 pub bottom_mm: f32,
152}
153
154impl GlyphExtents {
155 pub fn width_mm(self) -> f32 {
157 self.right_mm - self.left_mm
158 }
159
160 pub fn height_mm(self) -> f32 {
162 self.bottom_mm - self.top_mm
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
168pub enum GlyphPlacementError {
169 #[error("glyph placement {index} contains non-finite geometry")]
170 NonFinite { index: usize },
171 #[error("glyph placement {index} has a negative bounding-box extent")]
172 NegativeExtent { index: usize },
173 #[error("glyph spacing is non-finite or overflows")]
174 NonFiniteSpacing,
175 #[error("glyph placement {index} has an empty resource key")]
176 EmptyResourceKey { index: usize },
177 #[error("glyph placement {index} has a negative advance")]
178 NegativeAdvance { index: usize },
179 #[error("collision class count {classes} does not match placement count {placements}")]
180 CollisionClassCount { placements: usize, classes: usize },
181 #[error("collision direction count {directions} does not match placement count {placements}")]
182 CollisionDirectionCount {
183 placements: usize,
184 directions: usize,
185 },
186}
187
188pub fn validate_glyph_placements(placements: &[GlyphPlacement]) -> Result<(), GlyphPlacementError> {
190 for (index, placement) in placements.iter().enumerate() {
191 if placement.resource_key.trim().is_empty() {
192 return Err(GlyphPlacementError::EmptyResourceKey { index });
193 }
194 let values = [
195 placement.metrics.advance_mm,
196 placement.metrics.left_mm,
197 placement.metrics.top_mm,
198 placement.metrics.width_mm,
199 placement.metrics.height_mm,
200 placement.x_mm,
201 placement.y_mm,
202 ];
203 if values.iter().any(|value| !value.is_finite()) {
204 return Err(GlyphPlacementError::NonFinite { index });
205 }
206 if placement.metrics.width_mm < 0.0 || placement.metrics.height_mm < 0.0 {
207 return Err(GlyphPlacementError::NegativeExtent { index });
208 }
209 if placement.metrics.advance_mm < 0.0 {
210 return Err(GlyphPlacementError::NegativeAdvance { index });
211 }
212 }
213 Ok(())
214}
215
216pub fn glyph_extents(
218 placements: &[GlyphPlacement],
219) -> Result<Option<GlyphExtents>, GlyphPlacementError> {
220 validate_glyph_placements(placements)?;
221 let Some(first) = placements.first() else {
222 return Ok(None);
223 };
224 let (first_left, first_right) = horizontal_bounds(first);
225 let (first_top, first_bottom) = vertical_bounds(first);
226 if [first_left, first_right, first_top, first_bottom]
227 .iter()
228 .any(|value| !value.is_finite())
229 {
230 return Err(GlyphPlacementError::NonFinite { index: 0 });
231 }
232 let mut extents = GlyphExtents {
233 left_mm: first_left,
234 top_mm: first_top,
235 right_mm: first_right,
236 bottom_mm: first_bottom,
237 };
238 for (index, placement) in placements.iter().enumerate().skip(1) {
239 let (left, right) = horizontal_bounds(placement);
240 let (top, bottom) = vertical_bounds(placement);
241 if [left, right, top, bottom]
242 .iter()
243 .any(|value| !value.is_finite())
244 {
245 return Err(GlyphPlacementError::NonFinite { index });
246 }
247 extents.left_mm = extents.left_mm.min(left);
248 extents.top_mm = extents.top_mm.min(top);
249 extents.right_mm = extents.right_mm.max(right);
250 extents.bottom_mm = extents.bottom_mm.max(bottom);
251 }
252 Ok(Some(extents))
253}
254
255pub fn distribute_glyph_spacing(
257 placements: &mut [GlyphPlacement],
258 extra_mm: f32,
259) -> Result<usize, GlyphPlacementError> {
260 validate_glyph_placements(placements)?;
261 if !extra_mm.is_finite() {
262 return Err(GlyphPlacementError::NonFiniteSpacing);
263 }
264 if extra_mm <= 0.0 || placements.len() < 2 {
265 return Ok(0);
266 }
267 let mut order: Vec<usize> = (0..placements.len()).collect();
268 order.sort_by(|&left, &right| {
269 placements[left]
270 .x_mm
271 .total_cmp(&placements[right].x_mm)
272 .then(left.cmp(&right))
273 });
274 let denominator = (order.len() - 1) as f32;
275 let mut shifts = Vec::with_capacity(order.len().saturating_sub(1));
276 for (rank, &index) in order.iter().enumerate().skip(1) {
277 let shift = extra_mm * rank as f32 / denominator;
278 if !shift.is_finite() || !(placements[index].x_mm + shift).is_finite() {
279 return Err(GlyphPlacementError::NonFiniteSpacing);
280 }
281 shifts.push((index, shift));
282 }
283 let mut moved = 0;
284 for (index, shift) in shifts {
285 placements[index].x_mm += shift;
286 if shift > f32::EPSILON {
287 moved += 1;
288 }
289 }
290 Ok(moved)
291}
292
293pub fn resolve_glyph_collisions(placements: &mut [GlyphPlacement], gap_mm: f32) -> usize {
299 let order = collision_order(placements, None);
300 resolve_glyph_collisions_ordered(placements, gap_mm, &order)
301}
302
303pub fn resolve_glyph_collisions_with_classes(
309 placements: &mut [GlyphPlacement],
310 classes: &[GlyphCollisionClass],
311 gap_mm: f32,
312) -> Result<usize, GlyphPlacementError> {
313 validate_glyph_placements(placements)?;
314 if classes.len() != placements.len() {
315 return Err(GlyphPlacementError::CollisionClassCount {
316 placements: placements.len(),
317 classes: classes.len(),
318 });
319 }
320 if !gap_mm.is_finite() {
321 return Err(GlyphPlacementError::NonFiniteSpacing);
322 }
323 let mut candidate = placements.to_vec();
324 let order = collision_order(&candidate, Some(classes));
325 let moved = resolve_glyph_collisions_ordered(&mut candidate, gap_mm, &order);
326 glyph_extents(&candidate)?;
327 placements.clone_from_slice(&candidate);
328 Ok(moved)
329}
330
331pub fn resolve_glyph_collisions_constrained(
339 placements: &mut [GlyphPlacement],
340 classes: &[GlyphCollisionClass],
341 directions: &[GlyphCollisionDirection],
342 gap_mm: f32,
343) -> Result<usize, GlyphPlacementError> {
344 validate_glyph_placements(placements)?;
345 if classes.len() != placements.len() {
346 return Err(GlyphPlacementError::CollisionClassCount {
347 placements: placements.len(),
348 classes: classes.len(),
349 });
350 }
351 if directions.len() != placements.len() {
352 return Err(GlyphPlacementError::CollisionDirectionCount {
353 placements: placements.len(),
354 directions: directions.len(),
355 });
356 }
357 if !gap_mm.is_finite() {
358 return Err(GlyphPlacementError::NonFiniteSpacing);
359 }
360 let mut candidate = placements.to_vec();
361 let order = collision_order(&candidate, Some(classes));
362 let moved = resolve_glyph_collisions_constrained_ordered(
363 &mut candidate,
364 directions,
365 gap_mm.max(0.0),
366 &order,
367 );
368 glyph_extents(&candidate)?;
369 placements.clone_from_slice(&candidate);
370 Ok(moved)
371}
372
373fn resolve_glyph_collisions_constrained_ordered(
374 placements: &mut [GlyphPlacement],
375 directions: &[GlyphCollisionDirection],
376 gap_mm: f32,
377 order: &[usize],
378) -> usize {
379 let mut moved = 0;
380 for (position, &index) in order.iter().enumerate() {
381 let original = placements[index].clone();
382 let mut next = original.clone();
383 for &previous in &order[..position] {
384 let (left, right) = horizontal_bounds(&next);
385 let (top, bottom) = vertical_bounds(&next);
386 let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
387 let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
388 if right <= previous_left
389 || previous_right <= left
390 || bottom <= previous_top
391 || previous_bottom <= top
392 {
393 continue;
394 }
395 match directions[index] {
396 GlyphCollisionDirection::Fixed => {}
397 GlyphCollisionDirection::Right => {
398 next.x_mm = previous_right + gap_mm - next.metrics.left_mm;
399 }
400 GlyphCollisionDirection::Left => {
401 next.x_mm =
402 previous_left - gap_mm - next.metrics.left_mm - next.metrics.width_mm;
403 }
404 GlyphCollisionDirection::Down => {
405 next.y_mm = previous_bottom + gap_mm - next.metrics.top_mm;
406 }
407 GlyphCollisionDirection::Up => {
408 next.y_mm =
409 previous_top - gap_mm - next.metrics.top_mm - next.metrics.height_mm;
410 }
411 }
412 }
413 if next.x_mm != original.x_mm || next.y_mm != original.y_mm {
414 placements[index] = next;
415 moved += 1;
416 }
417 }
418 moved
419}
420
421fn resolve_glyph_collisions_ordered(
422 placements: &mut [GlyphPlacement],
423 gap_mm: f32,
424 order: &[usize],
425) -> usize {
426 let gap_mm = if gap_mm.is_finite() {
427 gap_mm.max(0.0)
428 } else {
429 0.0
430 };
431 let mut moved = 0;
432 for position in 0..order.len() {
433 let index = order[position];
434 let (left, right) = horizontal_bounds(&placements[index]);
435 let mut next_y = placements[index].y_mm;
436 for &previous in &order[..position] {
437 let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
438 if right <= previous_left || previous_right <= left {
439 continue;
440 }
441 let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
442 let current_top = next_y + placements[index].metrics.top_mm;
443 let current_bottom = current_top + placements[index].metrics.height_mm;
444 if current_bottom <= previous_top || previous_bottom <= current_top {
445 continue;
446 }
447 if current_top < previous_bottom + gap_mm {
448 next_y = previous_bottom + gap_mm - placements[index].metrics.top_mm;
449 }
450 }
451 if (next_y - placements[index].y_mm).abs() > f32::EPSILON {
452 placements[index].y_mm = next_y;
453 moved += 1;
454 }
455 }
456 moved
457}
458
459pub fn resolve_glyph_collisions_checked(
461 placements: &mut [GlyphPlacement],
462 gap_mm: f32,
463) -> Result<usize, GlyphPlacementError> {
464 validate_glyph_placements(placements)?;
465 if !gap_mm.is_finite() {
466 return Err(GlyphPlacementError::NonFiniteSpacing);
467 }
468 let mut candidate = placements.to_vec();
469 let moved = resolve_glyph_collisions(&mut candidate, gap_mm);
470 glyph_extents(&candidate)?;
471 placements.clone_from_slice(&candidate);
472 Ok(moved)
473}
474
475pub fn resolve_glyph_horizontal_collisions(
480 placements: &mut [GlyphPlacement],
481 gap_mm: f32,
482) -> usize {
483 let order = collision_order(placements, None);
484 resolve_glyph_horizontal_collisions_ordered(placements, gap_mm, &order)
485}
486
487pub fn resolve_glyph_horizontal_collisions_with_classes(
489 placements: &mut [GlyphPlacement],
490 classes: &[GlyphCollisionClass],
491 gap_mm: f32,
492) -> Result<usize, GlyphPlacementError> {
493 validate_glyph_placements(placements)?;
494 if classes.len() != placements.len() {
495 return Err(GlyphPlacementError::CollisionClassCount {
496 placements: placements.len(),
497 classes: classes.len(),
498 });
499 }
500 if !gap_mm.is_finite() {
501 return Err(GlyphPlacementError::NonFiniteSpacing);
502 }
503 let mut candidate = placements.to_vec();
504 let order = collision_order(&candidate, Some(classes));
505 let moved = resolve_glyph_horizontal_collisions_ordered(&mut candidate, gap_mm, &order);
506 glyph_extents(&candidate)?;
507 placements.clone_from_slice(&candidate);
508 Ok(moved)
509}
510
511fn resolve_glyph_horizontal_collisions_ordered(
512 placements: &mut [GlyphPlacement],
513 gap_mm: f32,
514 order: &[usize],
515) -> usize {
516 let gap_mm = if gap_mm.is_finite() {
517 gap_mm.max(0.0)
518 } else {
519 0.0
520 };
521 let mut moved = 0;
522 for position in 0..order.len() {
523 let index = order[position];
524 let original_x = placements[index].x_mm;
525 let mut next_x = original_x;
526 for &previous in &order[..position] {
527 let current = GlyphPlacement {
528 x_mm: next_x,
529 ..placements[index].clone()
530 };
531 let (left, right) = horizontal_bounds(¤t);
532 let (previous_left, previous_right) = horizontal_bounds(&placements[previous]);
533 let (top, bottom) = vertical_bounds(¤t);
534 let (previous_top, previous_bottom) = vertical_bounds(&placements[previous]);
535 if right <= previous_left
536 || previous_right <= left
537 || bottom <= previous_top
538 || previous_bottom <= top
539 {
540 continue;
541 }
542 next_x = previous_right + gap_mm - placements[index].metrics.left_mm;
543 }
544 if (next_x - original_x).abs() > f32::EPSILON {
545 placements[index].x_mm = next_x;
546 moved += 1;
547 }
548 }
549 moved
550}
551
552fn collision_order(
553 placements: &[GlyphPlacement],
554 classes: Option<&[GlyphCollisionClass]>,
555) -> Vec<usize> {
556 let class_rank = |index: usize| {
557 classes
558 .and_then(|values| values.get(index))
559 .map_or(0, |class| match class {
560 GlyphCollisionClass::Critical => 0,
561 GlyphCollisionClass::Spacing => 1,
562 GlyphCollisionClass::Annotation => 2,
563 GlyphCollisionClass::Decorative => 3,
564 })
565 };
566 let mut order: Vec<usize> = (0..placements.len()).collect();
567 order.sort_by_key(|&index| {
568 (
569 std::cmp::Reverse(placements[index].priority),
570 class_rank(index),
571 index,
572 )
573 });
574 order
575}
576
577pub fn resolve_glyph_horizontal_collisions_checked(
579 placements: &mut [GlyphPlacement],
580 gap_mm: f32,
581) -> Result<usize, GlyphPlacementError> {
582 validate_glyph_placements(placements)?;
583 if !gap_mm.is_finite() {
584 return Err(GlyphPlacementError::NonFiniteSpacing);
585 }
586 let mut candidate = placements.to_vec();
587 let moved = resolve_glyph_horizontal_collisions(&mut candidate, gap_mm);
588 glyph_extents(&candidate)?;
589 placements.clone_from_slice(&candidate);
590 Ok(moved)
591}
592
593fn horizontal_bounds(placement: &GlyphPlacement) -> (f32, f32) {
594 (
595 placement.x_mm + placement.metrics.left_mm,
596 placement.x_mm + placement.metrics.left_mm + placement.metrics.width_mm,
597 )
598}
599
600fn vertical_bottom(placement: &GlyphPlacement) -> f32 {
601 placement.y_mm + placement.metrics.top_mm + placement.metrics.height_mm
602}
603
604fn vertical_bounds(placement: &GlyphPlacement) -> (f32, f32) {
605 (
606 placement.y_mm + placement.metrics.top_mm,
607 vertical_bottom(placement),
608 )
609}
610
611#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
613pub enum PaperSize {
614 A4,
615 Letter,
616 Legal,
617 Custom { width_mm: f32, height_mm: f32 },
618}
619
620impl PaperSize {
621 fn dimensions_mm(self) -> (f32, f32) {
622 match self {
623 Self::A4 => (210.0, 297.0),
624 Self::Letter => (215.9, 279.4),
625 Self::Legal => (215.9, 355.6),
626 Self::Custom {
627 width_mm,
628 height_mm,
629 } => (width_mm, height_mm),
630 }
631 }
632}
633
634#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
636pub enum PageOrientation {
637 Portrait,
638 Landscape,
639}
640
641#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
643pub enum PageNumbering {
644 None,
645 OneBased,
646}
647
648#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
650pub enum FinalPagePolicy {
651 #[default]
653 AllowSingleSystem,
654 Balance,
656}
657
658#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
660pub enum PickupPolicy {
661 #[default]
663 Auto,
664 Preserve,
666 DetectFirstMeasure,
668}
669
670#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
672pub enum NotationBreakPolicy {
673 #[default]
675 Preserve,
676 KeepVoltaTogether,
678 KeepRepeatsTogether,
680}
681
682#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
684pub enum PrintColorPolicy {
685 #[default]
686 Monochrome,
687 Preserve,
688}
689
690#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
692pub enum CropMarkPolicy {
693 #[default]
694 None,
695 BleedEdges,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
700pub enum GlyphResourcePolicy {
701 #[default]
703 BuiltInVector,
704 HostProvided(String),
706}
707
708#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
710pub enum PartLayoutPolicy {
711 #[default]
713 FullScore,
714 ExtractedPart { part_index: usize },
716}
717
718#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
723#[serde(default)]
724pub struct PublicationPageTemplate {
725 pub odd: Option<String>,
726 pub even: Option<String>,
727}
728
729#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
733pub enum PublicationImagePlacement {
734 TitlePage,
736 #[default]
738 MusicPages,
739 EveryPage,
741}
742
743#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
748pub struct PublicationImageResource {
749 pub resource_key: String,
750 pub alt_text: String,
751 #[serde(default)]
752 pub placement: PublicationImagePlacement,
753 pub x_mm: f32,
754 pub y_mm: f32,
755 pub width_mm: f32,
756 pub height_mm: f32,
757}
758
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
764pub struct PublicationSection {
765 pub first_measure: usize,
767 pub title: String,
768 #[serde(default)]
770 pub start_on_new_page: bool,
771}
772
773#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
778pub struct PublicationSpacer {
779 pub before_measure: usize,
781 pub height_mm: f32,
782}
783
784#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
786pub enum PublicationFramePlacement {
787 TitlePage,
788 #[default]
789 MusicPages,
790 EveryPage,
791}
792
793#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
797pub struct PublicationFrame {
798 #[serde(default)]
799 pub placement: PublicationFramePlacement,
800 pub x_mm: f32,
801 pub y_mm: f32,
802 pub width_mm: f32,
803 pub height_mm: f32,
804 pub stroke_width_mm: f32,
805}
806
807impl PublicationPageTemplate {
808 fn is_configured(&self) -> bool {
809 self.odd.is_some() || self.even.is_some()
810 }
811
812 fn resolve(&self, page_number: Option<usize>) -> Option<&String> {
813 match page_number {
814 Some(number) if number % 2 == 0 => self.even.as_ref(),
815 Some(_) => self.odd.as_ref(),
816 None => None,
817 }
818 }
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
823#[serde(default)]
824pub struct PublicationConfig {
825 pub title_page: bool,
827 pub running_title: Option<String>,
829 pub show_part_names: bool,
830 pub show_measure_numbers: bool,
831 pub header_text: Option<String>,
833 pub footer_text: Option<String>,
835 pub header_template: PublicationPageTemplate,
837 pub footer_template: PublicationPageTemplate,
839 pub page_number_in_footer: bool,
841 pub header_alignment: PublicationTextAlignment,
842 pub footer_alignment: PublicationTextAlignment,
843 pub title_alignment: PublicationTextAlignment,
844 pub line_height_mm: f32,
846 #[serde(default)]
848 pub image_resources: Vec<PublicationImageResource>,
849 #[serde(default)]
851 pub sections: Vec<PublicationSection>,
852 #[serde(default)]
854 pub spacers: Vec<PublicationSpacer>,
855 #[serde(default)]
857 pub frames: Vec<PublicationFrame>,
858}
859
860impl Default for PublicationConfig {
861 fn default() -> Self {
862 Self {
863 title_page: false,
864 running_title: None,
865 show_part_names: true,
866 show_measure_numbers: true,
867 header_text: None,
868 footer_text: None,
869 header_template: PublicationPageTemplate::default(),
870 footer_template: PublicationPageTemplate::default(),
871 page_number_in_footer: false,
872 header_alignment: PublicationTextAlignment::Left,
873 footer_alignment: PublicationTextAlignment::Left,
874 title_alignment: PublicationTextAlignment::Center,
875 line_height_mm: 4.0,
876 image_resources: Vec::new(),
877 sections: Vec::new(),
878 spacers: Vec::new(),
879 frames: Vec::new(),
880 }
881 }
882}
883
884#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
886pub enum PublicationTextRole {
887 Header,
888 Footer,
889 Title,
890 Subtitle,
891 Credit,
892 Copyright,
893}
894
895#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
897pub enum PublicationTextAlignment {
898 #[default]
899 Left,
900 Center,
901 Right,
902}
903
904#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
906pub struct PublicationTextBlock {
907 pub role: PublicationTextRole,
908 pub text: String,
909 pub x_mm: f32,
910 pub y_mm: f32,
911 pub width_mm: f32,
912 pub height_mm: f32,
913 #[serde(default)]
914 pub alignment: PublicationTextAlignment,
915}
916
917#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
919pub struct PartLabel {
920 pub part_index: usize,
921 pub name: String,
922 pub short_name: String,
923}
924
925#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
927pub struct PartGroupMark {
928 pub first_part: usize,
929 pub last_part: usize,
930 pub symbol: PartGroupSymbol,
931 pub barlines_connect: bool,
932}
933
934#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
936#[serde(default)]
937pub struct PagePublication {
938 #[serde(default)]
939 pub is_title_page: bool,
940 pub title: String,
941 pub movement_title: String,
942 pub composer: String,
943 pub lyricist: String,
944 pub copyright: String,
945 pub running_title: Option<String>,
946 #[serde(default)]
948 pub score_texts: Vec<StyledText>,
949 pub part_labels: Vec<PartLabel>,
950 #[serde(default)]
951 pub part_groups: Vec<PartGroupMark>,
952 pub measure_numbers: Vec<u32>,
953 #[serde(default)]
954 pub text_blocks: Vec<PublicationTextBlock>,
955 #[serde(default)]
957 pub image_resources: Vec<PublicationImageResource>,
958 #[serde(default)]
960 pub sections: Vec<PublicationSection>,
961 #[serde(default)]
963 pub spacers: Vec<PublicationSpacer>,
964 #[serde(default)]
966 pub frames: Vec<PublicationFrame>,
967}
968
969#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
975pub struct KeepTogetherRange {
976 pub first_measure: usize,
977 pub last_measure: usize,
978}
979
980#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
985#[serde(default)]
986pub struct PrintConfig {
987 pub paper_size: PaperSize,
988 pub orientation: PageOrientation,
989 pub margin_top_mm: f32,
990 pub margin_right_mm: f32,
991 pub margin_bottom_mm: f32,
992 pub margin_left_mm: f32,
993 pub bleed_top_mm: f32,
994 pub bleed_right_mm: f32,
995 pub bleed_bottom_mm: f32,
996 pub bleed_left_mm: f32,
997 pub safe_top_mm: f32,
998 pub safe_right_mm: f32,
999 pub safe_bottom_mm: f32,
1000 pub safe_left_mm: f32,
1001 pub system_height_mm: f32,
1002 pub scale: f32,
1004 pub measures_per_system: usize,
1005 #[serde(default)]
1007 pub first_system_measures: Option<usize>,
1008 #[serde(default)]
1009 pub pickup_policy: PickupPolicy,
1010 #[serde(default)]
1011 pub notation_break_policy: NotationBreakPolicy,
1012 pub systems_per_page: Option<usize>,
1015 pub page_numbering: PageNumbering,
1016 #[serde(default)]
1017 pub final_page_policy: FinalPagePolicy,
1018 #[serde(default)]
1019 pub color_policy: PrintColorPolicy,
1020 #[serde(default)]
1021 pub crop_mark_policy: CropMarkPolicy,
1022 #[serde(default)]
1023 pub glyph_resources: GlyphResourcePolicy,
1024 #[serde(default)]
1025 pub publication: PublicationConfig,
1026 #[serde(default)]
1027 pub part_layout: PartLayoutPolicy,
1028 #[serde(default)]
1030 pub keep_together: Vec<KeepTogetherRange>,
1031}
1032
1033impl Default for PrintConfig {
1034 fn default() -> Self {
1035 Self {
1036 paper_size: PaperSize::A4,
1037 orientation: PageOrientation::Portrait,
1038 margin_top_mm: 16.0,
1039 margin_right_mm: 14.0,
1040 margin_bottom_mm: 16.0,
1041 margin_left_mm: 14.0,
1042 bleed_top_mm: 0.0,
1043 bleed_right_mm: 0.0,
1044 bleed_bottom_mm: 0.0,
1045 bleed_left_mm: 0.0,
1046 safe_top_mm: 0.0,
1047 safe_right_mm: 0.0,
1048 safe_bottom_mm: 0.0,
1049 safe_left_mm: 0.0,
1050 system_height_mm: 24.0,
1051 scale: 1.0,
1052 measures_per_system: 4,
1053 first_system_measures: None,
1054 pickup_policy: PickupPolicy::Auto,
1055 notation_break_policy: NotationBreakPolicy::Preserve,
1056 systems_per_page: None,
1057 page_numbering: PageNumbering::OneBased,
1058 final_page_policy: FinalPagePolicy::AllowSingleSystem,
1059 color_policy: PrintColorPolicy::Monochrome,
1060 crop_mark_policy: CropMarkPolicy::None,
1061 glyph_resources: GlyphResourcePolicy::BuiltInVector,
1062 publication: PublicationConfig::default(),
1063 part_layout: PartLayoutPolicy::FullScore,
1064 keep_together: Vec::new(),
1065 }
1066 }
1067}
1068
1069pub const PRINT_PRESET_SCHEMA_VERSION: u16 = 1;
1071pub const PRINT_LAYOUT_CONTRACT_VERSION: u16 = 32;
1073
1074#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1076pub enum PrintPreset {
1077 A4Score,
1078 LetterScore,
1079 A4Part { part_index: usize },
1080 LetterPart { part_index: usize },
1081}
1082
1083impl PrintPreset {
1084 pub fn config(self) -> PrintConfig {
1086 let (paper_size, part_layout) = match self {
1087 Self::A4Score => (PaperSize::A4, PartLayoutPolicy::FullScore),
1088 Self::LetterScore => (PaperSize::Letter, PartLayoutPolicy::FullScore),
1089 Self::A4Part { part_index } => (
1090 PaperSize::A4,
1091 PartLayoutPolicy::ExtractedPart { part_index },
1092 ),
1093 Self::LetterPart { part_index } => (
1094 PaperSize::Letter,
1095 PartLayoutPolicy::ExtractedPart { part_index },
1096 ),
1097 };
1098 PrintConfig {
1099 paper_size,
1100 part_layout,
1101 ..PrintConfig::default()
1102 }
1103 }
1104
1105 pub fn config_with_title_page(self, title_page: bool) -> PrintConfig {
1107 let mut config = self.config();
1108 config.publication.title_page = title_page;
1109 config
1110 }
1111
1112 pub const fn schema_version(self) -> u16 {
1114 PRINT_PRESET_SCHEMA_VERSION
1115 }
1116}
1117
1118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1120pub struct SystemLayout {
1121 pub address: SystemAddress,
1122 pub system_index: usize,
1123 pub page_index: usize,
1124 pub measure_indices: Vec<usize>,
1125 #[serde(default)]
1127 pub measure_spans: Vec<MeasureSpan>,
1128 #[serde(default)]
1130 pub span_segments: Vec<SpanSegment>,
1131 #[serde(default)]
1133 pub measure_marks: Vec<MeasureMark>,
1134 pub top_mm: f32,
1135 pub height_mm: f32,
1136 pub break_reason: BreakReason,
1137}
1138
1139#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1141pub struct PageAddress {
1142 pub page_index: usize,
1143}
1144
1145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1147pub struct SystemAddress {
1148 pub system_index: usize,
1149 pub page_index: usize,
1150 pub index_on_page: usize,
1151}
1152
1153#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1155pub struct MeasureSpan {
1156 pub first_measure: usize,
1157 pub last_measure: usize,
1158}
1159
1160#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1162pub struct SpanSegment {
1163 pub span_index: usize,
1164 pub starts_here: bool,
1165 pub ends_here: bool,
1166}
1167
1168#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1170pub struct PageSpanSegment {
1171 pub span_index: usize,
1172 pub starts_here: bool,
1173 pub ends_here: bool,
1174}
1175
1176#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1181pub struct MeasureMark {
1182 pub measure_index: usize,
1183 pub repeat_start: bool,
1184 pub repeat_end: bool,
1185 pub volta_number: Option<u8>,
1186 pub volta_kind: Option<String>,
1187 pub navigation: Option<String>,
1188 pub rehearsal: Option<String>,
1189 #[serde(default)]
1191 pub text_annotations: Vec<StyledText>,
1192}
1193
1194#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1196pub enum BreakReason {
1197 MeasureCapacity,
1198 ExplicitSystemBreak,
1199 ExplicitPageBreak,
1200 SectionBreak,
1201 PageCapacity,
1202 EndOfScore,
1203 TitlePage,
1204}
1205
1206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1208pub struct PageLayout {
1209 pub address: PageAddress,
1210 pub page_index: usize,
1211 pub page_number: Option<usize>,
1212 #[serde(default)]
1213 pub color_policy: PrintColorPolicy,
1214 #[serde(default)]
1215 pub crop_mark_policy: CropMarkPolicy,
1216 #[serde(default)]
1217 pub glyph_resources: GlyphResourcePolicy,
1218 #[serde(default)]
1219 pub publication: PagePublication,
1220 pub width_mm: f32,
1221 pub height_mm: f32,
1222 pub content_width_mm: f32,
1223 pub content_height_mm: f32,
1224 pub bleed_top_mm: f32,
1225 pub bleed_right_mm: f32,
1226 pub bleed_bottom_mm: f32,
1227 pub bleed_left_mm: f32,
1228 pub systems: Vec<SystemLayout>,
1229 #[serde(default)]
1231 pub span_segments: Vec<PageSpanSegment>,
1232 #[serde(default)]
1234 pub measure_marks: Vec<MeasureMark>,
1235 pub break_reason: BreakReason,
1236}
1237
1238#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1243pub struct PageArtifact {
1244 pub address: PageAddress,
1245 pub page_index: usize,
1246 pub page_number: Option<usize>,
1247 pub width_mm: f32,
1248 pub height_mm: f32,
1249 pub content_width_mm: f32,
1250 pub content_height_mm: f32,
1251 pub measure_span: Option<MeasureSpan>,
1252 pub diagnostics: Vec<PageArtifactDiagnostic>,
1253 pub layout: PageLayout,
1254}
1255
1256pub const PAGE_RENDER_TREE_CONTRACT_VERSION: u16 = 1;
1258
1259#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1261#[serde(rename_all = "kebab-case")]
1262pub enum PageRenderAddress {
1263 Note(NoteAddr),
1264 Spanner {
1265 id: String,
1266 },
1267 Publication {
1268 page_index: usize,
1269 block_index: usize,
1270 },
1271 Resource {
1272 page_index: usize,
1273 resource_key: String,
1274 },
1275 Frame {
1276 page_index: usize,
1277 frame_index: usize,
1278 },
1279}
1280
1281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1283#[serde(rename_all = "kebab-case")]
1284pub enum PageRenderNodeKind {
1285 Note,
1286 Rest,
1287 Spanner { starts_here: bool, ends_here: bool },
1288 PublicationBlock,
1289 Resource,
1290 Frame,
1291}
1292
1293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1295pub struct PageRenderNode {
1296 pub address: PageRenderAddress,
1297 pub kind: PageRenderNodeKind,
1298 #[serde(default, skip_serializing_if = "Option::is_none")]
1299 pub system: Option<SystemAddress>,
1300}
1301
1302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1304pub struct PageRenderTree {
1305 pub contract_version: u16,
1306 #[serde(default, skip_serializing_if = "Option::is_none")]
1307 pub view_id: Option<String>,
1308 pub page: PageArtifact,
1309 pub nodes: Vec<PageRenderNode>,
1310}
1311
1312impl PageRenderTree {
1313 pub fn validate(&self, score: &Score) -> Result<(), PrintLayoutError> {
1317 if self.contract_version != PAGE_RENDER_TREE_CONTRACT_VERSION {
1318 return Err(PrintLayoutError::UnsupportedPageRenderTreeContractVersion {
1319 found: self.contract_version,
1320 });
1321 }
1322 let view = self
1323 .view_id
1324 .as_deref()
1325 .map(|view_id| {
1326 score
1327 .views
1328 .iter()
1329 .find(|view| view.id == view_id)
1330 .ok_or(PrintLayoutError::InvalidView)
1331 })
1332 .transpose()?;
1333 for (node_index, node) in self.nodes.iter().enumerate() {
1334 let system = node.system.and_then(|address| {
1335 self.page
1336 .layout
1337 .systems
1338 .iter()
1339 .find(|system| system.address == address)
1340 });
1341 let score_owned = matches!(
1342 &node.address,
1343 PageRenderAddress::Note(_) | PageRenderAddress::Spanner { .. }
1344 );
1345 if score_owned != node.system.is_some() || node.system.is_some() && system.is_none() {
1346 return Err(PrintLayoutError::InvalidRenderTreeNode { node_index });
1347 }
1348 let valid = match &node.address {
1349 PageRenderAddress::Note(address) => score
1350 .parts
1351 .get(address.part)
1352 .and_then(|part| part.staves.get(address.staff))
1353 .and_then(|staff| staff.measures.get(address.measure))
1354 .and_then(|measure| measure.voices.get(address.voice))
1355 .and_then(|voice| voice.get(address.note))
1356 .is_some_and(|note| {
1357 matches!(
1358 (&node.kind, note.is_rest),
1359 (PageRenderNodeKind::Note, false) | (PageRenderNodeKind::Rest, true)
1360 )
1361 }),
1362 PageRenderAddress::Spanner { id } => {
1363 matches!(&node.kind, PageRenderNodeKind::Spanner { .. })
1364 && score.spanners.iter().any(|span| span.id == *id)
1365 }
1366 PageRenderAddress::Publication {
1367 page_index,
1368 block_index,
1369 } => {
1370 matches!(&node.kind, PageRenderNodeKind::PublicationBlock)
1371 && *page_index == self.page.page_index
1372 && self
1373 .page
1374 .layout
1375 .publication
1376 .text_blocks
1377 .get(*block_index)
1378 .is_some()
1379 }
1380 PageRenderAddress::Resource {
1381 page_index,
1382 resource_key,
1383 } => {
1384 matches!(&node.kind, PageRenderNodeKind::Resource)
1385 && *page_index == self.page.page_index
1386 && self
1387 .page
1388 .layout
1389 .publication
1390 .image_resources
1391 .iter()
1392 .any(|image| image.resource_key == *resource_key)
1393 }
1394 PageRenderAddress::Frame {
1395 page_index,
1396 frame_index,
1397 } => {
1398 matches!(&node.kind, PageRenderNodeKind::Frame)
1399 && *page_index == self.page.page_index
1400 && self
1401 .page
1402 .layout
1403 .publication
1404 .frames
1405 .get(*frame_index)
1406 .is_some()
1407 }
1408 };
1409 if !valid {
1410 return Err(PrintLayoutError::InvalidRenderTreeNode { node_index });
1411 }
1412 let visible_in_view = match (&node.address, view) {
1413 (PageRenderAddress::Note(address), Some(view)) => {
1414 view.parts.contains(&address.part)
1415 && !view.layout.hidden_staves.iter().any(|hidden| {
1416 hidden.part == address.part && hidden.staff == address.staff
1417 })
1418 }
1419 (PageRenderAddress::Spanner { id }, Some(view)) => score
1420 .spanners
1421 .iter()
1422 .find(|spanner| spanner.id == *id)
1423 .is_some_and(|spanner| {
1424 let endpoint_is_visible = |part: usize, staff: usize| {
1425 view.parts.contains(&part)
1426 && !view
1427 .layout
1428 .hidden_staves
1429 .iter()
1430 .any(|hidden| hidden.part == part && hidden.staff == staff)
1431 };
1432 endpoint_is_visible(spanner.start.part, spanner.start.staff)
1433 && endpoint_is_visible(spanner.end.part, spanner.end.staff)
1434 }),
1435 _ => true,
1436 };
1437 if !visible_in_view {
1438 return Err(PrintLayoutError::InvalidRenderTreeNode { node_index });
1439 }
1440 }
1441 Ok(())
1442 }
1443}
1444
1445#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1447pub enum PageArtifactDiagnostic {
1448 GlyphResourceRequired,
1450 GlyphOverflow {
1452 left: bool,
1453 top: bool,
1454 right: bool,
1455 bottom: bool,
1456 },
1457 SpanContinuation {
1459 span_index: usize,
1460 starts_here: bool,
1461 ends_here: bool,
1462 },
1463}
1464
1465impl PageLayout {
1466 pub fn measure_span(&self) -> Option<MeasureSpan> {
1468 let mut spans = self
1469 .systems
1470 .iter()
1471 .flat_map(|system| system.measure_spans.iter().copied());
1472 let first = spans.next()?;
1473 Some(spans.fold(first, |range, span| MeasureSpan {
1474 first_measure: range.first_measure.min(span.first_measure),
1475 last_measure: range.last_measure.max(span.last_measure),
1476 }))
1477 }
1478
1479 pub fn has_span_continuation(&self) -> bool {
1481 self.span_segments
1482 .iter()
1483 .any(|segment| !segment.starts_here || !segment.ends_here)
1484 }
1485
1486 pub fn artifact_diagnostics(
1492 &self,
1493 glyph_extents: Option<GlyphExtents>,
1494 ) -> Vec<PageArtifactDiagnostic> {
1495 let mut diagnostics = Vec::new();
1496 if matches!(self.glyph_resources, GlyphResourcePolicy::HostProvided(_)) {
1497 diagnostics.push(PageArtifactDiagnostic::GlyphResourceRequired);
1498 }
1499 if let Some(extents) = glyph_extents {
1500 let overflow = PageArtifactDiagnostic::GlyphOverflow {
1501 left: extents.left_mm < 0.0,
1502 top: extents.top_mm < 0.0,
1503 right: extents.right_mm > self.content_width_mm,
1504 bottom: extents.bottom_mm > self.content_height_mm,
1505 };
1506 if let PageArtifactDiagnostic::GlyphOverflow {
1507 left,
1508 top,
1509 right,
1510 bottom,
1511 } = overflow
1512 && (left || top || right || bottom)
1513 {
1514 diagnostics.push(overflow);
1515 }
1516 }
1517 diagnostics.extend(
1518 self.span_segments
1519 .iter()
1520 .filter(|segment| !segment.starts_here || !segment.ends_here)
1521 .map(|segment| PageArtifactDiagnostic::SpanContinuation {
1522 span_index: segment.span_index,
1523 starts_here: segment.starts_here,
1524 ends_here: segment.ends_here,
1525 }),
1526 );
1527 diagnostics
1528 }
1529}
1530
1531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1533pub struct PrintLayoutResult {
1534 pub contract_version: u16,
1535 pub pages: Vec<PageLayout>,
1536}
1537
1538impl PrintLayoutResult {
1539 pub fn validate(&self) -> Result<(), PrintLayoutError> {
1544 if self.contract_version != PRINT_LAYOUT_CONTRACT_VERSION {
1545 return Err(PrintLayoutError::UnsupportedContractVersion {
1546 found: self.contract_version,
1547 });
1548 }
1549 let mut expected_system_index = 0;
1550 let mut previous_page_number = None;
1551 let mut numbered_pages = None;
1552 for (page_index, page) in self.pages.iter().enumerate() {
1553 if page.page_index != page_index || page.address.page_index != page_index {
1554 return Err(PrintLayoutError::InvalidPageAddress { page_index });
1555 }
1556 if !page.width_mm.is_finite()
1557 || !page.height_mm.is_finite()
1558 || page.width_mm <= 0.0
1559 || page.height_mm <= 0.0
1560 || !page.content_width_mm.is_finite()
1561 || !page.content_height_mm.is_finite()
1562 || page.content_width_mm <= 0.0
1563 || page.content_height_mm <= 0.0
1564 || page.content_width_mm > page.width_mm
1565 || page.content_height_mm > page.height_mm
1566 || !page.bleed_top_mm.is_finite()
1567 || !page.bleed_right_mm.is_finite()
1568 || !page.bleed_bottom_mm.is_finite()
1569 || !page.bleed_left_mm.is_finite()
1570 || page.bleed_top_mm < 0.0
1571 || page.bleed_right_mm < 0.0
1572 || page.bleed_bottom_mm < 0.0
1573 || page.bleed_left_mm < 0.0
1574 {
1575 return Err(PrintLayoutError::InvalidPageGeometry { page_index });
1576 }
1577 if page.publication.image_resources.iter().any(|image| {
1578 !publication_image_resource_is_valid(image, page.width_mm, page.height_mm)
1579 }) || page
1580 .publication
1581 .frames
1582 .iter()
1583 .any(|frame| !publication_frame_is_valid(frame, page.width_mm, page.height_mm))
1584 || !publication_page_sections_are_valid(&page.publication.sections)
1585 || !publication_page_spacers_are_valid(&page.publication.spacers)
1586 {
1587 return Err(PrintLayoutError::InvalidPublicationMetadata { page_index });
1588 }
1589 let is_title_break = page.break_reason == BreakReason::TitlePage;
1590 if is_title_break != page.publication.is_title_page
1591 || (is_title_break && (page_index != 0 || !page.systems.is_empty()))
1592 {
1593 return Err(PrintLayoutError::InvalidTitlePage { page_index });
1594 }
1595 match page.page_number {
1596 Some(page_number)
1597 if page_number == 0
1598 || numbered_pages == Some(false)
1599 || page_index.checked_add(1) != Some(page_number)
1600 || previous_page_number.is_some_and(|previous| page_number <= previous) =>
1601 {
1602 return Err(PrintLayoutError::InvalidPageNumber { page_index });
1603 }
1604 Some(page_number) => {
1605 numbered_pages = Some(true);
1606 previous_page_number = Some(page_number);
1607 }
1608 None if numbered_pages == Some(true) => {
1609 return Err(PrintLayoutError::InvalidPageNumber { page_index });
1610 }
1611 None => numbered_pages = Some(false),
1612 }
1613 for (index_on_page, system) in page.systems.iter().enumerate() {
1614 if system.page_index != page_index
1615 || system.address.page_index != page_index
1616 || system.address.index_on_page != index_on_page
1617 || system.system_index != expected_system_index
1618 || system.address.system_index != expected_system_index
1619 {
1620 return Err(PrintLayoutError::InvalidSystemAddress {
1621 page_index,
1622 index_on_page,
1623 system_index: expected_system_index,
1624 });
1625 }
1626 if !system.top_mm.is_finite()
1627 || system.top_mm < 0.0
1628 || !system.height_mm.is_finite()
1629 || system.height_mm <= 0.0
1630 {
1631 return Err(PrintLayoutError::InvalidSystemGeometry {
1632 page_index,
1633 index_on_page,
1634 });
1635 }
1636 expected_system_index += 1;
1637 }
1638 }
1639 Ok(())
1640 }
1641
1642 pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
1644 self.pages
1645 .get(address.page_index)
1646 .filter(|page| page.address == address)
1647 }
1648
1649 pub fn export_page_artifacts(&self) -> Result<Vec<PageArtifact>, PrintLayoutError> {
1655 self.validate()?;
1656 Ok(self
1657 .pages
1658 .iter()
1659 .map(|page| PageArtifact {
1660 address: page.address,
1661 page_index: page.page_index,
1662 page_number: page.page_number,
1663 width_mm: page.width_mm,
1664 height_mm: page.height_mm,
1665 content_width_mm: page.content_width_mm,
1666 content_height_mm: page.content_height_mm,
1667 measure_span: page.measure_span(),
1668 diagnostics: page.artifact_diagnostics(None),
1669 layout: page.clone(),
1670 })
1671 .collect())
1672 }
1673
1674 pub fn export_page_render_trees(
1679 &self,
1680 score: &Score,
1681 ) -> Result<Vec<PageRenderTree>, PrintLayoutError> {
1682 let artifacts = self.export_page_artifacts()?;
1683 Ok(self
1684 .pages
1685 .iter()
1686 .zip(artifacts)
1687 .map(|(page, artifact)| {
1688 let mut nodes = Vec::new();
1689 for system in &page.systems {
1690 for span in &system.measure_spans {
1691 for (part_index, part) in score.parts.iter().enumerate() {
1692 for (staff_index, staff) in part.staves.iter().enumerate() {
1693 for measure_index in span.first_measure..=span.last_measure {
1694 let Some(measure) = staff.measures.get(measure_index) else {
1695 continue;
1696 };
1697 for (voice, notes) in measure.voices.iter().enumerate() {
1698 for (note, value) in notes.iter().enumerate() {
1699 nodes.push(PageRenderNode {
1700 address: PageRenderAddress::Note(NoteAddr {
1701 part: part_index,
1702 staff: staff_index,
1703 measure: measure_index,
1704 voice,
1705 note,
1706 }),
1707 kind: if value.is_rest {
1708 PageRenderNodeKind::Rest
1709 } else {
1710 PageRenderNodeKind::Note
1711 },
1712 system: Some(system.address),
1713 });
1714 }
1715 }
1716 }
1717 }
1718 }
1719 }
1720 for segment in &system.span_segments {
1721 if let Some(spanner) = score.spanners.get(segment.span_index) {
1722 nodes.push(PageRenderNode {
1723 address: PageRenderAddress::Spanner {
1724 id: spanner.id.clone(),
1725 },
1726 kind: PageRenderNodeKind::Spanner {
1727 starts_here: segment.starts_here,
1728 ends_here: segment.ends_here,
1729 },
1730 system: Some(system.address),
1731 });
1732 }
1733 }
1734 }
1735 for (block_index, _) in page.publication.text_blocks.iter().enumerate() {
1736 nodes.push(PageRenderNode {
1737 address: PageRenderAddress::Publication {
1738 page_index: page.page_index,
1739 block_index,
1740 },
1741 kind: PageRenderNodeKind::PublicationBlock,
1742 system: None,
1743 });
1744 }
1745 for image in &page.publication.image_resources {
1746 nodes.push(PageRenderNode {
1747 address: PageRenderAddress::Resource {
1748 page_index: page.page_index,
1749 resource_key: image.resource_key.clone(),
1750 },
1751 kind: PageRenderNodeKind::Resource,
1752 system: None,
1753 });
1754 }
1755 for (frame_index, _) in page.publication.frames.iter().enumerate() {
1756 nodes.push(PageRenderNode {
1757 address: PageRenderAddress::Frame {
1758 page_index: page.page_index,
1759 frame_index,
1760 },
1761 kind: PageRenderNodeKind::Frame,
1762 system: None,
1763 });
1764 }
1765 PageRenderTree {
1766 contract_version: PAGE_RENDER_TREE_CONTRACT_VERSION,
1767 view_id: None,
1768 page: artifact,
1769 nodes,
1770 }
1771 })
1772 .collect())
1773 }
1774
1775 pub fn export_page_render_trees_for_view(
1777 &self,
1778 score: &Score,
1779 view_id: &str,
1780 ) -> Result<Vec<PageRenderTree>, PrintLayoutError> {
1781 let view = score
1782 .views
1783 .iter()
1784 .find(|view| view.id == view_id)
1785 .ok_or(PrintLayoutError::InvalidView)?;
1786 let selected_parts = &view.parts;
1787 let hidden_staves = &view.layout.hidden_staves;
1788 let staff_is_visible = |part: usize, staff: usize| {
1789 selected_parts.contains(&part)
1790 && !hidden_staves
1791 .iter()
1792 .any(|hidden| hidden.part == part && hidden.staff == staff)
1793 };
1794 let mut trees = self.export_page_render_trees(score)?;
1795 for tree in &mut trees {
1796 tree.view_id = Some(view.id.clone());
1797 tree.nodes.retain(|node| match &node.address {
1798 PageRenderAddress::Note(address) => staff_is_visible(address.part, address.staff),
1799 PageRenderAddress::Spanner { id } => score
1800 .spanners
1801 .iter()
1802 .find(|spanner| spanner.id == *id)
1803 .is_some_and(|spanner| {
1804 staff_is_visible(spanner.start.part, spanner.start.staff)
1805 && staff_is_visible(spanner.end.part, spanner.end.staff)
1806 }),
1807 PageRenderAddress::Publication { .. } => true,
1808 PageRenderAddress::Resource { .. } => true,
1809 PageRenderAddress::Frame { .. } => true,
1810 });
1811 }
1812 Ok(trees)
1813 }
1814}
1815
1816#[derive(Debug, thiserror::Error, PartialEq)]
1817pub enum PrintLayoutError {
1818 #[error("unsupported page render tree contract version {found}")]
1819 UnsupportedPageRenderTreeContractVersion { found: u16 },
1820 #[error("page render tree node {node_index} has invalid ownership or address")]
1821 InvalidRenderTreeNode { node_index: usize },
1822 #[error("unknown score view")]
1823 InvalidView,
1824 #[error("paper dimensions must be finite and greater than zero")]
1825 InvalidPaperDimensions,
1826 #[error("margins must be finite and non-negative")]
1827 InvalidMargins,
1828 #[error("system height must be finite and greater than zero")]
1829 InvalidSystemHeight,
1830 #[error("print scale must be finite and greater than zero")]
1831 InvalidScale,
1832 #[error("margins leave no usable page area")]
1833 NoUsablePageArea,
1834 #[error("keep-together range is outside the score or reversed")]
1835 InvalidKeepTogetherRange,
1836 #[error("keep-together range exceeds the measures-per-system capacity")]
1837 KeepTogetherExceedsSystemCapacity,
1838 #[error("keep-together range conflicts with an explicit system or page break")]
1839 KeepTogetherConflictsWithExplicitBreak,
1840 #[error("repeat section exceeds the systems-per-page capacity")]
1841 RepeatRangeExceedsPageCapacity,
1842 #[error("extracted part index is outside the score")]
1843 InvalidPartIndex,
1844 #[error("publication line height must be finite and greater than zero")]
1845 InvalidPublicationLineHeight,
1846 #[error("host-provided glyph resource key must not be empty")]
1847 InvalidGlyphResourceKey,
1848 #[error("publication image resource {index} is invalid")]
1849 InvalidPublicationImageResource { index: usize },
1850 #[error("publication section {index} is invalid")]
1851 InvalidPublicationSection { index: usize },
1852 #[error("publication section {index} starts inside a keep-together range")]
1853 PublicationSectionConflictsWithKeepTogether { index: usize },
1854 #[error("publication spacer {index} is invalid or cannot fit before one system")]
1855 InvalidPublicationSpacer { index: usize },
1856 #[error("publication frame {index} is invalid")]
1857 InvalidPublicationFrame { index: usize },
1858 #[error("unsupported print layout contract version {found}")]
1859 UnsupportedContractVersion { found: u16 },
1860 #[error("page {page_index} has an inconsistent stable address")]
1861 InvalidPageAddress { page_index: usize },
1862 #[error("page {page_index} has an invalid or non-monotonic page number")]
1863 InvalidPageNumber { page_index: usize },
1864 #[error("page {page_index} has inconsistent title-page metadata")]
1865 InvalidTitlePage { page_index: usize },
1866 #[error(
1867 "system {system_index} at page {page_index}, position {index_on_page} has an inconsistent stable address"
1868 )]
1869 InvalidSystemAddress {
1870 page_index: usize,
1871 index_on_page: usize,
1872 system_index: usize,
1873 },
1874 #[error("page {page_index} has invalid physical geometry")]
1875 InvalidPageGeometry { page_index: usize },
1876 #[error("page {page_index} has invalid publication metadata")]
1877 InvalidPublicationMetadata { page_index: usize },
1878 #[error("system at page {page_index}, position {index_on_page} has invalid physical geometry")]
1879 InvalidSystemGeometry {
1880 page_index: usize,
1881 index_on_page: usize,
1882 },
1883}
1884
1885fn apply_keep_together(
1886 score: &Score,
1887 mut rows: Vec<crate::RowLayout>,
1888 ranges: &[KeepTogetherRange],
1889 capacity: usize,
1890) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
1891 let measure_count = score
1892 .parts
1893 .first()
1894 .and_then(|part| part.staves.first())
1895 .map(|staff| staff.measures.len())
1896 .unwrap_or(0);
1897 for range in ranges {
1898 let length = range
1899 .last_measure
1900 .checked_sub(range.first_measure)
1901 .and_then(|length| length.checked_add(1));
1902 if range.first_measure > range.last_measure || range.last_measure >= measure_count {
1903 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1904 }
1905 if length.is_none_or(|length| length > capacity) {
1906 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1907 }
1908 for measure_index in range.first_measure..range.last_measure {
1909 let has_break = score
1910 .parts
1911 .iter()
1912 .flat_map(|part| part.staves.iter())
1913 .filter_map(|staff| staff.measures.get(measure_index))
1914 .any(|measure| measure.system_break || measure.page_break);
1915 if has_break {
1916 return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
1917 }
1918 }
1919
1920 let mut split_rows = Vec::with_capacity(rows.len() + 2);
1924 for row in rows {
1925 let mut cuts = vec![0, row.measure_indices.len()];
1926 if let Some(position) = row
1927 .measure_indices
1928 .iter()
1929 .position(|&index| index == range.first_measure)
1930 {
1931 cuts.push(position);
1932 }
1933 if let Some(position) = row
1934 .measure_indices
1935 .iter()
1936 .position(|&index| index == range.last_measure)
1937 {
1938 cuts.push(position + 1);
1939 }
1940 cuts.sort_unstable();
1941 cuts.dedup();
1942 for window in cuts.windows(2) {
1943 if window[0] < window[1] {
1944 split_rows.push(crate::RowLayout {
1945 measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
1946 });
1947 }
1948 }
1949 }
1950 rows = split_rows;
1951
1952 let first_row = rows
1953 .iter()
1954 .position(|row| row.measure_indices.contains(&range.first_measure));
1955 let last_row = rows
1956 .iter()
1957 .position(|row| row.measure_indices.contains(&range.last_measure));
1958 let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
1959 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1960 };
1961
1962 if first_row != last_row {
1963 let merged: Vec<usize> = rows[first_row..=last_row]
1964 .iter()
1965 .flat_map(|row| row.measure_indices.iter().copied())
1966 .collect();
1967 if merged.len() > capacity {
1968 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1969 }
1970 rows.splice(
1971 first_row..=last_row,
1972 [crate::RowLayout {
1973 measure_indices: merged,
1974 }],
1975 );
1976 }
1977
1978 let row_index = rows
1979 .iter()
1980 .position(|row| row.measure_indices.contains(&range.first_measure))
1981 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1982 let row = rows.remove(row_index);
1983 let start = row
1984 .measure_indices
1985 .iter()
1986 .position(|&index| index == range.first_measure)
1987 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1988 let end = row
1989 .measure_indices
1990 .iter()
1991 .position(|&index| index == range.last_measure)
1992 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1993 let mut replacement = Vec::new();
1994 if start > 0 {
1995 replacement.push(crate::RowLayout {
1996 measure_indices: row.measure_indices[..start].to_vec(),
1997 });
1998 }
1999 replacement.push(crate::RowLayout {
2000 measure_indices: row.measure_indices[start..=end].to_vec(),
2001 });
2002 if end + 1 < row.measure_indices.len() {
2003 replacement.push(crate::RowLayout {
2004 measure_indices: row.measure_indices[end + 1..].to_vec(),
2005 });
2006 }
2007 rows.splice(row_index..row_index, replacement);
2008 }
2009 Ok(rows)
2010}
2011
2012fn has_first_measure_pickup(score: &Score) -> bool {
2013 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2014 return false;
2015 };
2016 let Some(measure) = staff.measures.first() else {
2017 return false;
2018 };
2019 if let Some(length) = measure.actual_length.and_then(|length| length.beats()) {
2021 let bar = measure
2022 .time_sig
2023 .as_ref()
2024 .unwrap_or(&score.settings.time_signature)
2025 .total_beats();
2026 return length < bar - 1e-9;
2027 }
2028 let expected = measure
2029 .time_sig
2030 .as_ref()
2031 .unwrap_or(&score.settings.time_signature)
2032 .total_beats();
2033 let actual = measure
2034 .voices
2035 .iter()
2036 .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
2037 .fold(0.0, f64::max);
2038 actual > 1e-9 && actual + 1e-9 < expected
2039}
2040
2041fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
2042 let measure_count = score
2043 .parts
2044 .first()
2045 .and_then(|part| part.staves.first())
2046 .map(|staff| staff.measures.len())
2047 .unwrap_or(0);
2048 measure_indices
2049 .iter()
2050 .filter_map(|&first_measure| {
2051 if first_measure >= measure_count {
2052 return None;
2053 }
2054 let count = score
2055 .parts
2056 .iter()
2057 .flat_map(|part| part.staves.iter())
2058 .filter_map(|staff| staff.measures.get(first_measure))
2059 .filter_map(|measure| measure.multi_rest_count)
2060 .map(usize::from)
2061 .max()
2062 .unwrap_or(1)
2063 .max(1);
2064 Some(MeasureSpan {
2065 first_measure,
2066 last_measure: first_measure
2067 .saturating_add(count.saturating_sub(1))
2068 .min(measure_count.saturating_sub(1)),
2069 })
2070 })
2071 .collect()
2072}
2073
2074fn span_bounds(span: &SpanMark) -> (usize, usize) {
2075 match span {
2076 SpanMark::Hairpin { start, end, .. }
2077 | SpanMark::Ottava { start, end, .. }
2078 | SpanMark::Pedal { start, end }
2079 | SpanMark::Slur { start, end }
2080 | SpanMark::TrillLine { start, end }
2081 | SpanMark::Glissando { start, end }
2082 | SpanMark::Harmony { start, end, .. } => (
2083 start.measure.min(end.measure),
2084 start.measure.max(end.measure),
2085 ),
2086 }
2087}
2088
2089fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
2090 let (Some(&first_measure), Some(&last_measure)) =
2091 (measure_indices.first(), measure_indices.last())
2092 else {
2093 return Vec::new();
2094 };
2095 spans
2096 .iter()
2097 .enumerate()
2098 .filter_map(|(span_index, span)| {
2099 let (start_measure, end_measure) = span_bounds(span);
2100 (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
2101 span_index,
2102 starts_here: (first_measure..=last_measure).contains(&start_measure),
2103 ends_here: (first_measure..=last_measure).contains(&end_measure),
2104 })
2105 })
2106 .collect()
2107}
2108
2109fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
2110 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2111 return Vec::new();
2112 };
2113 measure_indices
2114 .iter()
2115 .filter_map(|&measure_index| {
2116 let measure = staff.measures.get(measure_index)?;
2117 let repeat_start = matches!(
2118 measure.barline_left,
2119 Barline::RepeatStart | Barline::RepeatBoth
2120 );
2121 let repeat_end = matches!(
2122 measure.barline_right,
2123 Barline::RepeatEnd | Barline::RepeatBoth
2124 );
2125 let text_annotations = measure_text_entries(measure);
2126 let has_mark = repeat_start
2127 || repeat_end
2128 || measure.volta.is_some()
2129 || measure.navigation.is_some()
2130 || measure.rehearsal.is_some()
2131 || !text_annotations.is_empty();
2132 has_mark.then(|| MeasureMark {
2133 measure_index,
2134 repeat_start,
2135 repeat_end,
2136 volta_number: measure.volta.as_ref().map(|volta| volta.number),
2137 volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
2138 navigation: measure.navigation.clone(),
2139 rehearsal: measure.rehearsal.clone(),
2140 text_annotations,
2141 })
2142 })
2143 .collect()
2144}
2145
2146fn measure_text_entries(measure: &acorde_core::Measure) -> Vec<StyledText> {
2147 let mut entries = measure.texts.clone();
2148 for (style, text) in [
2149 (TextStyle::Generic, measure.tempo_text.as_deref()),
2150 (TextStyle::RehearsalMark, measure.rehearsal.as_deref()),
2151 (TextStyle::Generic, measure.navigation.as_deref()),
2152 (TextStyle::Expression, measure.expression_text.as_deref()),
2153 ] {
2154 let Some(text) = text else {
2155 continue;
2156 };
2157 if entries
2158 .iter()
2159 .any(|entry| entry.style == style && entry.text == text)
2160 {
2161 continue;
2162 }
2163 entries.push(StyledText {
2164 style,
2165 text: text.to_owned(),
2166 placement: None,
2167 offset_x: None,
2168 offset_y: None,
2169 relative_x: None,
2170 relative_y: None,
2171 });
2172 }
2173 entries
2174}
2175
2176fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2177 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2178 return Vec::new();
2179 };
2180 let mut ranges = Vec::new();
2181 let mut start = None;
2182 for (index, measure) in staff.measures.iter().enumerate() {
2183 let Some(volta) = measure.volta.as_ref() else {
2184 continue;
2185 };
2186 if matches!(volta.kind.as_str(), "begin" | "begin_end") {
2187 start = Some(index);
2188 }
2189 if matches!(volta.kind.as_str(), "end" | "begin_end")
2190 && let Some(first_measure) = start.take()
2191 {
2192 ranges.push(KeepTogetherRange {
2193 first_measure,
2194 last_measure: index,
2195 });
2196 }
2197 }
2198 ranges
2199}
2200
2201fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2202 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2203 return Vec::new();
2204 };
2205 let mut ranges = Vec::new();
2206 let mut start = None;
2207 for (index, measure) in staff.measures.iter().enumerate() {
2208 if matches!(
2209 measure.barline_left,
2210 Barline::RepeatStart | Barline::RepeatBoth
2211 ) {
2212 start = Some(index);
2213 }
2214 if matches!(
2215 measure.barline_right,
2216 Barline::RepeatEnd | Barline::RepeatBoth
2217 ) {
2218 ranges.push(KeepTogetherRange {
2219 first_measure: start.take().unwrap_or(0),
2220 last_measure: index,
2221 });
2222 }
2223 }
2224 ranges
2225}
2226
2227fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
2228 repeat_ranges(score)
2229 .into_iter()
2230 .filter_map(|range| {
2231 let first = rows
2232 .iter()
2233 .position(|row| row.measure_indices.contains(&range.first_measure))?;
2234 let last = rows
2235 .iter()
2236 .position(|row| row.measure_indices.contains(&range.last_measure))?;
2237 Some((first, last))
2238 })
2239 .collect()
2240}
2241
2242fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
2243 let mut segments = Vec::new();
2244 for system in systems {
2245 for segment in &system.span_segments {
2246 if let Some(existing) = segments
2247 .iter_mut()
2248 .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
2249 {
2250 existing.ends_here |= segment.ends_here;
2251 } else {
2252 segments.push(PageSpanSegment {
2253 span_index: segment.span_index,
2254 starts_here: segment.starts_here,
2255 ends_here: segment.ends_here,
2256 });
2257 }
2258 }
2259 }
2260 segments
2261}
2262
2263fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
2264 systems
2265 .iter()
2266 .flat_map(|system| system.measure_marks.iter().cloned())
2267 .collect()
2268}
2269
2270fn page_publication(
2271 score: &Score,
2272 measure_score: &Score,
2273 config: &PrintConfig,
2274 systems: &[SystemLayout],
2275 is_title_page: bool,
2276 page_number: Option<usize>,
2277) -> PagePublication {
2278 let metadata = &score.metadata;
2279 let part_labels = if config.publication.show_part_names {
2280 let parts = match config.part_layout {
2281 PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
2282 PartLayoutPolicy::ExtractedPart { part_index } => score
2283 .parts
2284 .get(part_index)
2285 .into_iter()
2286 .enumerate()
2287 .map(|(index, part)| (part_index + index, part))
2288 .collect(),
2289 };
2290 parts
2291 .into_iter()
2292 .map(|(part_index, part)| PartLabel {
2293 part_index,
2294 name: part.name.clone(),
2295 short_name: part.short_name.clone(),
2296 })
2297 .collect()
2298 } else {
2299 Vec::new()
2300 };
2301 let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
2302 score
2303 .part_groups
2304 .iter()
2305 .map(|group| PartGroupMark {
2306 first_part: group.first_part,
2307 last_part: group.last_part,
2308 symbol: group.symbol.clone(),
2309 barlines_connect: group.barlines_connect,
2310 })
2311 .collect()
2312 } else {
2313 Vec::new()
2314 };
2315 let measure_numbers = if config.publication.show_measure_numbers {
2316 let staff = measure_score
2317 .parts
2318 .first()
2319 .and_then(|part| part.staves.first());
2320 systems
2321 .iter()
2322 .flat_map(|system| system.measure_indices.iter().copied())
2323 .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
2324 .map(|measure| measure.number)
2325 .collect()
2326 } else {
2327 Vec::new()
2328 };
2329 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2330 let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
2331 (paper_height, paper_width)
2332 } else {
2333 (paper_width, paper_height)
2334 };
2335 let mut text_blocks = Vec::new();
2336 let header_text = if config.publication.header_template.is_configured() {
2337 config.publication.header_template.resolve(page_number)
2338 } else {
2339 config
2340 .publication
2341 .header_text
2342 .as_ref()
2343 .or(config.publication.running_title.as_ref())
2344 };
2345 if !is_title_page && let Some(text) = header_text {
2346 text_blocks.push(PublicationTextBlock {
2347 role: PublicationTextRole::Header,
2348 text: text.clone(),
2349 x_mm: config.margin_left_mm + config.safe_left_mm,
2350 y_mm: config.margin_top_mm,
2351 width_mm: page_width
2352 - config.margin_left_mm
2353 - config.margin_right_mm
2354 - config.safe_left_mm
2355 - config.safe_right_mm,
2356 height_mm: config.publication.line_height_mm,
2357 alignment: config.publication.header_alignment,
2358 });
2359 }
2360 let footer_text = if config.publication.footer_template.is_configured() {
2361 config.publication.footer_template.resolve(page_number)
2362 } else {
2363 config.publication.footer_text.as_ref()
2364 };
2365 if let Some(text) = footer_text {
2366 text_blocks.push(PublicationTextBlock {
2367 role: PublicationTextRole::Footer,
2368 text: text.clone(),
2369 x_mm: config.margin_left_mm + config.safe_left_mm,
2370 y_mm: page_height - config.margin_bottom_mm,
2371 width_mm: page_width
2372 - config.margin_left_mm
2373 - config.margin_right_mm
2374 - config.safe_left_mm
2375 - config.safe_right_mm,
2376 height_mm: config.publication.line_height_mm,
2377 alignment: config.publication.footer_alignment,
2378 });
2379 }
2380 if config.publication.page_number_in_footer {
2381 if let Some(page_number) = page_number {
2382 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2383 let (page_width, page_height) =
2384 if matches!(config.orientation, PageOrientation::Landscape) {
2385 (paper_height, paper_width)
2386 } else {
2387 (paper_width, paper_height)
2388 };
2389 text_blocks.push(PublicationTextBlock {
2390 role: PublicationTextRole::Footer,
2391 text: page_number.to_string(),
2392 x_mm: config.margin_left_mm + config.safe_left_mm,
2393 y_mm: page_height - config.margin_bottom_mm,
2394 width_mm: page_width
2395 - config.margin_left_mm
2396 - config.margin_right_mm
2397 - config.safe_left_mm
2398 - config.safe_right_mm,
2399 height_mm: config.publication.line_height_mm,
2400 alignment: config.publication.footer_alignment,
2401 });
2402 }
2403 }
2404 if is_title_page {
2405 let content_height = page_height
2406 - config.margin_top_mm
2407 - config.margin_bottom_mm
2408 - config.safe_top_mm
2409 - config.safe_bottom_mm;
2410 let title_x = config.margin_left_mm + config.safe_left_mm;
2411 let title_width = page_width
2412 - config.margin_left_mm
2413 - config.margin_right_mm
2414 - config.safe_left_mm
2415 - config.safe_right_mm;
2416 let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
2417 if !metadata.title.trim().is_empty() {
2418 text_blocks.push(PublicationTextBlock {
2419 role: PublicationTextRole::Title,
2420 text: metadata.title.clone(),
2421 x_mm: title_x,
2422 y_mm: title_y,
2423 width_mm: title_width,
2424 height_mm: config.publication.line_height_mm,
2425 alignment: config.publication.title_alignment,
2426 });
2427 }
2428 if !metadata.movement_title.trim().is_empty() {
2429 text_blocks.push(PublicationTextBlock {
2430 role: PublicationTextRole::Subtitle,
2431 text: metadata.movement_title.clone(),
2432 x_mm: title_x,
2433 y_mm: title_y + config.publication.line_height_mm * 2.5,
2434 width_mm: title_width,
2435 height_mm: config.publication.line_height_mm,
2436 alignment: config.publication.title_alignment,
2437 });
2438 }
2439 let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
2440 (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
2441 format!("{composer} / {lyricist}")
2442 }
2443 (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
2444 (_, lyricist) => lyricist.to_string(),
2445 };
2446 if !credit.is_empty() {
2447 text_blocks.push(PublicationTextBlock {
2448 role: PublicationTextRole::Credit,
2449 text: credit,
2450 x_mm: title_x,
2451 y_mm: title_y + config.publication.line_height_mm * 5.0,
2452 width_mm: title_width,
2453 height_mm: config.publication.line_height_mm,
2454 alignment: config.publication.title_alignment,
2455 });
2456 }
2457 if !metadata.copyright.trim().is_empty() {
2458 text_blocks.push(PublicationTextBlock {
2459 role: PublicationTextRole::Copyright,
2460 text: metadata.copyright.clone(),
2461 x_mm: title_x,
2462 y_mm: page_height - config.margin_bottom_mm,
2463 width_mm: title_width,
2464 height_mm: config.publication.line_height_mm,
2465 alignment: config.publication.title_alignment,
2466 });
2467 }
2468 }
2469 let image_resources = config
2470 .publication
2471 .image_resources
2472 .iter()
2473 .filter(|image| {
2474 matches!(
2475 (is_title_page, image.placement),
2476 (
2477 true,
2478 PublicationImagePlacement::TitlePage | PublicationImagePlacement::EveryPage
2479 ) | (
2480 false,
2481 PublicationImagePlacement::MusicPages | PublicationImagePlacement::EveryPage
2482 )
2483 )
2484 })
2485 .cloned()
2486 .collect();
2487 let sections = if is_title_page {
2488 Vec::new()
2489 } else {
2490 config
2491 .publication
2492 .sections
2493 .iter()
2494 .filter(|section| {
2495 systems
2496 .iter()
2497 .any(|system| system.measure_indices.contains(§ion.first_measure))
2498 })
2499 .cloned()
2500 .collect()
2501 };
2502 let spacers = if is_title_page {
2503 Vec::new()
2504 } else {
2505 config
2506 .publication
2507 .spacers
2508 .iter()
2509 .filter(|spacer| {
2510 systems
2511 .iter()
2512 .any(|system| system.measure_indices.contains(&spacer.before_measure))
2513 })
2514 .cloned()
2515 .collect()
2516 };
2517 let frames = config
2518 .publication
2519 .frames
2520 .iter()
2521 .filter(|frame| {
2522 matches!(
2523 (is_title_page, frame.placement),
2524 (
2525 true,
2526 PublicationFramePlacement::TitlePage | PublicationFramePlacement::EveryPage
2527 ) | (
2528 false,
2529 PublicationFramePlacement::MusicPages | PublicationFramePlacement::EveryPage
2530 )
2531 )
2532 })
2533 .cloned()
2534 .collect();
2535 PagePublication {
2536 is_title_page,
2537 title: metadata.title.clone(),
2538 movement_title: metadata.movement_title.clone(),
2539 composer: metadata.composer.clone(),
2540 lyricist: metadata.lyricist.clone(),
2541 copyright: metadata.copyright.clone(),
2542 running_title: config.publication.running_title.clone(),
2543 score_texts: score.texts.clone(),
2544 part_labels,
2545 part_groups,
2546 measure_numbers,
2547 text_blocks,
2548 image_resources,
2549 sections,
2550 spacers,
2551 frames,
2552 }
2553}
2554
2555#[allow(clippy::too_many_arguments)]
2556fn build_page_layout(
2557 score: &Score,
2558 layout_score: &Score,
2559 config: &PrintConfig,
2560 systems: Vec<SystemLayout>,
2561 page_index: usize,
2562 page_number: Option<usize>,
2563 width_mm: f32,
2564 height_mm: f32,
2565 content_width_mm: f32,
2566 content_height_mm: f32,
2567 break_reason: BreakReason,
2568 is_title_page: bool,
2569) -> PageLayout {
2570 let publication = page_publication(
2571 score,
2572 layout_score,
2573 config,
2574 &systems,
2575 is_title_page,
2576 page_number,
2577 );
2578 let span_segments = if is_title_page {
2579 Vec::new()
2580 } else {
2581 page_span_segments(&systems)
2582 };
2583 let measure_marks = if is_title_page {
2584 Vec::new()
2585 } else {
2586 page_measure_marks(&systems)
2587 };
2588 PageLayout {
2589 address: PageAddress { page_index },
2590 page_index,
2591 page_number,
2592 color_policy: config.color_policy,
2593 crop_mark_policy: config.crop_mark_policy,
2594 glyph_resources: config.glyph_resources.clone(),
2595 publication,
2596 width_mm,
2597 height_mm,
2598 content_width_mm,
2599 content_height_mm,
2600 bleed_top_mm: config.bleed_top_mm,
2601 bleed_right_mm: config.bleed_right_mm,
2602 bleed_bottom_mm: config.bleed_bottom_mm,
2603 bleed_left_mm: config.bleed_left_mm,
2604 span_segments,
2605 measure_marks,
2606 systems,
2607 break_reason,
2608 }
2609}
2610
2611fn score_for_part_layout(
2612 score: &Score,
2613 policy: PartLayoutPolicy,
2614) -> Result<Score, PrintLayoutError> {
2615 let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
2616 return Ok(score.clone());
2617 };
2618 let Some(part) = score.parts.get(part_index) else {
2619 return Err(PrintLayoutError::InvalidPartIndex);
2620 };
2621 let mut selected = score.clone();
2622 selected.parts = vec![part.clone()];
2623 selected.part_groups.clear();
2624 Ok(selected)
2625}
2626
2627fn validate_publication_sections(
2628 score: &Score,
2629 sections: &[PublicationSection],
2630 keep_together: &[KeepTogetherRange],
2631) -> Result<(), PrintLayoutError> {
2632 let measure_count = score
2633 .parts
2634 .first()
2635 .and_then(|part| part.staves.first())
2636 .map_or(0, |staff| staff.measures.len());
2637 let mut previous_start = None;
2638 for (index, section) in sections.iter().enumerate() {
2639 if section.first_measure >= measure_count
2640 || section.title.trim().is_empty()
2641 || section.title.len() > 1024
2642 || previous_start.is_some_and(|previous| previous >= section.first_measure)
2643 {
2644 return Err(PrintLayoutError::InvalidPublicationSection { index });
2645 }
2646 if keep_together.iter().any(|range| {
2647 range.first_measure < section.first_measure
2648 && section.first_measure <= range.last_measure
2649 }) {
2650 return Err(PrintLayoutError::PublicationSectionConflictsWithKeepTogether { index });
2651 }
2652 previous_start = Some(section.first_measure);
2653 }
2654 Ok(())
2655}
2656
2657fn validate_publication_spacers(
2658 score: &Score,
2659 spacers: &[PublicationSpacer],
2660 keep_together: &[KeepTogetherRange],
2661 content_height_mm: f32,
2662 scaled_system_height_mm: f32,
2663) -> Result<(), PrintLayoutError> {
2664 let measure_count = score
2665 .parts
2666 .first()
2667 .and_then(|part| part.staves.first())
2668 .map_or(0, |staff| staff.measures.len());
2669 let mut previous_measure = None;
2670 for (index, spacer) in spacers.iter().enumerate() {
2671 if spacer.before_measure >= measure_count
2672 || !spacer.height_mm.is_finite()
2673 || spacer.height_mm <= 0.0
2674 || spacer.height_mm + scaled_system_height_mm > content_height_mm
2675 || previous_measure.is_some_and(|previous| previous >= spacer.before_measure)
2676 || keep_together.iter().any(|range| {
2677 range.first_measure < spacer.before_measure
2678 && spacer.before_measure <= range.last_measure
2679 })
2680 {
2681 return Err(PrintLayoutError::InvalidPublicationSpacer { index });
2682 }
2683 previous_measure = Some(spacer.before_measure);
2684 }
2685 Ok(())
2686}
2687
2688fn spacer_height_before_measure(spacers: &[PublicationSpacer], measure_index: usize) -> f32 {
2689 spacers
2690 .iter()
2691 .find(|spacer| spacer.before_measure == measure_index)
2692 .map_or(0.0, |spacer| spacer.height_mm)
2693}
2694
2695fn split_rows_at_measure_starts(
2696 rows: Vec<crate::RowLayout>,
2697 starts: &[usize],
2698) -> Vec<crate::RowLayout> {
2699 if starts.is_empty() {
2700 return rows;
2701 }
2702 let mut split_rows = Vec::with_capacity(rows.len() + starts.len());
2703 for row in rows {
2704 let mut cuts = vec![0, row.measure_indices.len()];
2705 for start in starts {
2706 if let Some(position) = row.measure_indices.iter().position(|index| index == start) {
2707 cuts.push(position);
2708 }
2709 }
2710 cuts.sort_unstable();
2711 cuts.dedup();
2712 for window in cuts.windows(2) {
2713 if window[0] < window[1] {
2714 split_rows.push(crate::RowLayout {
2715 measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
2716 });
2717 }
2718 }
2719 }
2720 split_rows
2721}
2722
2723fn publication_image_resource_is_valid(
2724 image: &PublicationImageResource,
2725 width_mm: f32,
2726 height_mm: f32,
2727) -> bool {
2728 let safe_key = !image.resource_key.trim().is_empty()
2729 && image.resource_key.len() <= 256
2730 && !image.resource_key.contains("..")
2731 && !image.resource_key.contains(['/', '\\', ':']);
2732 let safe_alt_text = !image.alt_text.trim().is_empty() && image.alt_text.len() <= 4096;
2733 let finite_geometry = [image.x_mm, image.y_mm, image.width_mm, image.height_mm]
2734 .iter()
2735 .all(|value| value.is_finite());
2736 let fits_page = image.x_mm >= 0.0
2737 && image.y_mm >= 0.0
2738 && image.width_mm > 0.0
2739 && image.height_mm > 0.0
2740 && image.x_mm + image.width_mm <= width_mm
2741 && image.y_mm + image.height_mm <= height_mm;
2742 safe_key && safe_alt_text && finite_geometry && fits_page
2743}
2744
2745fn publication_frame_is_valid(frame: &PublicationFrame, width_mm: f32, height_mm: f32) -> bool {
2746 let finite_geometry = [
2747 frame.x_mm,
2748 frame.y_mm,
2749 frame.width_mm,
2750 frame.height_mm,
2751 frame.stroke_width_mm,
2752 ]
2753 .iter()
2754 .all(|value| value.is_finite());
2755 let fits_page = frame.x_mm >= 0.0
2756 && frame.y_mm >= 0.0
2757 && frame.width_mm > 0.0
2758 && frame.height_mm > 0.0
2759 && frame.stroke_width_mm > 0.0
2760 && frame.x_mm + frame.width_mm <= width_mm
2761 && frame.y_mm + frame.height_mm <= height_mm;
2762 finite_geometry && fits_page
2763}
2764
2765fn publication_page_sections_are_valid(sections: &[PublicationSection]) -> bool {
2766 sections.iter().enumerate().all(|(index, section)| {
2767 !section.title.trim().is_empty()
2768 && section.title.len() <= 1024
2769 && (index == 0 || sections[index - 1].first_measure < section.first_measure)
2770 })
2771}
2772
2773fn publication_page_spacers_are_valid(spacers: &[PublicationSpacer]) -> bool {
2774 spacers.iter().enumerate().all(|(index, spacer)| {
2775 spacer.height_mm.is_finite()
2776 && spacer.height_mm > 0.0
2777 && (index == 0 || spacers[index - 1].before_measure < spacer.before_measure)
2778 })
2779}
2780
2781fn validate_print_config(config: &PrintConfig) -> Result<(f32, f32, f32), PrintLayoutError> {
2782 let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
2783 if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
2784 return Err(PrintLayoutError::InvalidPaperDimensions);
2785 }
2786 if matches!(config.orientation, PageOrientation::Landscape) {
2787 std::mem::swap(&mut width_mm, &mut height_mm);
2788 }
2789
2790 let margins = [
2791 config.margin_top_mm,
2792 config.margin_right_mm,
2793 config.margin_bottom_mm,
2794 config.margin_left_mm,
2795 config.bleed_top_mm,
2796 config.bleed_right_mm,
2797 config.bleed_bottom_mm,
2798 config.bleed_left_mm,
2799 config.safe_top_mm,
2800 config.safe_right_mm,
2801 config.safe_bottom_mm,
2802 config.safe_left_mm,
2803 ];
2804 if margins
2805 .iter()
2806 .any(|value| !value.is_finite() || *value < 0.0)
2807 {
2808 return Err(PrintLayoutError::InvalidMargins);
2809 }
2810 if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
2811 return Err(PrintLayoutError::InvalidSystemHeight);
2812 }
2813 if !config.scale.is_finite() || config.scale <= 0.0 {
2814 return Err(PrintLayoutError::InvalidScale);
2815 }
2816 let scaled_system_height_mm = config.system_height_mm * config.scale;
2817 if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
2818 return Err(PrintLayoutError::InvalidScale);
2819 }
2820 if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
2821 return Err(PrintLayoutError::InvalidPublicationLineHeight);
2822 }
2823 for (index, image) in config.publication.image_resources.iter().enumerate() {
2824 if !publication_image_resource_is_valid(image, width_mm, height_mm) {
2825 return Err(PrintLayoutError::InvalidPublicationImageResource { index });
2826 }
2827 }
2828 for (index, frame) in config.publication.frames.iter().enumerate() {
2829 if !publication_frame_is_valid(frame, width_mm, height_mm) {
2830 return Err(PrintLayoutError::InvalidPublicationFrame { index });
2831 }
2832 }
2833 if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
2834 {
2835 return Err(PrintLayoutError::InvalidGlyphResourceKey);
2836 }
2837 Ok((width_mm, height_mm, scaled_system_height_mm))
2838}
2839
2840pub fn compute_print_layout(
2842 score: &Score,
2843 config: &PrintConfig,
2844) -> Result<PrintLayoutResult, PrintLayoutError> {
2845 let layout_score = score_for_part_layout(score, config.part_layout)?;
2846 let (width_mm, height_mm, scaled_system_height_mm) = validate_print_config(config)?;
2847
2848 let content_width_mm = width_mm
2849 - config.margin_left_mm
2850 - config.margin_right_mm
2851 - config.safe_left_mm
2852 - config.safe_right_mm;
2853 let content_height_mm = height_mm
2854 - config.margin_top_mm
2855 - config.margin_bottom_mm
2856 - config.safe_top_mm
2857 - config.safe_bottom_mm;
2858 if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
2859 return Err(PrintLayoutError::NoUsablePageArea);
2860 }
2861
2862 let systems_per_page = config
2863 .systems_per_page
2864 .unwrap_or_else(|| {
2865 (content_height_mm / scaled_system_height_mm)
2866 .floor()
2867 .max(1.0) as usize
2868 })
2869 .max(1);
2870 let layout = compute_layout(
2871 &layout_score,
2872 &LayoutConfig {
2873 measures_per_row: config.measures_per_system.max(1),
2874 first_row_measures: config.first_system_measures.or_else(|| {
2875 (matches!(
2876 config.pickup_policy,
2877 PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
2878 ) && has_first_measure_pickup(&layout_score))
2879 .then_some(1)
2880 }),
2881 ..LayoutConfig::default()
2882 },
2883 );
2884
2885 let mut keep_together = config.keep_together.clone();
2886 if matches!(
2887 config.notation_break_policy,
2888 NotationBreakPolicy::KeepVoltaTogether
2889 ) {
2890 keep_together.extend(volta_ranges(&layout_score));
2891 }
2892 let rows = apply_keep_together(
2893 &layout_score,
2894 layout.rows,
2895 &keep_together,
2896 config.measures_per_system.max(1),
2897 )?;
2898 validate_publication_sections(&layout_score, &config.publication.sections, &keep_together)?;
2899 validate_publication_spacers(
2900 &layout_score,
2901 &config.publication.spacers,
2902 &keep_together,
2903 content_height_mm,
2904 scaled_system_height_mm,
2905 )?;
2906 let rows = split_rows_at_measure_starts(
2907 rows,
2908 &config
2909 .publication
2910 .sections
2911 .iter()
2912 .map(|section| section.first_measure)
2913 .chain(
2914 config
2915 .publication
2916 .spacers
2917 .iter()
2918 .map(|spacer| spacer.before_measure),
2919 )
2920 .collect::<Vec<_>>(),
2921 );
2922
2923 let has_explicit_page_break = rows.iter().any(|row| {
2924 row.measure_indices.last().is_some_and(|&measure_index| {
2925 layout_score
2926 .parts
2927 .iter()
2928 .flat_map(|part| part.staves.iter())
2929 .filter_map(|staff| staff.measures.get(measure_index))
2930 .any(|measure| measure.page_break)
2931 })
2932 });
2933 let repeat_system_ranges = if matches!(
2934 config.notation_break_policy,
2935 NotationBreakPolicy::KeepRepeatsTogether
2936 ) {
2937 repeat_system_ranges(&layout_score, &rows)
2938 } else {
2939 Vec::new()
2940 };
2941 if repeat_system_ranges
2942 .iter()
2943 .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
2944 {
2945 return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
2946 }
2947 let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
2948 && !has_explicit_page_break
2949 && systems_per_page > 1
2950 && rows.len() > systems_per_page
2951 && repeat_system_ranges.is_empty()
2952 && !config
2953 .publication
2954 .sections
2955 .iter()
2956 .any(|section| section.start_on_new_page)
2957 && config.publication.spacers.is_empty()
2958 {
2959 let page_count = rows.len().div_ceil(systems_per_page);
2960 let base = rows.len() / page_count;
2961 let remainder = rows.len() % page_count;
2962 (0..page_count)
2963 .map(|index| base + usize::from(index < remainder))
2964 .collect::<Vec<_>>()
2965 } else {
2966 Vec::new()
2967 };
2968
2969 let mut pages = Vec::new();
2970 let mut page_systems = Vec::new();
2971 let mut page_used_height_mm = 0.0;
2972 let mut page_index = 0;
2973 for (system_index, row) in rows.iter().enumerate() {
2974 let repeat_starts_here = repeat_system_ranges
2975 .iter()
2976 .any(|(first, _)| *first == system_index);
2977 let section_starts_on_new_page =
2978 row.measure_indices.first().is_some_and(|measure_index| {
2979 config.publication.sections.iter().any(|section| {
2980 section.first_measure == *measure_index && section.start_on_new_page
2981 })
2982 });
2983 let spacer_height_mm = row.measure_indices.first().map_or(0.0, |measure_index| {
2984 spacer_height_before_measure(&config.publication.spacers, *measure_index)
2985 });
2986 let spacer_requires_new_page = !page_systems.is_empty()
2987 && page_used_height_mm + spacer_height_mm + scaled_system_height_mm > content_height_mm;
2988 if (repeat_starts_here || section_starts_on_new_page || spacer_requires_new_page)
2989 && !page_systems.is_empty()
2990 {
2991 let page_number = match config.page_numbering {
2992 PageNumbering::None => None,
2993 PageNumbering::OneBased => Some(page_index + 1),
2994 };
2995 pages.push(build_page_layout(
2996 score,
2997 &layout_score,
2998 config,
2999 std::mem::take(&mut page_systems),
3000 page_index,
3001 page_number,
3002 width_mm,
3003 height_mm,
3004 content_width_mm,
3005 content_height_mm,
3006 if section_starts_on_new_page {
3007 BreakReason::SectionBreak
3008 } else {
3009 BreakReason::PageCapacity
3010 },
3011 false,
3012 ));
3013 page_index += 1;
3014 page_used_height_mm = 0.0;
3015 }
3016 let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
3017 layout_score
3018 .parts
3019 .iter()
3020 .flat_map(|part| part.staves.iter())
3021 .filter_map(|staff| staff.measures.get(measure_index))
3022 .any(|measure| measure.page_break)
3023 });
3024 let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
3025 layout_score
3026 .parts
3027 .iter()
3028 .flat_map(|part| part.staves.iter())
3029 .filter_map(|staff| staff.measures.get(measure_index))
3030 .any(|measure| measure.system_break)
3031 });
3032 let is_last_system = system_index + 1 == rows.len();
3033 let break_reason = if explicit_page_break {
3034 BreakReason::ExplicitPageBreak
3035 } else if explicit_system_break {
3036 BreakReason::ExplicitSystemBreak
3037 } else if is_last_system {
3038 BreakReason::EndOfScore
3039 } else {
3040 BreakReason::MeasureCapacity
3041 };
3042 let system = SystemLayout {
3043 address: SystemAddress {
3044 system_index,
3045 page_index,
3046 index_on_page: page_systems.len(),
3047 },
3048 system_index,
3049 page_index,
3050 measure_indices: row.measure_indices.clone(),
3051 measure_spans: measure_spans(&layout_score, &row.measure_indices),
3052 span_segments: span_segments(&layout.spans, &row.measure_indices),
3053 measure_marks: measure_marks(&layout_score, &row.measure_indices),
3054 top_mm: config.margin_top_mm
3055 + config.safe_top_mm
3056 + page_used_height_mm
3057 + spacer_height_mm,
3058 height_mm: scaled_system_height_mm,
3059 break_reason,
3060 };
3061 page_systems.push(system);
3062 page_used_height_mm += spacer_height_mm + scaled_system_height_mm;
3063
3064 let page_capacity = page_capacities
3065 .get(page_index)
3066 .copied()
3067 .unwrap_or(systems_per_page);
3068 let page_is_full = page_systems.len() >= page_capacity;
3069 if page_is_full || explicit_page_break {
3070 let page_break_reason = if explicit_page_break {
3071 BreakReason::ExplicitPageBreak
3072 } else if is_last_system {
3073 BreakReason::EndOfScore
3074 } else {
3075 BreakReason::PageCapacity
3076 };
3077 let page_number = match config.page_numbering {
3078 PageNumbering::None => None,
3079 PageNumbering::OneBased => Some(page_index + 1),
3080 };
3081 pages.push(build_page_layout(
3082 score,
3083 &layout_score,
3084 config,
3085 std::mem::take(&mut page_systems),
3086 page_index,
3087 page_number,
3088 width_mm,
3089 height_mm,
3090 content_width_mm,
3091 content_height_mm,
3092 page_break_reason,
3093 false,
3094 ));
3095 page_index += 1;
3096 page_used_height_mm = 0.0;
3097 }
3098 }
3099 if !page_systems.is_empty() || pages.is_empty() {
3100 let page_number = match config.page_numbering {
3101 PageNumbering::None => None,
3102 PageNumbering::OneBased => Some(page_index + 1),
3103 };
3104 pages.push(build_page_layout(
3105 score,
3106 &layout_score,
3107 config,
3108 page_systems,
3109 page_index,
3110 page_number,
3111 width_mm,
3112 height_mm,
3113 content_width_mm,
3114 content_height_mm,
3115 BreakReason::EndOfScore,
3116 false,
3117 ));
3118 }
3119
3120 if config.publication.title_page {
3121 for page in &mut pages {
3122 page.page_index += 1;
3123 page.address.page_index = page.page_index;
3124 page.page_number = match config.page_numbering {
3125 PageNumbering::None => None,
3126 PageNumbering::OneBased => Some(page.page_index + 1),
3127 };
3128 for system in &mut page.systems {
3129 system.page_index += 1;
3130 system.address.page_index = system.page_index;
3131 }
3132 page.publication = page_publication(
3133 score,
3134 &layout_score,
3135 config,
3136 &page.systems,
3137 false,
3138 page.page_number,
3139 );
3140 }
3141 let page_number = match config.page_numbering {
3142 PageNumbering::None => None,
3143 PageNumbering::OneBased => Some(1),
3144 };
3145 pages.insert(
3146 0,
3147 build_page_layout(
3148 score,
3149 &layout_score,
3150 config,
3151 Vec::new(),
3152 0,
3153 page_number,
3154 width_mm,
3155 height_mm,
3156 content_width_mm,
3157 content_height_mm,
3158 BreakReason::TitlePage,
3159 true,
3160 ),
3161 );
3162 }
3163
3164 Ok(PrintLayoutResult {
3165 contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
3166 pages,
3167 })
3168}
3169
3170pub fn compute_print_layout_for_view(
3177 score: &Score,
3178 config: &PrintConfig,
3179 view_id: &str,
3180) -> Result<PrintLayoutResult, PrintLayoutError> {
3181 let view = score
3182 .views
3183 .iter()
3184 .find(|view| view.id == view_id)
3185 .ok_or(PrintLayoutError::InvalidView)?;
3186 let mut view_config = config.clone();
3187 if let Some(measures_per_row) = view.layout.measures_per_row {
3188 view_config.measures_per_system = measures_per_row;
3189 }
3190
3191 let mut projected = score
3192 .resolve_view(view_id)
3193 .map_err(|_| PrintLayoutError::InvalidView)?;
3194 for (indices, is_page_break) in [
3195 (&view.layout.system_breaks, false),
3196 (&view.layout.page_breaks, true),
3197 ] {
3198 for &measure_index in indices {
3199 let mut found = false;
3200 for part in &mut projected.parts {
3201 for staff in &mut part.staves {
3202 if let Some(measure) = staff.measures.get_mut(measure_index) {
3203 found = true;
3204 if is_page_break {
3205 measure.page_break = true;
3206 } else {
3207 measure.system_break = true;
3208 }
3209 }
3210 }
3211 }
3212 if !found {
3213 return Err(PrintLayoutError::InvalidView);
3214 }
3215 }
3216 }
3217 compute_print_layout(&projected, &view_config)
3218}
3219
3220#[cfg(test)]
3221mod tests {
3222 use super::*;
3223 use acorde_core::{
3224 Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score,
3225 ScoreTemplate, ScoreView, Staff, Step, ViewStaffRef,
3226 };
3227
3228 fn score_with_measures(count: usize) -> Score {
3229 let mut score = Score::default();
3230 let mut part = Part::new("Piano", "Pno.");
3231 let mut staff = Staff::new(Clef::Treble);
3232 staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
3233 part.staves = vec![staff];
3234 score.parts = vec![part];
3235 score
3236 }
3237
3238 #[test]
3239 fn publication_metadata_accepts_legacy_partial_json() {
3240 let publication: PagePublication =
3241 serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
3242 .expect("legacy publication metadata should deserialize");
3243
3244 assert!(publication.is_title_page);
3245 assert_eq!(publication.title, "Legacy score");
3246 assert!(publication.movement_title.is_empty());
3247 assert!(publication.part_labels.is_empty());
3248 assert!(publication.part_groups.is_empty());
3249 assert!(publication.text_blocks.is_empty());
3250 }
3251
3252 #[test]
3253 fn layout_validation_rejects_unsupported_contract_version() {
3254 let score = score_with_measures(1);
3255 let mut result =
3256 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3257 result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
3258
3259 assert_eq!(
3260 result.validate(),
3261 Err(PrintLayoutError::UnsupportedContractVersion {
3262 found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
3263 })
3264 );
3265 }
3266
3267 #[test]
3268 fn paginates_rows_and_preserves_measure_indices() {
3269 let score = score_with_measures(5);
3270 let result = compute_print_layout(
3271 &score,
3272 &PrintConfig {
3273 measures_per_system: 2,
3274 systems_per_page: Some(2),
3275 ..PrintConfig::default()
3276 },
3277 )
3278 .expect("valid print config");
3279 assert_eq!(result.pages.len(), 2);
3280 assert_eq!(
3281 result.pages[0]
3282 .systems
3283 .iter()
3284 .map(|s| s.measure_indices.clone())
3285 .collect::<Vec<_>>(),
3286 vec![vec![0, 1], vec![2, 3]]
3287 );
3288 assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
3289 assert_eq!(result.pages[1].systems[0].page_index, 1);
3290 assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
3291 assert_eq!(
3292 result.pages[1].systems[0].break_reason,
3293 BreakReason::EndOfScore
3294 );
3295 assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
3296 }
3297
3298 #[test]
3299 fn forced_page_break_starts_next_system_on_next_page() {
3300 let mut score = score_with_measures(3);
3301 score.parts[0].staves[0].measures[0].page_break = true;
3302 let result = compute_print_layout(
3303 &score,
3304 &PrintConfig {
3305 measures_per_system: 3,
3306 systems_per_page: Some(8),
3307 ..PrintConfig::default()
3308 },
3309 )
3310 .expect("valid print config");
3311 assert_eq!(result.pages.len(), 2);
3312 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3313 assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
3314 assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
3315 assert_eq!(
3316 result.pages[0].systems[0].break_reason,
3317 BreakReason::ExplicitPageBreak
3318 );
3319 }
3320
3321 #[test]
3322 fn keep_together_range_is_not_split_across_systems() {
3323 let score = score_with_measures(5);
3324 let result = compute_print_layout(
3325 &score,
3326 &PrintConfig {
3327 measures_per_system: 3,
3328 systems_per_page: Some(8),
3329 keep_together: vec![KeepTogetherRange {
3330 first_measure: 1,
3331 last_measure: 2,
3332 }],
3333 ..PrintConfig::default()
3334 },
3335 )
3336 .expect("valid keep-together range");
3337 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3338 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
3339 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
3340 }
3341
3342 #[test]
3343 fn first_system_measure_capacity_is_preserved_in_print_layout() {
3344 let score = score_with_measures(5);
3345 let result = compute_print_layout(
3346 &score,
3347 &PrintConfig {
3348 measures_per_system: 3,
3349 first_system_measures: Some(1),
3350 systems_per_page: Some(8),
3351 ..PrintConfig::default()
3352 },
3353 )
3354 .expect("valid first-system capacity");
3355 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3356 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3357 assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
3358 }
3359
3360 #[test]
3361 fn pickup_policy_isolates_a_partial_first_measure() {
3362 let mut score = score_with_measures(4);
3363 score.parts[0].staves[0].measures[0].voices[0] =
3364 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3365 let result = compute_print_layout(
3366 &score,
3367 &PrintConfig {
3368 measures_per_system: 3,
3369 pickup_policy: PickupPolicy::DetectFirstMeasure,
3370 systems_per_page: Some(8),
3371 ..PrintConfig::default()
3372 },
3373 )
3374 .expect("valid pickup policy");
3375 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3376 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3377 }
3378
3379 #[test]
3380 fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
3381 let mut score = score_with_measures(4);
3382 score.parts[0].staves[0].measures[0].voices[0] =
3383 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3384 let result = compute_print_layout(
3385 &score,
3386 &PrintConfig {
3387 measures_per_system: 3,
3388 systems_per_page: Some(8),
3389 ..PrintConfig::default()
3390 },
3391 )
3392 .expect("valid automatic pickup policy");
3393 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3394 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3395 }
3396
3397 #[test]
3398 fn system_exposes_physical_span_for_multi_rest_slot() {
3399 let mut score = score_with_measures(6);
3400 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3401 let result = compute_print_layout(&score, &PrintConfig::default())
3402 .expect("valid multi-rest print layout");
3403 assert_eq!(
3404 result.pages[0].systems[0].measure_spans[1],
3405 MeasureSpan {
3406 first_measure: 1,
3407 last_measure: 3,
3408 }
3409 );
3410 }
3411
3412 #[test]
3413 fn multirest_width_drives_system_breaking_without_splitting() {
3414 let mut score = score_with_measures(5);
3415 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3416 let result = compute_print_layout(
3417 &score,
3418 &PrintConfig {
3419 measures_per_system: 2,
3420 pickup_policy: PickupPolicy::Preserve,
3421 systems_per_page: Some(8),
3422 ..PrintConfig::default()
3423 },
3424 )
3425 .expect("valid multi-rest pagination");
3426 assert_eq!(
3427 result.pages[0]
3428 .systems
3429 .iter()
3430 .map(|system| system.measure_indices.clone())
3431 .collect::<Vec<_>>(),
3432 vec![vec![0], vec![1], vec![2, 3], vec![4]]
3433 );
3434 assert_eq!(
3435 result.pages[0].systems[1].measure_spans[0],
3436 MeasureSpan {
3437 first_measure: 1,
3438 last_measure: 3,
3439 }
3440 );
3441 }
3442
3443 #[test]
3444 fn system_exposes_cross_system_span_segments() {
3445 let mut score = score_with_measures(4);
3446 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3447 start.slur_start = true;
3448 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3449 end.slur_end = true;
3450 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3451 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3452 let result = compute_print_layout(
3453 &score,
3454 &PrintConfig {
3455 measures_per_system: 2,
3456 pickup_policy: PickupPolicy::Preserve,
3457 systems_per_page: Some(8),
3458 ..PrintConfig::default()
3459 },
3460 )
3461 .expect("valid cross-system span layout");
3462 assert_eq!(
3463 result.pages[0].systems[0].span_segments,
3464 vec![SpanSegment {
3465 span_index: 0,
3466 starts_here: true,
3467 ends_here: false,
3468 }]
3469 );
3470 assert_eq!(
3471 result.pages[0].systems[1].span_segments,
3472 vec![SpanSegment {
3473 span_index: 0,
3474 starts_here: false,
3475 ends_here: true,
3476 }]
3477 );
3478 }
3479
3480 #[test]
3481 fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
3482 let mut score = score_with_measures(4);
3483 let measures = &mut score.parts[0].staves[0].measures;
3484 measures[0].barline_right = Barline::RepeatEnd;
3485 measures[1].barline_left = Barline::RepeatStart;
3486 measures[2].volta = Some(acorde_core::VoltaBracket {
3487 number: 1,
3488 kind: "begin".to_string(),
3489 });
3490 measures[2].navigation = Some("ToCoda".to_string());
3491 measures[2].rehearsal = Some("B".to_string());
3492 let result = compute_print_layout(
3493 &score,
3494 &PrintConfig {
3495 measures_per_system: 2,
3496 systems_per_page: Some(8),
3497 ..PrintConfig::default()
3498 },
3499 )
3500 .expect("valid measure mark layout");
3501 assert_eq!(
3502 result.pages[0].systems[0].measure_marks,
3503 vec![
3504 MeasureMark {
3505 measure_index: 0,
3506 repeat_start: false,
3507 repeat_end: true,
3508 volta_number: None,
3509 volta_kind: None,
3510 navigation: None,
3511 rehearsal: None,
3512 text_annotations: vec![],
3513 },
3514 MeasureMark {
3515 measure_index: 1,
3516 repeat_start: true,
3517 repeat_end: false,
3518 volta_number: None,
3519 volta_kind: None,
3520 navigation: None,
3521 rehearsal: None,
3522 text_annotations: vec![],
3523 },
3524 ]
3525 );
3526 assert_eq!(
3527 result.pages[0].systems[1].measure_marks,
3528 vec![MeasureMark {
3529 measure_index: 2,
3530 repeat_start: false,
3531 repeat_end: false,
3532 volta_number: Some(1),
3533 volta_kind: Some("begin".to_string()),
3534 navigation: Some("ToCoda".to_string()),
3535 rehearsal: Some("B".to_string()),
3536 text_annotations: vec![
3537 acorde_core::StyledText {
3538 style: acorde_core::TextStyle::RehearsalMark,
3539 text: "B".to_string(),
3540 placement: None,
3541 offset_x: None,
3542 offset_y: None,
3543 relative_x: None,
3544 relative_y: None,
3545 },
3546 acorde_core::StyledText {
3547 style: acorde_core::TextStyle::Generic,
3548 text: "ToCoda".to_string(),
3549 placement: None,
3550 offset_x: None,
3551 offset_y: None,
3552 relative_x: None,
3553 relative_y: None,
3554 },
3555 ],
3556 }]
3557 );
3558 }
3559
3560 #[test]
3561 fn system_exposes_explicit_measure_text_without_legacy_fields() {
3562 let mut score = score_with_measures(1);
3563 score.parts[0].staves[0].measures[0]
3564 .texts
3565 .push(acorde_core::StyledText {
3566 style: acorde_core::TextStyle::Expression,
3567 text: "dolce".to_string(),
3568 placement: Some("above".to_string()),
3569 offset_x: Some(2.0),
3570 offset_y: Some(-1.0),
3571 relative_x: None,
3572 relative_y: None,
3573 });
3574 let result = compute_print_layout(&score, &PrintConfig::default())
3575 .expect("valid explicit measure text layout");
3576 let annotations = &result.pages[0].systems[0].measure_marks[0].text_annotations;
3577 assert_eq!(annotations.len(), 1);
3578 assert_eq!(annotations[0].style, acorde_core::TextStyle::Expression);
3579 assert_eq!(annotations[0].text, "dolce");
3580 assert_eq!(annotations[0].placement.as_deref(), Some("above"));
3581 assert_eq!(annotations[0].offset_x, Some(2.0));
3582 assert_eq!(annotations[0].offset_y, Some(-1.0));
3583 }
3584
3585 #[test]
3586 fn page_aggregates_cross_system_span_ownership() {
3587 let mut score = score_with_measures(4);
3588 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3589 start.slur_start = true;
3590 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3591 end.slur_end = true;
3592 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3593 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3594 let result = compute_print_layout(
3595 &score,
3596 &PrintConfig {
3597 measures_per_system: 2,
3598 pickup_policy: PickupPolicy::Preserve,
3599 systems_per_page: Some(1),
3600 ..PrintConfig::default()
3601 },
3602 )
3603 .expect("valid page span layout");
3604 assert_eq!(
3605 result.pages[0].span_segments,
3606 vec![PageSpanSegment {
3607 span_index: 0,
3608 starts_here: true,
3609 ends_here: false,
3610 }]
3611 );
3612 assert_eq!(
3613 result.pages[1].span_segments,
3614 vec![PageSpanSegment {
3615 span_index: 0,
3616 starts_here: false,
3617 ends_here: true,
3618 }]
3619 );
3620 }
3621
3622 #[test]
3623 fn page_artifact_measure_span_borrows_system_spans() {
3624 let mut score = score_with_measures(4);
3625 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3626 start.slur_start = true;
3627 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3628 end.slur_end = true;
3629 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3630 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3631 let result = compute_print_layout(
3632 &score,
3633 &PrintConfig {
3634 measures_per_system: 2,
3635 pickup_policy: PickupPolicy::Preserve,
3636 systems_per_page: Some(1),
3637 ..PrintConfig::default()
3638 },
3639 )
3640 .expect("valid page artifact");
3641 let first = result
3642 .page(PageAddress { page_index: 0 })
3643 .expect("first page");
3644 assert_eq!(
3645 first.measure_span(),
3646 Some(MeasureSpan {
3647 first_measure: 0,
3648 last_measure: 1,
3649 })
3650 );
3651 assert!(first.has_span_continuation());
3652 assert!(result.page(PageAddress { page_index: 99 }).is_none());
3653 assert!(result.validate().is_ok());
3654 }
3655
3656 #[test]
3657 fn export_page_artifacts_reports_host_glyph_resource_requirement() {
3658 let result = compute_print_layout(
3659 &score_with_measures(1),
3660 &PrintConfig {
3661 glyph_resources: GlyphResourcePolicy::HostProvided("licensed-font-v1".into()),
3662 ..PrintConfig::default()
3663 },
3664 )
3665 .expect("valid host resource policy");
3666
3667 let artifacts = result
3668 .export_page_artifacts()
3669 .expect("host resource requirement is a diagnostic");
3670 assert_eq!(
3671 artifacts[0].diagnostics,
3672 vec![PageArtifactDiagnostic::GlyphResourceRequired]
3673 );
3674 assert_eq!(
3675 artifacts[0].layout.glyph_resources,
3676 GlyphResourcePolicy::HostProvided("licensed-font-v1".into())
3677 );
3678 }
3679
3680 #[test]
3681 fn page_artifact_diagnostics_report_glyph_overflow_sides() {
3682 let result = compute_print_layout(&score_with_measures(1), &PrintConfig::default())
3683 .expect("valid print layout");
3684 let page = &result.pages[0];
3685 assert_eq!(
3686 page.artifact_diagnostics(Some(GlyphExtents {
3687 left_mm: -1.0,
3688 top_mm: -2.0,
3689 right_mm: page.content_width_mm + 3.0,
3690 bottom_mm: page.content_height_mm + 4.0,
3691 })),
3692 vec![PageArtifactDiagnostic::GlyphOverflow {
3693 left: true,
3694 top: true,
3695 right: true,
3696 bottom: true,
3697 }]
3698 );
3699 assert!(page.artifact_diagnostics(None).is_empty());
3700 }
3701
3702 #[test]
3703 fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
3704 let mut score = score_with_measures(4);
3705 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3706 start.slur_start = true;
3707 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3708 end.slur_end = true;
3709 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3710 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3711 let result = compute_print_layout(
3712 &score,
3713 &PrintConfig {
3714 measures_per_system: 2,
3715 pickup_policy: PickupPolicy::Preserve,
3716 systems_per_page: Some(1),
3717 ..PrintConfig::default()
3718 },
3719 )
3720 .expect("valid print config");
3721
3722 let artifacts = result
3723 .export_page_artifacts()
3724 .expect("valid page artifacts");
3725 assert_eq!(artifacts.len(), 2);
3726 assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
3727 assert_eq!(artifacts[1].page_index, 1);
3728 assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
3729 assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
3730 assert_eq!(
3731 artifacts[0].measure_span,
3732 Some(MeasureSpan {
3733 first_measure: 0,
3734 last_measure: 1,
3735 })
3736 );
3737 assert_eq!(
3738 artifacts[0].diagnostics,
3739 vec![PageArtifactDiagnostic::SpanContinuation {
3740 span_index: 0,
3741 starts_here: true,
3742 ends_here: false,
3743 }]
3744 );
3745 assert_eq!(
3746 artifacts[1].diagnostics,
3747 vec![PageArtifactDiagnostic::SpanContinuation {
3748 span_index: 0,
3749 starts_here: false,
3750 ends_here: true,
3751 }]
3752 );
3753 }
3754
3755 #[test]
3756 fn export_page_artifacts_rejects_invalid_serialized_layout() {
3757 let score = score_with_measures(1);
3758 let mut result =
3759 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3760 result.pages[0].width_mm = f32::NAN;
3761
3762 assert!(matches!(
3763 result.export_page_artifacts(),
3764 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3765 ));
3766 }
3767
3768 #[test]
3769 fn page_lookup_rejects_mismatched_serialized_address() {
3770 let score = score_with_measures(1);
3771 let mut result =
3772 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3773 result.pages[0].address = PageAddress { page_index: 7 };
3774
3775 assert!(result.page(PageAddress { page_index: 0 }).is_none());
3776 assert_eq!(
3777 result.validate(),
3778 Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
3779 );
3780 }
3781
3782 #[test]
3783 fn layout_validation_rejects_mismatched_system_address() {
3784 let score = score_with_measures(2);
3785 let mut result = compute_print_layout(
3786 &score,
3787 &PrintConfig {
3788 measures_per_system: 1,
3789 ..PrintConfig::default()
3790 },
3791 )
3792 .expect("valid print config");
3793 result.pages[0].systems[0].address.index_on_page = 4;
3794
3795 assert_eq!(
3796 result.validate(),
3797 Err(PrintLayoutError::InvalidSystemAddress {
3798 page_index: 0,
3799 index_on_page: 0,
3800 system_index: 0,
3801 })
3802 );
3803 }
3804
3805 #[test]
3806 fn layout_validation_rejects_non_monotonic_page_number() {
3807 let score = score_with_measures(2);
3808 let mut result = compute_print_layout(
3809 &score,
3810 &PrintConfig {
3811 measures_per_system: 1,
3812 page_numbering: PageNumbering::OneBased,
3813 systems_per_page: Some(1),
3814 ..PrintConfig::default()
3815 },
3816 )
3817 .expect("valid print config");
3818 result.pages[1].page_number = Some(1);
3819
3820 assert_eq!(
3821 result.validate(),
3822 Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
3823 );
3824 }
3825
3826 #[test]
3827 fn layout_validation_rejects_inconsistent_title_page_metadata() {
3828 let score = score_with_measures(1);
3829 let mut result =
3830 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3831 result.pages[0].publication.is_title_page = true;
3832
3833 assert_eq!(
3834 result.validate(),
3835 Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
3836 );
3837 }
3838
3839 #[test]
3840 fn layout_validation_rejects_non_finite_page_geometry() {
3841 let score = score_with_measures(1);
3842 let mut result =
3843 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3844 result.pages[0].width_mm = f32::NAN;
3845
3846 assert_eq!(
3847 result.validate(),
3848 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3849 );
3850 }
3851
3852 #[test]
3853 fn layout_validation_rejects_non_positive_system_geometry() {
3854 let score = score_with_measures(1);
3855 let mut result =
3856 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3857 result.pages[0].systems[0].height_mm = 0.0;
3858
3859 assert_eq!(
3860 result.validate(),
3861 Err(PrintLayoutError::InvalidSystemGeometry {
3862 page_index: 0,
3863 index_on_page: 0,
3864 })
3865 );
3866 }
3867
3868 #[test]
3869 fn layout_validation_rejects_content_larger_than_page() {
3870 let score = score_with_measures(1);
3871 let mut result =
3872 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3873 result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
3874
3875 assert_eq!(
3876 result.validate(),
3877 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3878 );
3879 }
3880
3881 #[test]
3882 fn layout_validation_rejects_invalid_persisted_publication_metadata() {
3883 let score = score_with_measures(1);
3884 let mut result =
3885 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3886 result.pages[0]
3887 .publication
3888 .image_resources
3889 .push(PublicationImageResource {
3890 resource_key: "../unsafe".into(),
3891 alt_text: "Unsafe resource".into(),
3892 placement: PublicationImagePlacement::EveryPage,
3893 x_mm: 0.0,
3894 y_mm: 0.0,
3895 width_mm: 1.0,
3896 height_mm: 1.0,
3897 });
3898
3899 assert_eq!(
3900 result.validate(),
3901 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3902 );
3903
3904 result.pages[0].publication.image_resources.clear();
3905 result.pages[0].publication.frames.push(PublicationFrame {
3906 placement: PublicationFramePlacement::EveryPage,
3907 x_mm: 0.0,
3908 y_mm: 0.0,
3909 width_mm: 1.0,
3910 height_mm: 1.0,
3911 stroke_width_mm: f32::NAN,
3912 });
3913 assert_eq!(
3914 result.validate(),
3915 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3916 );
3917
3918 result.pages[0].publication.frames.clear();
3919 result.pages[0]
3920 .publication
3921 .sections
3922 .push(PublicationSection {
3923 first_measure: 0,
3924 title: " ".into(),
3925 start_on_new_page: false,
3926 });
3927 assert_eq!(
3928 result.validate(),
3929 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3930 );
3931 }
3932
3933 #[test]
3934 fn notation_policy_keeps_volta_range_in_one_system() {
3935 let mut score = score_with_measures(4);
3936 score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
3937 number: 1,
3938 kind: "begin".to_string(),
3939 });
3940 score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
3941 number: 1,
3942 kind: "end".to_string(),
3943 });
3944 let result = compute_print_layout(
3945 &score,
3946 &PrintConfig {
3947 measures_per_system: 2,
3948 systems_per_page: Some(8),
3949 notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
3950 ..PrintConfig::default()
3951 },
3952 )
3953 .expect("valid volta-preserving layout");
3954 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3955 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
3956 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
3957 }
3958
3959 #[test]
3960 fn notation_policy_keeps_repeat_section_on_one_page() {
3961 let mut score = score_with_measures(5);
3962 score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
3963 score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
3964 let result = compute_print_layout(
3965 &score,
3966 &PrintConfig {
3967 measures_per_system: 2,
3968 systems_per_page: Some(2),
3969 notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
3970 ..PrintConfig::default()
3971 },
3972 )
3973 .expect("valid repeat-preserving layout");
3974 assert_eq!(result.pages[0].systems.len(), 1);
3975 assert_eq!(result.pages[1].systems.len(), 2);
3976 assert_eq!(
3977 result.pages[1]
3978 .systems
3979 .iter()
3980 .flat_map(|system| system.measure_indices.iter().copied())
3981 .collect::<Vec<_>>(),
3982 vec![2, 3, 4]
3983 );
3984 }
3985
3986 #[test]
3987 fn balance_policy_avoids_single_system_final_page() {
3988 let score = score_with_measures(5);
3989 let result = compute_print_layout(
3990 &score,
3991 &PrintConfig {
3992 measures_per_system: 1,
3993 systems_per_page: Some(4),
3994 final_page_policy: FinalPagePolicy::Balance,
3995 ..PrintConfig::default()
3996 },
3997 )
3998 .expect("valid balanced print config");
3999 assert_eq!(result.pages.len(), 2);
4000 assert_eq!(result.pages[0].systems.len(), 3);
4001 assert_eq!(result.pages[1].systems.len(), 2);
4002 }
4003
4004 #[test]
4005 fn balance_policy_preserves_explicit_page_breaks() {
4006 let mut score = score_with_measures(5);
4007 score.parts[0].staves[0].measures[1].page_break = true;
4008 let result = compute_print_layout(
4009 &score,
4010 &PrintConfig {
4011 measures_per_system: 1,
4012 systems_per_page: Some(4),
4013 final_page_policy: FinalPagePolicy::Balance,
4014 ..PrintConfig::default()
4015 },
4016 )
4017 .expect("valid explicit-break print config");
4018 assert_eq!(result.pages[0].systems.len(), 2);
4019 assert_eq!(result.pages[1].systems.len(), 3);
4020 }
4021
4022 #[test]
4023 fn keep_together_rejects_ranges_larger_than_system_capacity() {
4024 let score = score_with_measures(4);
4025 let error = compute_print_layout(
4026 &score,
4027 &PrintConfig {
4028 measures_per_system: 2,
4029 keep_together: vec![KeepTogetherRange {
4030 first_measure: 0,
4031 last_measure: 2,
4032 }],
4033 ..PrintConfig::default()
4034 },
4035 )
4036 .expect_err("range must fit in one system");
4037 assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
4038 }
4039
4040 #[test]
4041 fn keep_together_rejects_explicit_break_inside_range() {
4042 let mut score = score_with_measures(4);
4043 score.parts[0].staves[0].measures[1].system_break = true;
4044 let error = compute_print_layout(
4045 &score,
4046 &PrintConfig {
4047 measures_per_system: 3,
4048 keep_together: vec![KeepTogetherRange {
4049 first_measure: 0,
4050 last_measure: 2,
4051 }],
4052 ..PrintConfig::default()
4053 },
4054 )
4055 .expect_err("explicit break must win");
4056 assert_eq!(
4057 error,
4058 PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
4059 );
4060 }
4061
4062 #[test]
4063 fn rejects_margins_that_leave_no_page_area() {
4064 let score = score_with_measures(1);
4065 let error = compute_print_layout(
4066 &score,
4067 &PrintConfig {
4068 margin_left_mm: 200.0,
4069 ..PrintConfig::default()
4070 },
4071 )
4072 .expect_err("invalid page area");
4073 assert_eq!(error, PrintLayoutError::NoUsablePageArea);
4074 }
4075
4076 #[test]
4077 fn safe_area_reduces_content_and_bleed_is_exposed() {
4078 let score = score_with_measures(1);
4079 let result = compute_print_layout(
4080 &score,
4081 &PrintConfig {
4082 bleed_top_mm: 3.0,
4083 bleed_right_mm: 3.0,
4084 bleed_bottom_mm: 3.0,
4085 bleed_left_mm: 3.0,
4086 safe_top_mm: 5.0,
4087 safe_right_mm: 6.0,
4088 safe_bottom_mm: 7.0,
4089 safe_left_mm: 8.0,
4090 ..PrintConfig::default()
4091 },
4092 )
4093 .expect("valid print config");
4094 let page = &result.pages[0];
4095 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4096 assert_eq!(page.bleed_left_mm, 3.0);
4097 assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
4098 assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
4099 assert_eq!(page.systems[0].top_mm, 21.0);
4100 }
4101
4102 #[test]
4103 fn scale_changes_system_height_and_page_capacity() {
4104 let score = score_with_measures(10);
4105 let result = compute_print_layout(
4106 &score,
4107 &PrintConfig {
4108 scale: 2.0,
4109 measures_per_system: 1,
4110 systems_per_page: None,
4111 ..PrintConfig::default()
4112 },
4113 )
4114 .expect("valid print config");
4115 assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
4116 assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
4117 assert_eq!(result.pages.len(), 2);
4118 }
4119
4120 #[test]
4121 fn rejects_non_positive_scale() {
4122 let score = score_with_measures(1);
4123 let error = compute_print_layout(
4124 &score,
4125 &PrintConfig {
4126 scale: 0.0,
4127 ..PrintConfig::default()
4128 },
4129 )
4130 .expect_err("invalid scale");
4131 assert_eq!(error, PrintLayoutError::InvalidScale);
4132 }
4133
4134 #[test]
4135 fn page_numbering_is_configurable() {
4136 let score = score_with_measures(5);
4137 let numbered = compute_print_layout(
4138 &score,
4139 &PrintConfig {
4140 measures_per_system: 1,
4141 systems_per_page: Some(2),
4142 ..PrintConfig::default()
4143 },
4144 )
4145 .expect("valid print config");
4146 assert_eq!(numbered.pages[0].page_number, Some(1));
4147 assert_eq!(numbered.pages[1].page_number, Some(2));
4148
4149 let unnumbered = compute_print_layout(
4150 &score,
4151 &PrintConfig {
4152 page_numbering: PageNumbering::None,
4153 measures_per_system: 1,
4154 systems_per_page: Some(2),
4155 ..PrintConfig::default()
4156 },
4157 )
4158 .expect("valid print config");
4159 assert!(
4160 unnumbered
4161 .pages
4162 .iter()
4163 .all(|page| page.page_number.is_none())
4164 );
4165 }
4166
4167 #[test]
4168 fn rejects_invalid_publication_line_height() {
4169 let score = score_with_measures(1);
4170 let error = compute_print_layout(
4171 &score,
4172 &PrintConfig {
4173 publication: PublicationConfig {
4174 line_height_mm: 0.0,
4175 ..PublicationConfig::default()
4176 },
4177 ..PrintConfig::default()
4178 },
4179 )
4180 .expect_err("invalid publication line height");
4181 assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
4182 }
4183
4184 #[test]
4185 fn publication_image_resources_are_safe_and_page_scoped() {
4186 let score = score_with_measures(1);
4187 let config = PrintConfig {
4188 publication: PublicationConfig {
4189 title_page: true,
4190 image_resources: vec![
4191 PublicationImageResource {
4192 resource_key: "cover-art-v1".into(),
4193 alt_text: "Cover art".into(),
4194 placement: PublicationImagePlacement::TitlePage,
4195 x_mm: 10.0,
4196 y_mm: 10.0,
4197 width_mm: 30.0,
4198 height_mm: 20.0,
4199 },
4200 PublicationImageResource {
4201 resource_key: "publisher-mark".into(),
4202 alt_text: "Publisher mark".into(),
4203 placement: PublicationImagePlacement::MusicPages,
4204 x_mm: 160.0,
4205 y_mm: 10.0,
4206 width_mm: 20.0,
4207 height_mm: 10.0,
4208 },
4209 ],
4210 ..PublicationConfig::default()
4211 },
4212 ..PrintConfig::default()
4213 };
4214 let result = compute_print_layout(&score, &config).expect("valid image resources");
4215 assert_eq!(result.pages[0].publication.image_resources.len(), 1);
4216 assert_eq!(
4217 result.pages[0].publication.image_resources[0].resource_key,
4218 "cover-art-v1"
4219 );
4220 assert_eq!(result.pages[1].publication.image_resources.len(), 1);
4221 assert_eq!(
4222 result.pages[1].publication.image_resources[0].resource_key,
4223 "publisher-mark"
4224 );
4225
4226 let invalid = PrintConfig {
4227 publication: PublicationConfig {
4228 image_resources: vec![PublicationImageResource {
4229 resource_key: "../secret.png".into(),
4230 alt_text: "Unsafe path".into(),
4231 placement: PublicationImagePlacement::EveryPage,
4232 x_mm: 0.0,
4233 y_mm: 0.0,
4234 width_mm: 1.0,
4235 height_mm: 1.0,
4236 }],
4237 ..PublicationConfig::default()
4238 },
4239 ..PrintConfig::default()
4240 };
4241 assert_eq!(
4242 compute_print_layout(&score, &invalid),
4243 Err(PrintLayoutError::InvalidPublicationImageResource { index: 0 })
4244 );
4245 }
4246
4247 #[test]
4248 fn publication_frames_are_validated_and_page_scoped() {
4249 let score = score_with_measures(1);
4250 let config = PrintConfig {
4251 publication: PublicationConfig {
4252 title_page: true,
4253 frames: vec![
4254 PublicationFrame {
4255 placement: PublicationFramePlacement::TitlePage,
4256 x_mm: 8.0,
4257 y_mm: 8.0,
4258 width_mm: 194.0,
4259 height_mm: 281.0,
4260 stroke_width_mm: 0.4,
4261 },
4262 PublicationFrame {
4263 placement: PublicationFramePlacement::MusicPages,
4264 x_mm: 12.0,
4265 y_mm: 12.0,
4266 width_mm: 186.0,
4267 height_mm: 273.0,
4268 stroke_width_mm: 0.3,
4269 },
4270 ],
4271 ..PublicationConfig::default()
4272 },
4273 ..PrintConfig::default()
4274 };
4275 let result = compute_print_layout(&score, &config).expect("valid frames");
4276 assert_eq!(result.pages[0].publication.frames.len(), 1);
4277 assert_eq!(result.pages[1].publication.frames.len(), 1);
4278 assert_eq!(result.pages[0].publication.frames[0].stroke_width_mm, 0.4);
4279
4280 let invalid = PrintConfig {
4281 publication: PublicationConfig {
4282 frames: vec![PublicationFrame {
4283 placement: PublicationFramePlacement::EveryPage,
4284 x_mm: 0.0,
4285 y_mm: 0.0,
4286 width_mm: 211.0,
4287 height_mm: 297.0,
4288 stroke_width_mm: 0.0,
4289 }],
4290 ..PublicationConfig::default()
4291 },
4292 ..PrintConfig::default()
4293 };
4294 assert_eq!(
4295 compute_print_layout(&score, &invalid),
4296 Err(PrintLayoutError::InvalidPublicationFrame { index: 0 })
4297 );
4298 }
4299
4300 #[test]
4301 fn publication_spacers_consume_page_height_and_follow_systems() {
4302 let score = score_with_measures(4);
4303 let config = PrintConfig {
4304 measures_per_system: 4,
4305 systems_per_page: Some(2),
4306 system_height_mm: 130.0,
4307 publication: PublicationConfig {
4308 spacers: vec![PublicationSpacer {
4309 before_measure: 2,
4310 height_mm: 20.0,
4311 }],
4312 ..PublicationConfig::default()
4313 },
4314 ..PrintConfig::default()
4315 };
4316 let result = compute_print_layout(&score, &config).expect("valid publication spacer");
4317 assert_eq!(result.pages.len(), 2);
4318 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4319 assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4320 assert_eq!(result.pages[1].systems[0].top_mm, 36.0);
4321 assert_eq!(result.pages[1].publication.spacers.len(), 1);
4322 assert_eq!(result.pages[1].publication.spacers[0].before_measure, 2);
4323 result
4324 .validate()
4325 .expect("persisted spacer metadata is valid");
4326
4327 let invalid = PrintConfig {
4328 system_height_mm: 260.0,
4329 publication: PublicationConfig {
4330 spacers: vec![PublicationSpacer {
4331 before_measure: 0,
4332 height_mm: 10.0,
4333 }],
4334 ..PublicationConfig::default()
4335 },
4336 ..PrintConfig::default()
4337 };
4338 assert_eq!(
4339 compute_print_layout(&score, &invalid),
4340 Err(PrintLayoutError::InvalidPublicationSpacer { index: 0 })
4341 );
4342 }
4343
4344 #[test]
4345 fn publication_sections_split_systems_and_can_start_a_page() {
4346 let score = score_with_measures(5);
4347 let config = PrintConfig {
4348 measures_per_system: 4,
4349 systems_per_page: Some(2),
4350 publication: PublicationConfig {
4351 sections: vec![PublicationSection {
4352 first_measure: 2,
4353 title: "Second movement".into(),
4354 start_on_new_page: true,
4355 }],
4356 ..PublicationConfig::default()
4357 },
4358 ..PrintConfig::default()
4359 };
4360 let result = compute_print_layout(&score, &config).expect("valid publication section");
4361 assert_eq!(result.pages.len(), 2);
4362 assert_eq!(result.pages[0].break_reason, BreakReason::SectionBreak);
4363 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4364 assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4365 assert_eq!(result.pages[1].publication.sections.len(), 1);
4366 assert_eq!(
4367 result.pages[1].publication.sections[0].title,
4368 "Second movement"
4369 );
4370
4371 let invalid = PrintConfig {
4372 publication: PublicationConfig {
4373 sections: vec![PublicationSection {
4374 first_measure: 5,
4375 title: "Outside score".into(),
4376 start_on_new_page: false,
4377 }],
4378 ..PublicationConfig::default()
4379 },
4380 ..PrintConfig::default()
4381 };
4382 assert_eq!(
4383 compute_print_layout(&score, &invalid),
4384 Err(PrintLayoutError::InvalidPublicationSection { index: 0 })
4385 );
4386 }
4387
4388 #[test]
4389 fn rejects_empty_host_glyph_resource_key() {
4390 let score = score_with_measures(1);
4391 let error = compute_print_layout(
4392 &score,
4393 &PrintConfig {
4394 glyph_resources: GlyphResourcePolicy::HostProvided(" ".into()),
4395 ..PrintConfig::default()
4396 },
4397 )
4398 .expect_err("empty host resource key");
4399 assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
4400 }
4401
4402 #[test]
4403 fn glyph_resource_descriptor_requires_reproducible_metadata() {
4404 let descriptor = GlyphResourceDescriptor {
4405 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4406 resource_key: "publisher-font-v2".into(),
4407 metrics_contract_version: 1,
4408 license_notice: "licensed by publisher".into(),
4409 fallback: GlyphFallbackPolicy::UseResource("acorde-vector-glyphs-v1".into()),
4410 };
4411 assert_eq!(descriptor.validate(), Ok(()));
4412 }
4413
4414 #[test]
4415 fn glyph_resource_descriptor_rejects_missing_license_and_self_fallback() {
4416 let missing_license = GlyphResourceDescriptor {
4417 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4418 resource_key: "font".into(),
4419 metrics_contract_version: 1,
4420 license_notice: " ".into(),
4421 fallback: GlyphFallbackPolicy::Reject,
4422 };
4423 assert_eq!(
4424 missing_license.validate(),
4425 Err(GlyphResourceDescriptorError::EmptyLicenseNotice)
4426 );
4427
4428 let self_fallback = GlyphResourceDescriptor {
4429 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4430 resource_key: "font".into(),
4431 metrics_contract_version: 1,
4432 license_notice: "licensed".into(),
4433 fallback: GlyphFallbackPolicy::UseResource("font".into()),
4434 };
4435 assert_eq!(
4436 self_fallback.validate(),
4437 Err(GlyphResourceDescriptorError::FallbackMatchesPrimary)
4438 );
4439 }
4440
4441 #[test]
4442 fn print_color_and_crop_policies_are_exposed_per_page() {
4443 let score = score_with_measures(1);
4444 let result = compute_print_layout(
4445 &score,
4446 &PrintConfig {
4447 color_policy: PrintColorPolicy::Preserve,
4448 crop_mark_policy: CropMarkPolicy::BleedEdges,
4449 ..PrintConfig::default()
4450 },
4451 )
4452 .expect("valid print config");
4453 let page = &result.pages[0];
4454 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4455 assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
4456 assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
4457 }
4458
4459 #[test]
4460 fn glyph_resource_policy_is_exposed_per_page() {
4461 let score = score_with_measures(1);
4462 let result = compute_print_layout(
4463 &score,
4464 &PrintConfig {
4465 glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
4466 ..PrintConfig::default()
4467 },
4468 )
4469 .expect("valid print config");
4470 assert_eq!(
4471 result.pages[0].glyph_resources,
4472 GlyphResourcePolicy::HostProvided("music-font-v1".into())
4473 );
4474 }
4475
4476 #[test]
4477 fn publication_metadata_is_deterministic_and_page_scoped() {
4478 let mut score = score_with_measures(3);
4479 score.metadata.title = "Suite".into();
4480 score.metadata.movement_title = "I. Prelude".into();
4481 score.metadata.composer = "Composer".into();
4482 score.metadata.copyright = "© 2026 Composer".into();
4483 score.metadata.lyricist = "Lyricist".into();
4484 score.metadata.copyright = "Copyright".into();
4485 score.parts.push(Part::new("Strings", "Str."));
4486 score.part_groups.push(PartGroup {
4487 first_part: 0,
4488 last_part: 1,
4489 symbol: PartGroupSymbol::Bracket,
4490 barlines_connect: true,
4491 });
4492 for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
4493 measure.number = (index + 1) as u32;
4494 }
4495 let result = compute_print_layout(
4496 &score,
4497 &PrintConfig {
4498 measures_per_system: 2,
4499 systems_per_page: Some(1),
4500 publication: PublicationConfig {
4501 running_title: Some("Suite — Composer".into()),
4502 header_text: Some("Suite".into()),
4503 footer_text: Some("Copyright".into()),
4504 page_number_in_footer: true,
4505 header_alignment: PublicationTextAlignment::Center,
4506 footer_alignment: PublicationTextAlignment::Right,
4507 ..PublicationConfig::default()
4508 },
4509 ..PrintConfig::default()
4510 },
4511 )
4512 .expect("valid print config");
4513 assert_eq!(result.pages[0].publication.title, "Suite");
4514 assert_eq!(
4515 result.pages[0].publication.running_title.as_deref(),
4516 Some("Suite — Composer")
4517 );
4518 assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
4519 assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
4520 assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
4521 assert_eq!(result.pages[0].publication.part_groups.len(), 1);
4522 assert_eq!(
4523 result.pages[0].publication.part_groups[0].symbol,
4524 PartGroupSymbol::Bracket
4525 );
4526 assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
4527 assert_eq!(
4528 result.pages[0].publication.text_blocks[0].role,
4529 PublicationTextRole::Header
4530 );
4531 assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
4532 assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
4533 assert_eq!(
4534 result.pages[0].publication.text_blocks[1].role,
4535 PublicationTextRole::Footer
4536 );
4537 assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
4538 assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
4539 assert_eq!(
4540 result.pages[0].publication.text_blocks[0].alignment,
4541 PublicationTextAlignment::Center
4542 );
4543 assert_eq!(
4544 result.pages[0].publication.text_blocks[1].alignment,
4545 PublicationTextAlignment::Right
4546 );
4547 let artifacts = result
4548 .export_page_artifacts()
4549 .expect("publication pages export without host resources");
4550 assert_eq!(artifacts.len(), result.pages.len());
4551 assert_eq!(artifacts[0].layout.publication, result.pages[0].publication);
4552 assert!(
4553 artifacts
4554 .iter()
4555 .all(|artifact| artifact.diagnostics.is_empty())
4556 );
4557 }
4558
4559 #[test]
4560 fn publication_templates_select_odd_even_text_and_can_omit_a_side() {
4561 let score = score_with_measures(3);
4562 let result = compute_print_layout(
4563 &score,
4564 &PrintConfig {
4565 measures_per_system: 1,
4566 systems_per_page: Some(1),
4567 publication: PublicationConfig {
4568 header_text: Some("legacy header".into()),
4569 footer_text: Some("legacy footer".into()),
4570 header_template: PublicationPageTemplate {
4571 odd: Some("Odd header".into()),
4572 even: Some("Even header".into()),
4573 },
4574 footer_template: PublicationPageTemplate {
4575 odd: Some("Odd footer".into()),
4576 even: None,
4577 },
4578 ..PublicationConfig::default()
4579 },
4580 ..PrintConfig::default()
4581 },
4582 )
4583 .expect("valid print layout");
4584 let first_texts: Vec<_> = result.pages[0]
4585 .publication
4586 .text_blocks
4587 .iter()
4588 .map(|block| block.text.as_str())
4589 .collect();
4590 let second_texts: Vec<_> = result.pages[1]
4591 .publication
4592 .text_blocks
4593 .iter()
4594 .map(|block| block.text.as_str())
4595 .collect();
4596 assert_eq!(first_texts, vec!["Odd header", "Odd footer"]);
4597 assert_eq!(second_texts, vec!["Even header"]);
4598 }
4599
4600 #[test]
4601 fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
4602 let mut score = score_with_measures(2);
4603 let mut part = Part::new("Flute", "Fl.");
4604 let mut staff = Staff::new(Clef::Treble);
4605 staff.measures = vec![Measure::empty(4, 4); 5];
4606 part.staves = vec![staff];
4607 score.parts.push(part);
4608
4609 let extracted = compute_print_layout(
4610 &score,
4611 &PrintConfig {
4612 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
4613 measures_per_system: 2,
4614 systems_per_page: Some(1),
4615 ..PrintConfig::default()
4616 },
4617 )
4618 .expect("valid extracted part");
4619 assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
4620 assert_eq!(extracted.pages.len(), 3);
4621 assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
4622
4623 let error = compute_print_layout(
4624 &score,
4625 &PrintConfig {
4626 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
4627 ..PrintConfig::default()
4628 },
4629 )
4630 .expect_err("missing extracted part");
4631 assert_eq!(error, PrintLayoutError::InvalidPartIndex);
4632 }
4633
4634 #[test]
4635 fn title_page_is_inserted_without_consuming_music_page_capacity() {
4636 let mut score = score_with_measures(3);
4637 score.texts.push(StyledText {
4638 style: TextStyle::Expression,
4639 text: "Dedication".into(),
4640 placement: None,
4641 offset_x: None,
4642 offset_y: None,
4643 relative_x: None,
4644 relative_y: None,
4645 });
4646 score.metadata.title = "Suite".into();
4647 score.metadata.movement_title = "I. Prelude".into();
4648 score.metadata.composer = "Composer".into();
4649 score.metadata.copyright = "© 2026 Composer".into();
4650 let result = compute_print_layout(
4651 &score,
4652 &PrintConfig {
4653 systems_per_page: Some(1),
4654 measures_per_system: 2,
4655 publication: PublicationConfig {
4656 title_page: true,
4657 ..PublicationConfig::default()
4658 },
4659 ..PrintConfig::default()
4660 },
4661 )
4662 .expect("valid title page config");
4663 assert_eq!(result.pages.len(), 3);
4664 assert!(result.pages[0].systems.is_empty());
4665 assert!(result.pages[0].publication.is_title_page);
4666 assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
4667 assert_eq!(result.pages[0].page_number, Some(1));
4668 assert_eq!(result.pages[1].page_number, Some(2));
4669 assert_eq!(result.pages[1].systems[0].page_index, 1);
4670 assert!(!result.pages[1].publication.is_title_page);
4671 assert_eq!(result.pages[0].publication.score_texts, score.texts);
4672 assert!(result.validate().is_ok());
4673 assert_eq!(
4674 result.pages[0]
4675 .publication
4676 .text_blocks
4677 .iter()
4678 .map(|block| block.role)
4679 .collect::<Vec<_>>(),
4680 vec![
4681 PublicationTextRole::Title,
4682 PublicationTextRole::Subtitle,
4683 PublicationTextRole::Credit,
4684 PublicationTextRole::Copyright
4685 ]
4686 );
4687 }
4688
4689 #[test]
4690 fn print_presets_are_versioned_and_select_the_expected_scope() {
4691 assert_eq!(PrintPreset::A4Score.schema_version(), 1);
4692 assert_eq!(
4693 PrintPreset::A4Score.config().part_layout,
4694 PartLayoutPolicy::FullScore
4695 );
4696 assert_eq!(
4697 PrintPreset::LetterPart { part_index: 2 }
4698 .config()
4699 .part_layout,
4700 PartLayoutPolicy::ExtractedPart { part_index: 2 }
4701 );
4702 assert_eq!(
4703 PrintPreset::LetterScore.config().paper_size,
4704 PaperSize::Letter
4705 );
4706 assert!(
4707 PrintPreset::A4Score
4708 .config_with_title_page(true)
4709 .publication
4710 .title_page
4711 );
4712 assert!(!PrintPreset::A4Score.config().publication.title_page);
4713 assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 1);
4714 }
4715
4716 #[test]
4717 fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
4718 let metrics = GlyphMetrics {
4719 advance_mm: 4.0,
4720 left_mm: -1.0,
4721 top_mm: -2.0,
4722 width_mm: 2.0,
4723 height_mm: 4.0,
4724 };
4725 let mut placements = vec![
4726 GlyphPlacement {
4727 resource_key: "high".into(),
4728 metrics,
4729 x_mm: 10.0,
4730 y_mm: 20.0,
4731 priority: 10,
4732 },
4733 GlyphPlacement {
4734 resource_key: "low".into(),
4735 metrics,
4736 x_mm: 10.0,
4737 y_mm: 20.0,
4738 priority: 1,
4739 },
4740 ];
4741 let moved = resolve_glyph_collisions(&mut placements, 1.0);
4742 assert_eq!(moved, 1);
4743 assert_eq!(placements[0].y_mm, 20.0);
4744 assert_eq!(placements[1].y_mm, 25.0);
4745 }
4746
4747 #[test]
4748 fn class_aware_collision_resolution_uses_stable_semantic_tie_breakers() {
4749 let metrics = GlyphMetrics {
4750 advance_mm: 4.0,
4751 left_mm: -1.0,
4752 top_mm: -2.0,
4753 width_mm: 2.0,
4754 height_mm: 4.0,
4755 };
4756 let mut placements = vec![
4757 GlyphPlacement {
4758 resource_key: "annotation".into(),
4759 metrics,
4760 x_mm: 10.0,
4761 y_mm: 20.0,
4762 priority: 1,
4763 },
4764 GlyphPlacement {
4765 resource_key: "critical".into(),
4766 metrics,
4767 x_mm: 10.0,
4768 y_mm: 20.0,
4769 priority: 1,
4770 },
4771 ];
4772 let classes = [
4773 GlyphCollisionClass::Annotation,
4774 GlyphCollisionClass::Critical,
4775 ];
4776 assert_eq!(
4777 resolve_glyph_collisions_with_classes(&mut placements, &classes, 1.0),
4778 Ok(1)
4779 );
4780 assert_eq!(placements[0].y_mm, 25.0);
4781 assert_eq!(placements[1].y_mm, 20.0);
4782 assert_eq!(
4783 resolve_glyph_horizontal_collisions_with_classes(
4784 &mut placements,
4785 &[GlyphCollisionClass::Annotation],
4786 1.0,
4787 ),
4788 Err(GlyphPlacementError::CollisionClassCount {
4789 placements: 2,
4790 classes: 1,
4791 })
4792 );
4793 }
4794
4795 #[test]
4796 fn constrained_collision_pass_honors_annotation_escape_lanes() {
4797 let metrics = GlyphMetrics {
4798 advance_mm: 4.0,
4799 left_mm: -1.0,
4800 top_mm: -2.0,
4801 width_mm: 2.0,
4802 height_mm: 4.0,
4803 };
4804 let mut placements = vec![
4805 GlyphPlacement {
4806 resource_key: "notation".into(),
4807 metrics,
4808 x_mm: 10.0,
4809 y_mm: 20.0,
4810 priority: 1,
4811 },
4812 GlyphPlacement {
4813 resource_key: "lyric".into(),
4814 metrics,
4815 x_mm: 10.0,
4816 y_mm: 20.0,
4817 priority: 1,
4818 },
4819 GlyphPlacement {
4820 resource_key: "rehearsal".into(),
4821 metrics,
4822 x_mm: 10.0,
4823 y_mm: 20.0,
4824 priority: 1,
4825 },
4826 GlyphPlacement {
4827 resource_key: "tab".into(),
4828 metrics,
4829 x_mm: 10.0,
4830 y_mm: 20.0,
4831 priority: 1,
4832 },
4833 ];
4834 let classes = [
4835 GlyphCollisionClass::Critical,
4836 GlyphCollisionClass::Annotation,
4837 GlyphCollisionClass::Annotation,
4838 GlyphCollisionClass::Annotation,
4839 ];
4840 let directions = [
4841 GlyphCollisionDirection::Down,
4842 GlyphCollisionDirection::Down,
4843 GlyphCollisionDirection::Up,
4844 GlyphCollisionDirection::Right,
4845 ];
4846
4847 assert_eq!(
4848 resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
4849 Ok(3)
4850 );
4851 assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
4852 assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
4853 assert_eq!((placements[2].x_mm, placements[2].y_mm), (10.0, 15.0));
4854 assert_eq!((placements[3].x_mm, placements[3].y_mm), (13.0, 20.0));
4855
4856 let before = placements.clone();
4857 assert_eq!(
4858 resolve_glyph_collisions_constrained(
4859 &mut placements,
4860 &classes,
4861 &[GlyphCollisionDirection::Down],
4862 1.0
4863 ),
4864 Err(GlyphPlacementError::CollisionDirectionCount {
4865 placements: 4,
4866 directions: 1,
4867 })
4868 );
4869 assert_eq!(placements, before);
4870 }
4871
4872 #[test]
4873 fn constrained_collision_pass_keeps_fixed_obstacles_in_place() {
4874 let metrics = GlyphMetrics {
4875 advance_mm: 4.0,
4876 left_mm: -1.0,
4877 top_mm: -2.0,
4878 width_mm: 2.0,
4879 height_mm: 4.0,
4880 };
4881 let mut placements = vec![
4882 GlyphPlacement {
4883 resource_key: "resolved-annotation".into(),
4884 metrics,
4885 x_mm: 10.0,
4886 y_mm: 20.0,
4887 priority: 2,
4888 },
4889 GlyphPlacement {
4890 resource_key: "measure-text".into(),
4891 metrics,
4892 x_mm: 10.0,
4893 y_mm: 20.0,
4894 priority: 1,
4895 },
4896 ];
4897 let classes = [
4898 GlyphCollisionClass::Critical,
4899 GlyphCollisionClass::Annotation,
4900 ];
4901 let directions = [
4902 GlyphCollisionDirection::Fixed,
4903 GlyphCollisionDirection::Down,
4904 ];
4905
4906 assert_eq!(
4907 resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
4908 Ok(1)
4909 );
4910 assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
4911 assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
4912 }
4913
4914 #[test]
4915 fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
4916 let metrics = GlyphMetrics {
4917 advance_mm: 4.0,
4918 left_mm: -1.0,
4919 top_mm: -1.0,
4920 width_mm: 2.0,
4921 height_mm: 2.0,
4922 };
4923 let mut placements = vec![
4924 GlyphPlacement {
4925 resource_key: "high".into(),
4926 metrics,
4927 x_mm: 10.0,
4928 y_mm: 20.0,
4929 priority: 10,
4930 },
4931 GlyphPlacement {
4932 resource_key: "low".into(),
4933 metrics,
4934 x_mm: 10.0,
4935 y_mm: 0.0,
4936 priority: 1,
4937 },
4938 ];
4939 assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
4940 assert_eq!(placements[1].y_mm, 0.0);
4941 }
4942
4943 #[test]
4944 fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
4945 let mut placements = vec![GlyphPlacement {
4946 resource_key: "test".into(),
4947 metrics: GlyphMetrics {
4948 advance_mm: 1.0,
4949 left_mm: 0.0,
4950 top_mm: 0.0,
4951 width_mm: 1.0,
4952 height_mm: 1.0,
4953 },
4954 x_mm: 0.0,
4955 y_mm: 0.0,
4956 priority: 0,
4957 }];
4958 assert_eq!(validate_glyph_placements(&placements), Ok(()));
4959 placements[0].x_mm = f32::NAN;
4960 assert_eq!(
4961 validate_glyph_placements(&placements),
4962 Err(GlyphPlacementError::NonFinite { index: 0 })
4963 );
4964 placements[0].x_mm = 0.0;
4965 placements[0].metrics.width_mm = -1.0;
4966 assert_eq!(
4967 validate_glyph_placements(&placements),
4968 Err(GlyphPlacementError::NegativeExtent { index: 0 })
4969 );
4970 }
4971
4972 #[test]
4973 fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
4974 let metrics = GlyphMetrics {
4975 advance_mm: 4.0,
4976 left_mm: -1.0,
4977 top_mm: -1.0,
4978 width_mm: 2.0,
4979 height_mm: 2.0,
4980 };
4981 let mut placements = vec![
4982 GlyphPlacement {
4983 resource_key: "high".into(),
4984 metrics,
4985 x_mm: 10.0,
4986 y_mm: 20.0,
4987 priority: 10,
4988 },
4989 GlyphPlacement {
4990 resource_key: "low".into(),
4991 metrics,
4992 x_mm: 10.0,
4993 y_mm: 20.0,
4994 priority: 1,
4995 },
4996 GlyphPlacement {
4997 resource_key: "far".into(),
4998 metrics,
4999 x_mm: 10.0,
5000 y_mm: 30.0,
5001 priority: 1,
5002 },
5003 ];
5004 assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
5005 assert_eq!(placements[0].x_mm, 10.0);
5006 assert_eq!(placements[1].x_mm, 13.0);
5007 assert_eq!(placements[2].x_mm, 10.0);
5008 }
5009
5010 #[test]
5011 fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
5012 let metrics = GlyphMetrics {
5013 advance_mm: 1.0,
5014 left_mm: 0.0,
5015 top_mm: 0.0,
5016 width_mm: 1.0,
5017 height_mm: 1.0,
5018 };
5019 let mut placements = vec![
5020 GlyphPlacement {
5021 resource_key: "second".into(),
5022 metrics,
5023 x_mm: 20.0,
5024 y_mm: 0.0,
5025 priority: 0,
5026 },
5027 GlyphPlacement {
5028 resource_key: "first".into(),
5029 metrics,
5030 x_mm: 10.0,
5031 y_mm: 0.0,
5032 priority: 0,
5033 },
5034 GlyphPlacement {
5035 resource_key: "third".into(),
5036 metrics,
5037 x_mm: 30.0,
5038 y_mm: 0.0,
5039 priority: 0,
5040 },
5041 ];
5042 assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
5043 assert_eq!(placements[0].x_mm, 23.0);
5044 assert_eq!(placements[1].x_mm, 10.0);
5045 assert_eq!(placements[2].x_mm, 36.0);
5046 assert_eq!(
5047 distribute_glyph_spacing(&mut placements, f32::NAN),
5048 Err(GlyphPlacementError::NonFiniteSpacing)
5049 );
5050 let before = placements.clone();
5051 assert_eq!(
5052 distribute_glyph_spacing(&mut placements, f32::MAX),
5053 Err(GlyphPlacementError::NonFiniteSpacing)
5054 );
5055 assert_eq!(placements, before);
5056 }
5057
5058 #[test]
5059 fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
5060 let mut placement = GlyphPlacement {
5061 resource_key: " ".into(),
5062 metrics: GlyphMetrics {
5063 advance_mm: 1.0,
5064 left_mm: 0.0,
5065 top_mm: 0.0,
5066 width_mm: 1.0,
5067 height_mm: 1.0,
5068 },
5069 x_mm: 0.0,
5070 y_mm: 0.0,
5071 priority: 0,
5072 };
5073 assert_eq!(
5074 validate_glyph_placements(&[placement.clone()]),
5075 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5076 );
5077 placement.resource_key = "glyph".into();
5078 placement.metrics.advance_mm = -1.0;
5079 assert_eq!(
5080 validate_glyph_placements(&[placement]),
5081 Err(GlyphPlacementError::NegativeAdvance { index: 0 })
5082 );
5083 }
5084
5085 #[test]
5086 fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
5087 let mut placements = vec![GlyphPlacement {
5088 resource_key: String::new(),
5089 metrics: GlyphMetrics {
5090 advance_mm: 1.0,
5091 left_mm: 0.0,
5092 top_mm: 0.0,
5093 width_mm: 1.0,
5094 height_mm: 1.0,
5095 },
5096 x_mm: 0.0,
5097 y_mm: 0.0,
5098 priority: 0,
5099 }];
5100 assert_eq!(
5101 resolve_glyph_collisions_checked(&mut placements, 1.0),
5102 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5103 );
5104 assert_eq!(
5105 resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
5106 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5107 );
5108 assert_eq!(placements[0].x_mm, 0.0);
5109 assert_eq!(placements[0].y_mm, 0.0);
5110 }
5111
5112 #[test]
5113 fn checked_collision_resolvers_reject_non_finite_gap() {
5114 let metrics = GlyphMetrics {
5115 advance_mm: 1.0,
5116 left_mm: 0.0,
5117 top_mm: 0.0,
5118 width_mm: 1.0,
5119 height_mm: 1.0,
5120 };
5121 let original = vec![GlyphPlacement {
5122 resource_key: "glyph".into(),
5123 metrics,
5124 x_mm: 0.0,
5125 y_mm: 0.0,
5126 priority: 0,
5127 }];
5128 let mut vertical = original.clone();
5129 assert_eq!(
5130 resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
5131 Err(GlyphPlacementError::NonFiniteSpacing)
5132 );
5133 assert_eq!(vertical, original);
5134
5135 let mut horizontal = original.clone();
5136 assert_eq!(
5137 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
5138 Err(GlyphPlacementError::NonFiniteSpacing)
5139 );
5140 assert_eq!(horizontal, original);
5141 }
5142
5143 #[test]
5144 fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
5145 let metrics = GlyphMetrics {
5146 advance_mm: 1.0,
5147 left_mm: 0.0,
5148 top_mm: 0.0,
5149 width_mm: f32::MAX / 2.0,
5150 height_mm: f32::MAX / 2.0,
5151 };
5152 let original = vec![
5153 GlyphPlacement {
5154 resource_key: "high".into(),
5155 metrics,
5156 x_mm: 0.0,
5157 y_mm: 0.0,
5158 priority: 1,
5159 },
5160 GlyphPlacement {
5161 resource_key: "low".into(),
5162 metrics,
5163 x_mm: 0.0,
5164 y_mm: 0.0,
5165 priority: 0,
5166 },
5167 ];
5168 let mut vertical = original.clone();
5169 assert_eq!(
5170 resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
5171 Err(GlyphPlacementError::NonFinite { index: 1 })
5172 );
5173 assert_eq!(vertical, original);
5174
5175 let mut horizontal = original.clone();
5176 assert_eq!(
5177 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
5178 Err(GlyphPlacementError::NonFinite { index: 1 })
5179 );
5180 assert_eq!(horizontal, original);
5181 }
5182
5183 #[test]
5184 fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
5185 let metrics = GlyphMetrics {
5186 advance_mm: 1.0,
5187 left_mm: -1.0,
5188 top_mm: -2.0,
5189 width_mm: 3.0,
5190 height_mm: 4.0,
5191 };
5192 let placements = vec![
5193 GlyphPlacement {
5194 resource_key: "a".into(),
5195 metrics,
5196 x_mm: 10.0,
5197 y_mm: 20.0,
5198 priority: 0,
5199 },
5200 GlyphPlacement {
5201 resource_key: "b".into(),
5202 metrics,
5203 x_mm: 30.0,
5204 y_mm: 5.0,
5205 priority: 0,
5206 },
5207 ];
5208 assert_eq!(
5209 glyph_extents(&placements),
5210 Ok(Some(GlyphExtents {
5211 left_mm: 9.0,
5212 top_mm: 3.0,
5213 right_mm: 32.0,
5214 bottom_mm: 22.0,
5215 }))
5216 );
5217 let extents = glyph_extents(&placements).unwrap().unwrap();
5218 assert_eq!(extents.width_mm(), 23.0);
5219 assert_eq!(extents.height_mm(), 19.0);
5220 assert_eq!(glyph_extents(&[]), Ok(None));
5221 }
5222
5223 #[test]
5224 fn glyph_extents_reject_derived_bound_overflow() {
5225 let placements = [GlyphPlacement {
5226 resource_key: "edge".into(),
5227 metrics: GlyphMetrics {
5228 advance_mm: 1.0,
5229 left_mm: 0.0,
5230 top_mm: 0.0,
5231 width_mm: f32::MAX,
5232 height_mm: 1.0,
5233 },
5234 x_mm: f32::MAX,
5235 y_mm: 0.0,
5236 priority: 0,
5237 }];
5238 assert_eq!(
5239 glyph_extents(&placements),
5240 Err(GlyphPlacementError::NonFinite { index: 0 })
5241 );
5242 }
5243
5244 #[test]
5245 fn page_render_tree_preserves_canonical_note_and_rest_addresses() {
5246 let mut score = score_with_measures(1);
5247 score.parts[0].staves[0].measures[0].voices[0] = vec![
5248 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5249 Note::rest(Duration::Quarter),
5250 ];
5251 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5252 let trees = layout
5253 .export_page_render_trees(&score)
5254 .expect("render trees");
5255 assert_eq!(trees.len(), 1);
5256 assert_eq!(trees[0].contract_version, PAGE_RENDER_TREE_CONTRACT_VERSION);
5257 assert_eq!(trees[0].validate(&score), Ok(()));
5258 assert!(trees[0].nodes.iter().any(|node| matches!(
5259 (&node.address, &node.kind),
5260 (PageRenderAddress::Note(address), PageRenderNodeKind::Note)
5261 if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 0
5262 )));
5263 assert!(trees[0].nodes.iter().any(|node| matches!(
5264 (&node.address, &node.kind),
5265 (PageRenderAddress::Note(address), PageRenderNodeKind::Rest)
5266 if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 1
5267 )));
5268 let restored: Vec<PageRenderTree> =
5269 serde_json::from_str(&serde_json::to_string(&trees).expect("trees serialize"))
5270 .expect("trees deserialize");
5271 assert_eq!(restored, trees);
5272 let mut invalid = restored[0].clone();
5273 invalid.nodes[0]
5274 .system
5275 .as_mut()
5276 .expect("system node")
5277 .page_index = 99;
5278 assert!(matches!(
5279 invalid.validate(&score),
5280 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5281 ));
5282
5283 let mut wrong_note_kind = restored[0].clone();
5284 wrong_note_kind.nodes[0].kind = PageRenderNodeKind::Rest;
5285 assert!(matches!(
5286 wrong_note_kind.validate(&score),
5287 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5288 ));
5289
5290 let mut missing_system = restored[0].clone();
5291 missing_system.nodes[0].system = None;
5292 assert!(matches!(
5293 missing_system.validate(&score),
5294 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5295 ));
5296 }
5297
5298 #[test]
5299 fn page_render_tree_for_linked_view_keeps_source_part_addresses() {
5300 let mut score = score_with_measures(1);
5301 score.parts[0].staves[0].measures[0].voices[0] =
5302 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5303 score
5304 .views
5305 .push(ScoreView::linked_part("piano", "Piano", 0));
5306 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5307 let trees = layout
5308 .export_page_render_trees_for_view(&score, "piano")
5309 .expect("view trees");
5310 assert_eq!(trees[0].view_id.as_deref(), Some("piano"));
5311 assert!(trees[0].nodes.iter().all(|node| match &node.address {
5312 PageRenderAddress::Note(address) => address.part == 0,
5313 _ => true,
5314 }));
5315 assert!(
5316 layout
5317 .export_page_render_trees_for_view(&score, "missing")
5318 .is_err()
5319 );
5320 }
5321
5322 #[test]
5323 fn page_render_tree_for_linked_view_omits_hidden_staff_nodes() {
5324 let mut score = Score::template(ScoreTemplate::Piano);
5325 score.parts[0].staves[0].measures[0].voices[0] =
5326 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5327 score.parts[0].staves[1].measures[0].voices[0] =
5328 vec![Note::new(Pitch::new(Step::C, 3), Duration::Quarter)];
5329 let mut view = ScoreView::linked_part("piano", "Piano", 0);
5330 view.layout
5331 .hidden_staves
5332 .push(ViewStaffRef { part: 0, staff: 1 });
5333 score.views.push(view);
5334
5335 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5336 let trees = layout
5337 .export_page_render_trees_for_view(&score, "piano")
5338 .expect("view trees");
5339
5340 let note_staves = trees
5341 .iter()
5342 .flat_map(|tree| tree.nodes.iter())
5343 .filter_map(|node| match &node.address {
5344 PageRenderAddress::Note(address) => Some(address.staff),
5345 _ => None,
5346 })
5347 .collect::<Vec<_>>();
5348 assert!(!note_staves.is_empty());
5349 assert!(note_staves.iter().all(|&staff| staff == 0));
5350 assert!(trees.iter().all(|tree| tree.validate(&score).is_ok()));
5351
5352 let mut invalid = trees[0].clone();
5353 invalid.nodes.push(PageRenderNode {
5354 address: PageRenderAddress::Note(NoteAddr {
5355 part: 0,
5356 staff: 1,
5357 measure: 0,
5358 voice: 0,
5359 note: 0,
5360 }),
5361 kind: PageRenderNodeKind::Note,
5362 system: None,
5363 });
5364 assert!(matches!(
5365 invalid.validate(&score),
5366 Err(PrintLayoutError::InvalidRenderTreeNode { .. })
5367 ));
5368 }
5369
5370 #[test]
5371 fn print_layout_for_linked_view_applies_local_breaks_without_mutating_score() {
5372 let mut score = score_with_measures(4);
5373 let mut view = ScoreView::linked_part("part", "Part", 0);
5374 view.layout.measures_per_row = Some(3);
5375 view.layout.system_breaks.push(1);
5376 score.views.push(view);
5377
5378 let layout = compute_print_layout_for_view(&score, &PrintConfig::default(), "part")
5379 .expect("view layout");
5380 let systems = layout
5381 .pages
5382 .iter()
5383 .flat_map(|page| page.systems.iter())
5384 .collect::<Vec<_>>();
5385 assert_eq!(
5386 systems
5387 .iter()
5388 .map(|system| system.measure_indices.clone())
5389 .collect::<Vec<_>>(),
5390 vec![vec![0, 1], vec![2, 3]]
5391 );
5392 assert!(!score.parts[0].staves[0].measures[1].system_break);
5393 }
5394
5395 #[test]
5396 fn page_render_tree_keeps_page_scoped_resource_addresses() {
5397 let score = score_with_measures(1);
5398 let config = PrintConfig {
5399 publication: PublicationConfig {
5400 title_page: true,
5401 image_resources: vec![
5402 PublicationImageResource {
5403 resource_key: "cover-art-v1".into(),
5404 alt_text: "Cover".into(),
5405 placement: PublicationImagePlacement::TitlePage,
5406 x_mm: 10.0,
5407 y_mm: 10.0,
5408 width_mm: 30.0,
5409 height_mm: 20.0,
5410 },
5411 PublicationImageResource {
5412 resource_key: "publisher-mark".into(),
5413 alt_text: "Mark".into(),
5414 placement: PublicationImagePlacement::MusicPages,
5415 x_mm: 160.0,
5416 y_mm: 10.0,
5417 width_mm: 20.0,
5418 height_mm: 10.0,
5419 },
5420 ],
5421 frames: vec![PublicationFrame {
5422 placement: PublicationFramePlacement::EveryPage,
5423 x_mm: 5.0,
5424 y_mm: 5.0,
5425 width_mm: 200.0,
5426 height_mm: 287.0,
5427 stroke_width_mm: 0.5,
5428 }],
5429 ..PublicationConfig::default()
5430 },
5431 ..PrintConfig::default()
5432 };
5433 let layout = compute_print_layout(&score, &config).expect("layout");
5434 let trees = layout
5435 .export_page_render_trees(&score)
5436 .expect("render trees");
5437 assert!(trees[0].nodes.iter().any(|node| matches!(
5438 (&node.address, &node.kind),
5439 (
5440 PageRenderAddress::Resource { page_index: 0, resource_key },
5441 PageRenderNodeKind::Resource,
5442 ) if resource_key == "cover-art-v1"
5443 )));
5444 assert!(trees[1].nodes.iter().any(|node| matches!(
5445 (&node.address, &node.kind),
5446 (
5447 PageRenderAddress::Resource { page_index: 1, resource_key },
5448 PageRenderNodeKind::Resource,
5449 ) if resource_key == "publisher-mark"
5450 )));
5451 assert!(
5452 trees
5453 .iter()
5454 .enumerate()
5455 .all(|(page_index, tree)| tree.nodes.iter().any(|node| matches!(
5456 (&node.address, &node.kind),
5457 (
5458 PageRenderAddress::Frame { page_index: address_page, frame_index: 0 },
5459 PageRenderNodeKind::Frame,
5460 ) if *address_page == page_index
5461 )))
5462 );
5463 }
5464}