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 let mut previous_system_bottom_mm = 0.0;
1614 for (index_on_page, system) in page.systems.iter().enumerate() {
1615 if system.page_index != page_index
1616 || system.address.page_index != page_index
1617 || system.address.index_on_page != index_on_page
1618 || system.system_index != expected_system_index
1619 || system.address.system_index != expected_system_index
1620 {
1621 return Err(PrintLayoutError::InvalidSystemAddress {
1622 page_index,
1623 index_on_page,
1624 system_index: expected_system_index,
1625 });
1626 }
1627 let system_bottom_mm = system.top_mm + system.height_mm;
1628 if !system.top_mm.is_finite()
1629 || system.top_mm < 0.0
1630 || !system.height_mm.is_finite()
1631 || system.height_mm <= 0.0
1632 || !system_bottom_mm.is_finite()
1633 || system_bottom_mm > page.height_mm
1634 || system.top_mm < previous_system_bottom_mm
1635 {
1636 return Err(PrintLayoutError::InvalidSystemGeometry {
1637 page_index,
1638 index_on_page,
1639 });
1640 }
1641 previous_system_bottom_mm = system_bottom_mm;
1642 expected_system_index += 1;
1643 }
1644 }
1645 Ok(())
1646 }
1647
1648 pub fn page(&self, address: PageAddress) -> Option<&PageLayout> {
1650 self.pages
1651 .get(address.page_index)
1652 .filter(|page| page.address == address)
1653 }
1654
1655 pub fn export_page_artifacts(&self) -> Result<Vec<PageArtifact>, PrintLayoutError> {
1661 self.validate()?;
1662 Ok(self
1663 .pages
1664 .iter()
1665 .map(|page| PageArtifact {
1666 address: page.address,
1667 page_index: page.page_index,
1668 page_number: page.page_number,
1669 width_mm: page.width_mm,
1670 height_mm: page.height_mm,
1671 content_width_mm: page.content_width_mm,
1672 content_height_mm: page.content_height_mm,
1673 measure_span: page.measure_span(),
1674 diagnostics: page.artifact_diagnostics(None),
1675 layout: page.clone(),
1676 })
1677 .collect())
1678 }
1679
1680 pub fn export_page_render_trees(
1685 &self,
1686 score: &Score,
1687 ) -> Result<Vec<PageRenderTree>, PrintLayoutError> {
1688 let artifacts = self.export_page_artifacts()?;
1689 Ok(self
1690 .pages
1691 .iter()
1692 .zip(artifacts)
1693 .map(|(page, artifact)| {
1694 let mut nodes = Vec::new();
1695 for system in &page.systems {
1696 for span in &system.measure_spans {
1697 for (part_index, part) in score.parts.iter().enumerate() {
1698 for (staff_index, staff) in part.staves.iter().enumerate() {
1699 for measure_index in span.first_measure..=span.last_measure {
1700 let Some(measure) = staff.measures.get(measure_index) else {
1701 continue;
1702 };
1703 for (voice, notes) in measure.voices.iter().enumerate() {
1704 for (note, value) in notes.iter().enumerate() {
1705 nodes.push(PageRenderNode {
1706 address: PageRenderAddress::Note(NoteAddr {
1707 part: part_index,
1708 staff: staff_index,
1709 measure: measure_index,
1710 voice,
1711 note,
1712 }),
1713 kind: if value.is_rest {
1714 PageRenderNodeKind::Rest
1715 } else {
1716 PageRenderNodeKind::Note
1717 },
1718 system: Some(system.address),
1719 });
1720 }
1721 }
1722 }
1723 }
1724 }
1725 }
1726 for segment in &system.span_segments {
1727 if let Some(spanner) = score.spanners.get(segment.span_index) {
1728 nodes.push(PageRenderNode {
1729 address: PageRenderAddress::Spanner {
1730 id: spanner.id.clone(),
1731 },
1732 kind: PageRenderNodeKind::Spanner {
1733 starts_here: segment.starts_here,
1734 ends_here: segment.ends_here,
1735 },
1736 system: Some(system.address),
1737 });
1738 }
1739 }
1740 }
1741 for (block_index, _) in page.publication.text_blocks.iter().enumerate() {
1742 nodes.push(PageRenderNode {
1743 address: PageRenderAddress::Publication {
1744 page_index: page.page_index,
1745 block_index,
1746 },
1747 kind: PageRenderNodeKind::PublicationBlock,
1748 system: None,
1749 });
1750 }
1751 for image in &page.publication.image_resources {
1752 nodes.push(PageRenderNode {
1753 address: PageRenderAddress::Resource {
1754 page_index: page.page_index,
1755 resource_key: image.resource_key.clone(),
1756 },
1757 kind: PageRenderNodeKind::Resource,
1758 system: None,
1759 });
1760 }
1761 for (frame_index, _) in page.publication.frames.iter().enumerate() {
1762 nodes.push(PageRenderNode {
1763 address: PageRenderAddress::Frame {
1764 page_index: page.page_index,
1765 frame_index,
1766 },
1767 kind: PageRenderNodeKind::Frame,
1768 system: None,
1769 });
1770 }
1771 PageRenderTree {
1772 contract_version: PAGE_RENDER_TREE_CONTRACT_VERSION,
1773 view_id: None,
1774 page: artifact,
1775 nodes,
1776 }
1777 })
1778 .collect())
1779 }
1780
1781 pub fn export_page_render_trees_for_view(
1783 &self,
1784 score: &Score,
1785 view_id: &str,
1786 ) -> Result<Vec<PageRenderTree>, PrintLayoutError> {
1787 let view = score
1788 .views
1789 .iter()
1790 .find(|view| view.id == view_id)
1791 .ok_or(PrintLayoutError::InvalidView)?;
1792 let selected_parts = &view.parts;
1793 let hidden_staves = &view.layout.hidden_staves;
1794 let staff_is_visible = |part: usize, staff: usize| {
1795 selected_parts.contains(&part)
1796 && !hidden_staves
1797 .iter()
1798 .any(|hidden| hidden.part == part && hidden.staff == staff)
1799 };
1800 let mut trees = self.export_page_render_trees(score)?;
1801 for tree in &mut trees {
1802 tree.view_id = Some(view.id.clone());
1803 tree.nodes.retain(|node| match &node.address {
1804 PageRenderAddress::Note(address) => staff_is_visible(address.part, address.staff),
1805 PageRenderAddress::Spanner { id } => score
1806 .spanners
1807 .iter()
1808 .find(|spanner| spanner.id == *id)
1809 .is_some_and(|spanner| {
1810 staff_is_visible(spanner.start.part, spanner.start.staff)
1811 && staff_is_visible(spanner.end.part, spanner.end.staff)
1812 }),
1813 PageRenderAddress::Publication { .. } => true,
1814 PageRenderAddress::Resource { .. } => true,
1815 PageRenderAddress::Frame { .. } => true,
1816 });
1817 }
1818 Ok(trees)
1819 }
1820}
1821
1822#[derive(Debug, thiserror::Error, PartialEq)]
1823pub enum PrintLayoutError {
1824 #[error("unsupported page render tree contract version {found}")]
1825 UnsupportedPageRenderTreeContractVersion { found: u16 },
1826 #[error("page render tree node {node_index} has invalid ownership or address")]
1827 InvalidRenderTreeNode { node_index: usize },
1828 #[error("unknown score view")]
1829 InvalidView,
1830 #[error("paper dimensions must be finite and greater than zero")]
1831 InvalidPaperDimensions,
1832 #[error("margins must be finite and non-negative")]
1833 InvalidMargins,
1834 #[error("system height must be finite, positive, and fit the usable page area")]
1835 InvalidSystemHeight,
1836 #[error("print scale must be finite and greater than zero")]
1837 InvalidScale,
1838 #[error("margins leave no usable page area")]
1839 NoUsablePageArea,
1840 #[error("keep-together range is outside the score or reversed")]
1841 InvalidKeepTogetherRange,
1842 #[error("keep-together range exceeds the measures-per-system capacity")]
1843 KeepTogetherExceedsSystemCapacity,
1844 #[error("keep-together range conflicts with an explicit system or page break")]
1845 KeepTogetherConflictsWithExplicitBreak,
1846 #[error("repeat section exceeds the systems-per-page capacity")]
1847 RepeatRangeExceedsPageCapacity,
1848 #[error("extracted part index is outside the score")]
1849 InvalidPartIndex,
1850 #[error("publication line height must be finite and greater than zero")]
1851 InvalidPublicationLineHeight,
1852 #[error("host-provided glyph resource key must not be empty")]
1853 InvalidGlyphResourceKey,
1854 #[error("publication image resource {index} is invalid")]
1855 InvalidPublicationImageResource { index: usize },
1856 #[error("publication section {index} is invalid")]
1857 InvalidPublicationSection { index: usize },
1858 #[error("publication section {index} starts inside a keep-together range")]
1859 PublicationSectionConflictsWithKeepTogether { index: usize },
1860 #[error("publication spacer {index} is invalid or cannot fit before one system")]
1861 InvalidPublicationSpacer { index: usize },
1862 #[error("publication frame {index} is invalid")]
1863 InvalidPublicationFrame { index: usize },
1864 #[error("unsupported print layout contract version {found}")]
1865 UnsupportedContractVersion { found: u16 },
1866 #[error("page {page_index} has an inconsistent stable address")]
1867 InvalidPageAddress { page_index: usize },
1868 #[error("page {page_index} has an invalid or non-monotonic page number")]
1869 InvalidPageNumber { page_index: usize },
1870 #[error("page {page_index} has inconsistent title-page metadata")]
1871 InvalidTitlePage { page_index: usize },
1872 #[error(
1873 "system {system_index} at page {page_index}, position {index_on_page} has an inconsistent stable address"
1874 )]
1875 InvalidSystemAddress {
1876 page_index: usize,
1877 index_on_page: usize,
1878 system_index: usize,
1879 },
1880 #[error("page {page_index} has invalid physical geometry")]
1881 InvalidPageGeometry { page_index: usize },
1882 #[error("page {page_index} has invalid publication metadata")]
1883 InvalidPublicationMetadata { page_index: usize },
1884 #[error("system at page {page_index}, position {index_on_page} has invalid physical geometry")]
1885 InvalidSystemGeometry {
1886 page_index: usize,
1887 index_on_page: usize,
1888 },
1889}
1890
1891fn apply_keep_together(
1892 score: &Score,
1893 mut rows: Vec<crate::RowLayout>,
1894 ranges: &[KeepTogetherRange],
1895 capacity: usize,
1896) -> Result<Vec<crate::RowLayout>, PrintLayoutError> {
1897 let measure_count = score
1898 .parts
1899 .first()
1900 .and_then(|part| part.staves.first())
1901 .map(|staff| staff.measures.len())
1902 .unwrap_or(0);
1903 for range in ranges {
1904 let length = range
1905 .last_measure
1906 .checked_sub(range.first_measure)
1907 .and_then(|length| length.checked_add(1));
1908 if range.first_measure > range.last_measure || range.last_measure >= measure_count {
1909 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1910 }
1911 if length.is_none_or(|length| length > capacity) {
1912 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1913 }
1914 for measure_index in range.first_measure..range.last_measure {
1915 let has_break = score
1916 .parts
1917 .iter()
1918 .flat_map(|part| part.staves.iter())
1919 .filter_map(|staff| staff.measures.get(measure_index))
1920 .any(|measure| measure.system_break || measure.page_break);
1921 if has_break {
1922 return Err(PrintLayoutError::KeepTogetherConflictsWithExplicitBreak);
1923 }
1924 }
1925
1926 let mut split_rows = Vec::with_capacity(rows.len() + 2);
1930 for row in rows {
1931 let mut cuts = vec![0, row.measure_indices.len()];
1932 if let Some(position) = row
1933 .measure_indices
1934 .iter()
1935 .position(|&index| index == range.first_measure)
1936 {
1937 cuts.push(position);
1938 }
1939 if let Some(position) = row
1940 .measure_indices
1941 .iter()
1942 .position(|&index| index == range.last_measure)
1943 {
1944 cuts.push(position + 1);
1945 }
1946 cuts.sort_unstable();
1947 cuts.dedup();
1948 for window in cuts.windows(2) {
1949 if window[0] < window[1] {
1950 split_rows.push(crate::RowLayout {
1951 measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
1952 });
1953 }
1954 }
1955 }
1956 rows = split_rows;
1957
1958 let first_row = rows
1959 .iter()
1960 .position(|row| row.measure_indices.contains(&range.first_measure));
1961 let last_row = rows
1962 .iter()
1963 .position(|row| row.measure_indices.contains(&range.last_measure));
1964 let (Some(first_row), Some(last_row)) = (first_row, last_row) else {
1965 return Err(PrintLayoutError::InvalidKeepTogetherRange);
1966 };
1967
1968 if first_row != last_row {
1969 let merged: Vec<usize> = rows[first_row..=last_row]
1970 .iter()
1971 .flat_map(|row| row.measure_indices.iter().copied())
1972 .collect();
1973 if merged.len() > capacity {
1974 return Err(PrintLayoutError::KeepTogetherExceedsSystemCapacity);
1975 }
1976 rows.splice(
1977 first_row..=last_row,
1978 [crate::RowLayout {
1979 measure_indices: merged,
1980 }],
1981 );
1982 }
1983
1984 let row_index = rows
1985 .iter()
1986 .position(|row| row.measure_indices.contains(&range.first_measure))
1987 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1988 let row = rows.remove(row_index);
1989 let start = row
1990 .measure_indices
1991 .iter()
1992 .position(|&index| index == range.first_measure)
1993 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1994 let end = row
1995 .measure_indices
1996 .iter()
1997 .position(|&index| index == range.last_measure)
1998 .ok_or(PrintLayoutError::InvalidKeepTogetherRange)?;
1999 let mut replacement = Vec::new();
2000 if start > 0 {
2001 replacement.push(crate::RowLayout {
2002 measure_indices: row.measure_indices[..start].to_vec(),
2003 });
2004 }
2005 replacement.push(crate::RowLayout {
2006 measure_indices: row.measure_indices[start..=end].to_vec(),
2007 });
2008 if end + 1 < row.measure_indices.len() {
2009 replacement.push(crate::RowLayout {
2010 measure_indices: row.measure_indices[end + 1..].to_vec(),
2011 });
2012 }
2013 rows.splice(row_index..row_index, replacement);
2014 }
2015 Ok(rows)
2016}
2017
2018fn has_first_measure_pickup(score: &Score) -> bool {
2019 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2020 return false;
2021 };
2022 let Some(measure) = staff.measures.first() else {
2023 return false;
2024 };
2025 if let Some(length) = measure.actual_length.and_then(|length| length.beats()) {
2027 let bar = measure
2028 .time_sig
2029 .as_ref()
2030 .unwrap_or(&score.settings.time_signature)
2031 .total_beats();
2032 return length < bar - 1e-9;
2033 }
2034 let expected = measure
2035 .time_sig
2036 .as_ref()
2037 .unwrap_or(&score.settings.time_signature)
2038 .total_beats();
2039 let actual = measure
2040 .voices
2041 .iter()
2042 .map(|voice| voice.iter().map(|note| note.beats()).sum::<f64>())
2043 .fold(0.0, f64::max);
2044 actual > 1e-9 && actual + 1e-9 < expected
2045}
2046
2047fn measure_spans(score: &Score, measure_indices: &[usize]) -> Vec<MeasureSpan> {
2048 let measure_count = score
2049 .parts
2050 .first()
2051 .and_then(|part| part.staves.first())
2052 .map(|staff| staff.measures.len())
2053 .unwrap_or(0);
2054 measure_indices
2055 .iter()
2056 .filter_map(|&first_measure| {
2057 if first_measure >= measure_count {
2058 return None;
2059 }
2060 let count = score
2061 .parts
2062 .iter()
2063 .flat_map(|part| part.staves.iter())
2064 .filter_map(|staff| staff.measures.get(first_measure))
2065 .filter_map(|measure| measure.multi_rest_count)
2066 .map(usize::from)
2067 .max()
2068 .unwrap_or(1)
2069 .max(1);
2070 Some(MeasureSpan {
2071 first_measure,
2072 last_measure: first_measure
2073 .saturating_add(count.saturating_sub(1))
2074 .min(measure_count.saturating_sub(1)),
2075 })
2076 })
2077 .collect()
2078}
2079
2080fn span_bounds(span: &SpanMark) -> (usize, usize) {
2081 match span {
2082 SpanMark::Hairpin { start, end, .. }
2083 | SpanMark::Ottava { start, end, .. }
2084 | SpanMark::Pedal { start, end }
2085 | SpanMark::Slur { start, end }
2086 | SpanMark::TrillLine { start, end }
2087 | SpanMark::Glissando { start, end }
2088 | SpanMark::Harmony { start, end, .. } => (
2089 start.measure.min(end.measure),
2090 start.measure.max(end.measure),
2091 ),
2092 }
2093}
2094
2095fn span_segments(spans: &[SpanMark], measure_indices: &[usize]) -> Vec<SpanSegment> {
2096 let (Some(&first_measure), Some(&last_measure)) =
2097 (measure_indices.first(), measure_indices.last())
2098 else {
2099 return Vec::new();
2100 };
2101 spans
2102 .iter()
2103 .enumerate()
2104 .filter_map(|(span_index, span)| {
2105 let (start_measure, end_measure) = span_bounds(span);
2106 (start_measure <= last_measure && end_measure >= first_measure).then_some(SpanSegment {
2107 span_index,
2108 starts_here: (first_measure..=last_measure).contains(&start_measure),
2109 ends_here: (first_measure..=last_measure).contains(&end_measure),
2110 })
2111 })
2112 .collect()
2113}
2114
2115fn measure_marks(score: &Score, measure_indices: &[usize]) -> Vec<MeasureMark> {
2116 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2117 return Vec::new();
2118 };
2119 measure_indices
2120 .iter()
2121 .filter_map(|&measure_index| {
2122 let measure = staff.measures.get(measure_index)?;
2123 let repeat_start = matches!(
2124 measure.barline_left,
2125 Barline::RepeatStart | Barline::RepeatBoth
2126 );
2127 let repeat_end = matches!(
2128 measure.barline_right,
2129 Barline::RepeatEnd | Barline::RepeatBoth
2130 );
2131 let text_annotations = measure_text_entries(measure);
2132 let has_mark = repeat_start
2133 || repeat_end
2134 || measure.volta.is_some()
2135 || measure.navigation.is_some()
2136 || measure.rehearsal.is_some()
2137 || !text_annotations.is_empty();
2138 has_mark.then(|| MeasureMark {
2139 measure_index,
2140 repeat_start,
2141 repeat_end,
2142 volta_number: measure.volta.as_ref().map(|volta| volta.number),
2143 volta_kind: measure.volta.as_ref().map(|volta| volta.kind.clone()),
2144 navigation: measure.navigation.clone(),
2145 rehearsal: measure.rehearsal.clone(),
2146 text_annotations,
2147 })
2148 })
2149 .collect()
2150}
2151
2152fn measure_text_entries(measure: &acorde_core::Measure) -> Vec<StyledText> {
2153 let mut entries = measure.texts.clone();
2154 for (style, text) in [
2155 (TextStyle::Generic, measure.tempo_text.as_deref()),
2156 (TextStyle::RehearsalMark, measure.rehearsal.as_deref()),
2157 (TextStyle::Generic, measure.navigation.as_deref()),
2158 (TextStyle::Expression, measure.expression_text.as_deref()),
2159 ] {
2160 let Some(text) = text else {
2161 continue;
2162 };
2163 if entries
2164 .iter()
2165 .any(|entry| entry.style == style && entry.text == text)
2166 {
2167 continue;
2168 }
2169 entries.push(StyledText {
2170 style,
2171 text: text.to_owned(),
2172 placement: None,
2173 offset_x: None,
2174 offset_y: None,
2175 relative_x: None,
2176 relative_y: None,
2177 });
2178 }
2179 entries
2180}
2181
2182fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2183 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2184 return Vec::new();
2185 };
2186 let mut ranges = Vec::new();
2187 let mut start = None;
2188 for (index, measure) in staff.measures.iter().enumerate() {
2189 let Some(volta) = measure.volta.as_ref() else {
2190 continue;
2191 };
2192 if matches!(volta.kind.as_str(), "begin" | "begin_end") {
2193 start = Some(index);
2194 }
2195 if matches!(volta.kind.as_str(), "end" | "begin_end")
2196 && let Some(first_measure) = start.take()
2197 {
2198 ranges.push(KeepTogetherRange {
2199 first_measure,
2200 last_measure: index,
2201 });
2202 }
2203 }
2204 ranges
2205}
2206
2207fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2208 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2209 return Vec::new();
2210 };
2211 let mut ranges = Vec::new();
2212 let mut start = None;
2213 for (index, measure) in staff.measures.iter().enumerate() {
2214 if matches!(
2215 measure.barline_left,
2216 Barline::RepeatStart | Barline::RepeatBoth
2217 ) {
2218 start = Some(index);
2219 }
2220 if matches!(
2221 measure.barline_right,
2222 Barline::RepeatEnd | Barline::RepeatBoth
2223 ) {
2224 ranges.push(KeepTogetherRange {
2225 first_measure: start.take().unwrap_or(0),
2226 last_measure: index,
2227 });
2228 }
2229 }
2230 ranges
2231}
2232
2233fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
2234 repeat_ranges(score)
2235 .into_iter()
2236 .filter_map(|range| {
2237 let first = rows
2238 .iter()
2239 .position(|row| row.measure_indices.contains(&range.first_measure))?;
2240 let last = rows
2241 .iter()
2242 .position(|row| row.measure_indices.contains(&range.last_measure))?;
2243 Some((first, last))
2244 })
2245 .collect()
2246}
2247
2248fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
2249 let mut segments = Vec::new();
2250 for system in systems {
2251 for segment in &system.span_segments {
2252 if let Some(existing) = segments
2253 .iter_mut()
2254 .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
2255 {
2256 existing.ends_here |= segment.ends_here;
2257 } else {
2258 segments.push(PageSpanSegment {
2259 span_index: segment.span_index,
2260 starts_here: segment.starts_here,
2261 ends_here: segment.ends_here,
2262 });
2263 }
2264 }
2265 }
2266 segments
2267}
2268
2269fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
2270 systems
2271 .iter()
2272 .flat_map(|system| system.measure_marks.iter().cloned())
2273 .collect()
2274}
2275
2276fn page_publication(
2277 score: &Score,
2278 measure_score: &Score,
2279 config: &PrintConfig,
2280 systems: &[SystemLayout],
2281 is_title_page: bool,
2282 page_number: Option<usize>,
2283) -> PagePublication {
2284 let metadata = &score.metadata;
2285 let part_labels = if config.publication.show_part_names {
2286 let parts = match config.part_layout {
2287 PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
2288 PartLayoutPolicy::ExtractedPart { part_index } => score
2289 .parts
2290 .get(part_index)
2291 .into_iter()
2292 .enumerate()
2293 .map(|(index, part)| (part_index + index, part))
2294 .collect(),
2295 };
2296 parts
2297 .into_iter()
2298 .map(|(part_index, part)| PartLabel {
2299 part_index,
2300 name: part.name.clone(),
2301 short_name: part.short_name.clone(),
2302 })
2303 .collect()
2304 } else {
2305 Vec::new()
2306 };
2307 let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
2308 score
2309 .part_groups
2310 .iter()
2311 .map(|group| PartGroupMark {
2312 first_part: group.first_part,
2313 last_part: group.last_part,
2314 symbol: group.symbol.clone(),
2315 barlines_connect: group.barlines_connect,
2316 })
2317 .collect()
2318 } else {
2319 Vec::new()
2320 };
2321 let measure_numbers = if config.publication.show_measure_numbers {
2322 let staff = measure_score
2323 .parts
2324 .first()
2325 .and_then(|part| part.staves.first());
2326 systems
2327 .iter()
2328 .flat_map(|system| system.measure_indices.iter().copied())
2329 .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
2330 .map(|measure| measure.number)
2331 .collect()
2332 } else {
2333 Vec::new()
2334 };
2335 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2336 let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
2337 (paper_height, paper_width)
2338 } else {
2339 (paper_width, paper_height)
2340 };
2341 let mut text_blocks = Vec::new();
2342 let header_text = if config.publication.header_template.is_configured() {
2343 config.publication.header_template.resolve(page_number)
2344 } else {
2345 config
2346 .publication
2347 .header_text
2348 .as_ref()
2349 .or(config.publication.running_title.as_ref())
2350 };
2351 if !is_title_page && let Some(text) = header_text {
2352 text_blocks.push(PublicationTextBlock {
2353 role: PublicationTextRole::Header,
2354 text: text.clone(),
2355 x_mm: config.margin_left_mm + config.safe_left_mm,
2356 y_mm: config.margin_top_mm,
2357 width_mm: page_width
2358 - config.margin_left_mm
2359 - config.margin_right_mm
2360 - config.safe_left_mm
2361 - config.safe_right_mm,
2362 height_mm: config.publication.line_height_mm,
2363 alignment: config.publication.header_alignment,
2364 });
2365 }
2366 let footer_text = if config.publication.footer_template.is_configured() {
2367 config.publication.footer_template.resolve(page_number)
2368 } else {
2369 config.publication.footer_text.as_ref()
2370 };
2371 if let Some(text) = footer_text {
2372 text_blocks.push(PublicationTextBlock {
2373 role: PublicationTextRole::Footer,
2374 text: text.clone(),
2375 x_mm: config.margin_left_mm + config.safe_left_mm,
2376 y_mm: page_height - config.margin_bottom_mm,
2377 width_mm: page_width
2378 - config.margin_left_mm
2379 - config.margin_right_mm
2380 - config.safe_left_mm
2381 - config.safe_right_mm,
2382 height_mm: config.publication.line_height_mm,
2383 alignment: config.publication.footer_alignment,
2384 });
2385 }
2386 if config.publication.page_number_in_footer
2387 && let Some(page_number) = page_number
2388 {
2389 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2390 let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape)
2391 {
2392 (paper_height, paper_width)
2393 } else {
2394 (paper_width, paper_height)
2395 };
2396 text_blocks.push(PublicationTextBlock {
2397 role: PublicationTextRole::Footer,
2398 text: page_number.to_string(),
2399 x_mm: config.margin_left_mm + config.safe_left_mm,
2400 y_mm: page_height - config.margin_bottom_mm,
2401 width_mm: page_width
2402 - config.margin_left_mm
2403 - config.margin_right_mm
2404 - config.safe_left_mm
2405 - config.safe_right_mm,
2406 height_mm: config.publication.line_height_mm,
2407 alignment: config.publication.footer_alignment,
2408 });
2409 }
2410 if is_title_page {
2411 let content_height = page_height
2412 - config.margin_top_mm
2413 - config.margin_bottom_mm
2414 - config.safe_top_mm
2415 - config.safe_bottom_mm;
2416 let title_x = config.margin_left_mm + config.safe_left_mm;
2417 let title_width = page_width
2418 - config.margin_left_mm
2419 - config.margin_right_mm
2420 - config.safe_left_mm
2421 - config.safe_right_mm;
2422 let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
2423 if !metadata.title.trim().is_empty() {
2424 text_blocks.push(PublicationTextBlock {
2425 role: PublicationTextRole::Title,
2426 text: metadata.title.clone(),
2427 x_mm: title_x,
2428 y_mm: title_y,
2429 width_mm: title_width,
2430 height_mm: config.publication.line_height_mm,
2431 alignment: config.publication.title_alignment,
2432 });
2433 }
2434 if !metadata.movement_title.trim().is_empty() {
2435 text_blocks.push(PublicationTextBlock {
2436 role: PublicationTextRole::Subtitle,
2437 text: metadata.movement_title.clone(),
2438 x_mm: title_x,
2439 y_mm: title_y + config.publication.line_height_mm * 2.5,
2440 width_mm: title_width,
2441 height_mm: config.publication.line_height_mm,
2442 alignment: config.publication.title_alignment,
2443 });
2444 }
2445 let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
2446 (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
2447 format!("{composer} / {lyricist}")
2448 }
2449 (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
2450 (_, lyricist) => lyricist.to_string(),
2451 };
2452 if !credit.is_empty() {
2453 text_blocks.push(PublicationTextBlock {
2454 role: PublicationTextRole::Credit,
2455 text: credit,
2456 x_mm: title_x,
2457 y_mm: title_y + config.publication.line_height_mm * 5.0,
2458 width_mm: title_width,
2459 height_mm: config.publication.line_height_mm,
2460 alignment: config.publication.title_alignment,
2461 });
2462 }
2463 if !metadata.copyright.trim().is_empty() {
2464 text_blocks.push(PublicationTextBlock {
2465 role: PublicationTextRole::Copyright,
2466 text: metadata.copyright.clone(),
2467 x_mm: title_x,
2468 y_mm: page_height - config.margin_bottom_mm,
2469 width_mm: title_width,
2470 height_mm: config.publication.line_height_mm,
2471 alignment: config.publication.title_alignment,
2472 });
2473 }
2474 }
2475 let image_resources = config
2476 .publication
2477 .image_resources
2478 .iter()
2479 .filter(|image| {
2480 matches!(
2481 (is_title_page, image.placement),
2482 (
2483 true,
2484 PublicationImagePlacement::TitlePage | PublicationImagePlacement::EveryPage
2485 ) | (
2486 false,
2487 PublicationImagePlacement::MusicPages | PublicationImagePlacement::EveryPage
2488 )
2489 )
2490 })
2491 .cloned()
2492 .collect();
2493 let sections = if is_title_page {
2494 Vec::new()
2495 } else {
2496 config
2497 .publication
2498 .sections
2499 .iter()
2500 .filter(|section| {
2501 systems
2502 .iter()
2503 .any(|system| system.measure_indices.contains(§ion.first_measure))
2504 })
2505 .cloned()
2506 .collect()
2507 };
2508 let spacers = if is_title_page {
2509 Vec::new()
2510 } else {
2511 config
2512 .publication
2513 .spacers
2514 .iter()
2515 .filter(|spacer| {
2516 systems
2517 .iter()
2518 .any(|system| system.measure_indices.contains(&spacer.before_measure))
2519 })
2520 .cloned()
2521 .collect()
2522 };
2523 let frames = config
2524 .publication
2525 .frames
2526 .iter()
2527 .filter(|frame| {
2528 matches!(
2529 (is_title_page, frame.placement),
2530 (
2531 true,
2532 PublicationFramePlacement::TitlePage | PublicationFramePlacement::EveryPage
2533 ) | (
2534 false,
2535 PublicationFramePlacement::MusicPages | PublicationFramePlacement::EveryPage
2536 )
2537 )
2538 })
2539 .cloned()
2540 .collect();
2541 PagePublication {
2542 is_title_page,
2543 title: metadata.title.clone(),
2544 movement_title: metadata.movement_title.clone(),
2545 composer: metadata.composer.clone(),
2546 lyricist: metadata.lyricist.clone(),
2547 copyright: metadata.copyright.clone(),
2548 running_title: config.publication.running_title.clone(),
2549 score_texts: score.texts.clone(),
2550 part_labels,
2551 part_groups,
2552 measure_numbers,
2553 text_blocks,
2554 image_resources,
2555 sections,
2556 spacers,
2557 frames,
2558 }
2559}
2560
2561#[allow(clippy::too_many_arguments)]
2562fn build_page_layout(
2563 score: &Score,
2564 layout_score: &Score,
2565 config: &PrintConfig,
2566 systems: Vec<SystemLayout>,
2567 page_index: usize,
2568 page_number: Option<usize>,
2569 width_mm: f32,
2570 height_mm: f32,
2571 content_width_mm: f32,
2572 content_height_mm: f32,
2573 break_reason: BreakReason,
2574 is_title_page: bool,
2575) -> PageLayout {
2576 let publication = page_publication(
2577 score,
2578 layout_score,
2579 config,
2580 &systems,
2581 is_title_page,
2582 page_number,
2583 );
2584 let span_segments = if is_title_page {
2585 Vec::new()
2586 } else {
2587 page_span_segments(&systems)
2588 };
2589 let measure_marks = if is_title_page {
2590 Vec::new()
2591 } else {
2592 page_measure_marks(&systems)
2593 };
2594 PageLayout {
2595 address: PageAddress { page_index },
2596 page_index,
2597 page_number,
2598 color_policy: config.color_policy,
2599 crop_mark_policy: config.crop_mark_policy,
2600 glyph_resources: config.glyph_resources.clone(),
2601 publication,
2602 width_mm,
2603 height_mm,
2604 content_width_mm,
2605 content_height_mm,
2606 bleed_top_mm: config.bleed_top_mm,
2607 bleed_right_mm: config.bleed_right_mm,
2608 bleed_bottom_mm: config.bleed_bottom_mm,
2609 bleed_left_mm: config.bleed_left_mm,
2610 span_segments,
2611 measure_marks,
2612 systems,
2613 break_reason,
2614 }
2615}
2616
2617fn score_for_part_layout(
2618 score: &Score,
2619 policy: PartLayoutPolicy,
2620) -> Result<Score, PrintLayoutError> {
2621 let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
2622 return Ok(score.clone());
2623 };
2624 let Some(part) = score.parts.get(part_index) else {
2625 return Err(PrintLayoutError::InvalidPartIndex);
2626 };
2627 let mut selected = score.clone();
2628 selected.parts = vec![part.clone()];
2629 selected.part_groups.clear();
2630 Ok(selected)
2631}
2632
2633fn validate_publication_sections(
2634 score: &Score,
2635 sections: &[PublicationSection],
2636 keep_together: &[KeepTogetherRange],
2637) -> Result<(), PrintLayoutError> {
2638 let measure_count = score
2639 .parts
2640 .first()
2641 .and_then(|part| part.staves.first())
2642 .map_or(0, |staff| staff.measures.len());
2643 let mut previous_start = None;
2644 for (index, section) in sections.iter().enumerate() {
2645 if section.first_measure >= measure_count
2646 || section.title.trim().is_empty()
2647 || section.title.len() > 1024
2648 || previous_start.is_some_and(|previous| previous >= section.first_measure)
2649 {
2650 return Err(PrintLayoutError::InvalidPublicationSection { index });
2651 }
2652 if keep_together.iter().any(|range| {
2653 range.first_measure < section.first_measure
2654 && section.first_measure <= range.last_measure
2655 }) {
2656 return Err(PrintLayoutError::PublicationSectionConflictsWithKeepTogether { index });
2657 }
2658 previous_start = Some(section.first_measure);
2659 }
2660 Ok(())
2661}
2662
2663fn validate_publication_spacers(
2664 score: &Score,
2665 spacers: &[PublicationSpacer],
2666 keep_together: &[KeepTogetherRange],
2667 content_height_mm: f32,
2668 scaled_system_height_mm: f32,
2669) -> Result<(), PrintLayoutError> {
2670 let measure_count = score
2671 .parts
2672 .first()
2673 .and_then(|part| part.staves.first())
2674 .map_or(0, |staff| staff.measures.len());
2675 let mut previous_measure = None;
2676 for (index, spacer) in spacers.iter().enumerate() {
2677 if spacer.before_measure >= measure_count
2678 || !spacer.height_mm.is_finite()
2679 || spacer.height_mm <= 0.0
2680 || spacer.height_mm + scaled_system_height_mm > content_height_mm
2681 || previous_measure.is_some_and(|previous| previous >= spacer.before_measure)
2682 || keep_together.iter().any(|range| {
2683 range.first_measure < spacer.before_measure
2684 && spacer.before_measure <= range.last_measure
2685 })
2686 {
2687 return Err(PrintLayoutError::InvalidPublicationSpacer { index });
2688 }
2689 previous_measure = Some(spacer.before_measure);
2690 }
2691 Ok(())
2692}
2693
2694fn spacer_height_before_measure(spacers: &[PublicationSpacer], measure_index: usize) -> f32 {
2695 spacers
2696 .iter()
2697 .find(|spacer| spacer.before_measure == measure_index)
2698 .map_or(0.0, |spacer| spacer.height_mm)
2699}
2700
2701fn split_rows_at_measure_starts(
2702 rows: Vec<crate::RowLayout>,
2703 starts: &[usize],
2704) -> Vec<crate::RowLayout> {
2705 if starts.is_empty() {
2706 return rows;
2707 }
2708 let mut split_rows = Vec::with_capacity(rows.len() + starts.len());
2709 for row in rows {
2710 let mut cuts = vec![0, row.measure_indices.len()];
2711 for start in starts {
2712 if let Some(position) = row.measure_indices.iter().position(|index| index == start) {
2713 cuts.push(position);
2714 }
2715 }
2716 cuts.sort_unstable();
2717 cuts.dedup();
2718 for window in cuts.windows(2) {
2719 if window[0] < window[1] {
2720 split_rows.push(crate::RowLayout {
2721 measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
2722 });
2723 }
2724 }
2725 }
2726 split_rows
2727}
2728
2729fn publication_image_resource_is_valid(
2730 image: &PublicationImageResource,
2731 width_mm: f32,
2732 height_mm: f32,
2733) -> bool {
2734 let safe_key = !image.resource_key.trim().is_empty()
2735 && image.resource_key.len() <= 256
2736 && !image.resource_key.contains("..")
2737 && !image.resource_key.contains(['/', '\\', ':']);
2738 let safe_alt_text = !image.alt_text.trim().is_empty() && image.alt_text.len() <= 4096;
2739 let finite_geometry = [image.x_mm, image.y_mm, image.width_mm, image.height_mm]
2740 .iter()
2741 .all(|value| value.is_finite());
2742 let fits_page = image.x_mm >= 0.0
2743 && image.y_mm >= 0.0
2744 && image.width_mm > 0.0
2745 && image.height_mm > 0.0
2746 && image.x_mm + image.width_mm <= width_mm
2747 && image.y_mm + image.height_mm <= height_mm;
2748 safe_key && safe_alt_text && finite_geometry && fits_page
2749}
2750
2751fn publication_frame_is_valid(frame: &PublicationFrame, width_mm: f32, height_mm: f32) -> bool {
2752 let finite_geometry = [
2753 frame.x_mm,
2754 frame.y_mm,
2755 frame.width_mm,
2756 frame.height_mm,
2757 frame.stroke_width_mm,
2758 ]
2759 .iter()
2760 .all(|value| value.is_finite());
2761 let fits_page = frame.x_mm >= 0.0
2762 && frame.y_mm >= 0.0
2763 && frame.width_mm > 0.0
2764 && frame.height_mm > 0.0
2765 && frame.stroke_width_mm > 0.0
2766 && frame.x_mm + frame.width_mm <= width_mm
2767 && frame.y_mm + frame.height_mm <= height_mm;
2768 finite_geometry && fits_page
2769}
2770
2771fn publication_page_sections_are_valid(sections: &[PublicationSection]) -> bool {
2772 sections.iter().enumerate().all(|(index, section)| {
2773 !section.title.trim().is_empty()
2774 && section.title.len() <= 1024
2775 && (index == 0 || sections[index - 1].first_measure < section.first_measure)
2776 })
2777}
2778
2779fn publication_page_spacers_are_valid(spacers: &[PublicationSpacer]) -> bool {
2780 spacers.iter().enumerate().all(|(index, spacer)| {
2781 spacer.height_mm.is_finite()
2782 && spacer.height_mm > 0.0
2783 && (index == 0 || spacers[index - 1].before_measure < spacer.before_measure)
2784 })
2785}
2786
2787fn validate_print_config(config: &PrintConfig) -> Result<(f32, f32, f32), PrintLayoutError> {
2788 let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
2789 if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
2790 return Err(PrintLayoutError::InvalidPaperDimensions);
2791 }
2792 if matches!(config.orientation, PageOrientation::Landscape) {
2793 std::mem::swap(&mut width_mm, &mut height_mm);
2794 }
2795
2796 let margins = [
2797 config.margin_top_mm,
2798 config.margin_right_mm,
2799 config.margin_bottom_mm,
2800 config.margin_left_mm,
2801 config.bleed_top_mm,
2802 config.bleed_right_mm,
2803 config.bleed_bottom_mm,
2804 config.bleed_left_mm,
2805 config.safe_top_mm,
2806 config.safe_right_mm,
2807 config.safe_bottom_mm,
2808 config.safe_left_mm,
2809 ];
2810 if margins
2811 .iter()
2812 .any(|value| !value.is_finite() || *value < 0.0)
2813 {
2814 return Err(PrintLayoutError::InvalidMargins);
2815 }
2816 if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
2817 return Err(PrintLayoutError::InvalidSystemHeight);
2818 }
2819 if !config.scale.is_finite() || config.scale <= 0.0 {
2820 return Err(PrintLayoutError::InvalidScale);
2821 }
2822 let scaled_system_height_mm = config.system_height_mm * config.scale;
2823 if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
2824 return Err(PrintLayoutError::InvalidScale);
2825 }
2826 if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
2827 return Err(PrintLayoutError::InvalidPublicationLineHeight);
2828 }
2829 for (index, image) in config.publication.image_resources.iter().enumerate() {
2830 if !publication_image_resource_is_valid(image, width_mm, height_mm) {
2831 return Err(PrintLayoutError::InvalidPublicationImageResource { index });
2832 }
2833 }
2834 for (index, frame) in config.publication.frames.iter().enumerate() {
2835 if !publication_frame_is_valid(frame, width_mm, height_mm) {
2836 return Err(PrintLayoutError::InvalidPublicationFrame { index });
2837 }
2838 }
2839 if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
2840 {
2841 return Err(PrintLayoutError::InvalidGlyphResourceKey);
2842 }
2843 Ok((width_mm, height_mm, scaled_system_height_mm))
2844}
2845
2846pub fn compute_print_layout(
2848 score: &Score,
2849 config: &PrintConfig,
2850) -> Result<PrintLayoutResult, PrintLayoutError> {
2851 let layout_score = score_for_part_layout(score, config.part_layout)?;
2852 let (width_mm, height_mm, scaled_system_height_mm) = validate_print_config(config)?;
2853
2854 let content_width_mm = width_mm
2855 - config.margin_left_mm
2856 - config.margin_right_mm
2857 - config.safe_left_mm
2858 - config.safe_right_mm;
2859 let content_height_mm = height_mm
2860 - config.margin_top_mm
2861 - config.margin_bottom_mm
2862 - config.safe_top_mm
2863 - config.safe_bottom_mm;
2864 if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
2865 return Err(PrintLayoutError::NoUsablePageArea);
2866 }
2867
2868 if scaled_system_height_mm > content_height_mm {
2869 return Err(PrintLayoutError::InvalidSystemHeight);
2870 }
2871 let geometric_capacity = (content_height_mm / scaled_system_height_mm).floor() as usize;
2872 if config
2873 .systems_per_page
2874 .is_some_and(|requested| requested.max(1) > geometric_capacity)
2875 {
2876 return Err(PrintLayoutError::InvalidSystemHeight);
2877 }
2878 let systems_per_page = config.systems_per_page.unwrap_or(geometric_capacity).max(1);
2879 let layout = compute_layout(
2880 &layout_score,
2881 &LayoutConfig {
2882 measures_per_row: config.measures_per_system.max(1),
2883 first_row_measures: config.first_system_measures.or_else(|| {
2884 (matches!(
2885 config.pickup_policy,
2886 PickupPolicy::Auto | PickupPolicy::DetectFirstMeasure
2887 ) && has_first_measure_pickup(&layout_score))
2888 .then_some(1)
2889 }),
2890 ..LayoutConfig::default()
2891 },
2892 );
2893
2894 let mut keep_together = config.keep_together.clone();
2895 if matches!(
2896 config.notation_break_policy,
2897 NotationBreakPolicy::KeepVoltaTogether
2898 ) {
2899 keep_together.extend(volta_ranges(&layout_score));
2900 }
2901 let rows = apply_keep_together(
2902 &layout_score,
2903 layout.rows,
2904 &keep_together,
2905 config.measures_per_system.max(1),
2906 )?;
2907 validate_publication_sections(&layout_score, &config.publication.sections, &keep_together)?;
2908 validate_publication_spacers(
2909 &layout_score,
2910 &config.publication.spacers,
2911 &keep_together,
2912 content_height_mm,
2913 scaled_system_height_mm,
2914 )?;
2915 let rows = split_rows_at_measure_starts(
2916 rows,
2917 &config
2918 .publication
2919 .sections
2920 .iter()
2921 .map(|section| section.first_measure)
2922 .chain(
2923 config
2924 .publication
2925 .spacers
2926 .iter()
2927 .map(|spacer| spacer.before_measure),
2928 )
2929 .collect::<Vec<_>>(),
2930 );
2931
2932 let has_explicit_page_break = rows.iter().any(|row| {
2933 row.measure_indices.last().is_some_and(|&measure_index| {
2934 layout_score
2935 .parts
2936 .iter()
2937 .flat_map(|part| part.staves.iter())
2938 .filter_map(|staff| staff.measures.get(measure_index))
2939 .any(|measure| measure.page_break)
2940 })
2941 });
2942 let repeat_system_ranges = if matches!(
2943 config.notation_break_policy,
2944 NotationBreakPolicy::KeepRepeatsTogether
2945 ) {
2946 repeat_system_ranges(&layout_score, &rows)
2947 } else {
2948 Vec::new()
2949 };
2950 if repeat_system_ranges
2951 .iter()
2952 .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
2953 {
2954 return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
2955 }
2956 let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
2957 && !has_explicit_page_break
2958 && systems_per_page > 1
2959 && rows.len() > systems_per_page
2960 && repeat_system_ranges.is_empty()
2961 && !config
2962 .publication
2963 .sections
2964 .iter()
2965 .any(|section| section.start_on_new_page)
2966 && config.publication.spacers.is_empty()
2967 {
2968 let page_count = rows.len().div_ceil(systems_per_page);
2969 let base = rows.len() / page_count;
2970 let remainder = rows.len() % page_count;
2971 (0..page_count)
2972 .map(|index| base + usize::from(index < remainder))
2973 .collect::<Vec<_>>()
2974 } else {
2975 Vec::new()
2976 };
2977
2978 let mut pages = Vec::new();
2979 let mut page_systems = Vec::new();
2980 let mut page_used_height_mm = 0.0;
2981 let mut page_index = 0;
2982 for (system_index, row) in rows.iter().enumerate() {
2983 let repeat_starts_here = repeat_system_ranges
2984 .iter()
2985 .any(|(first, _)| *first == system_index);
2986 let section_starts_on_new_page =
2987 row.measure_indices.first().is_some_and(|measure_index| {
2988 config.publication.sections.iter().any(|section| {
2989 section.first_measure == *measure_index && section.start_on_new_page
2990 })
2991 });
2992 let spacer_height_mm = row.measure_indices.first().map_or(0.0, |measure_index| {
2993 spacer_height_before_measure(&config.publication.spacers, *measure_index)
2994 });
2995 let spacer_requires_new_page = !page_systems.is_empty()
2996 && page_used_height_mm + spacer_height_mm + scaled_system_height_mm > content_height_mm;
2997 if (repeat_starts_here || section_starts_on_new_page || spacer_requires_new_page)
2998 && !page_systems.is_empty()
2999 {
3000 let page_number = match config.page_numbering {
3001 PageNumbering::None => None,
3002 PageNumbering::OneBased => Some(page_index + 1),
3003 };
3004 pages.push(build_page_layout(
3005 score,
3006 &layout_score,
3007 config,
3008 std::mem::take(&mut page_systems),
3009 page_index,
3010 page_number,
3011 width_mm,
3012 height_mm,
3013 content_width_mm,
3014 content_height_mm,
3015 if section_starts_on_new_page {
3016 BreakReason::SectionBreak
3017 } else {
3018 BreakReason::PageCapacity
3019 },
3020 false,
3021 ));
3022 page_index += 1;
3023 page_used_height_mm = 0.0;
3024 }
3025 let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
3026 layout_score
3027 .parts
3028 .iter()
3029 .flat_map(|part| part.staves.iter())
3030 .filter_map(|staff| staff.measures.get(measure_index))
3031 .any(|measure| measure.page_break)
3032 });
3033 let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
3034 layout_score
3035 .parts
3036 .iter()
3037 .flat_map(|part| part.staves.iter())
3038 .filter_map(|staff| staff.measures.get(measure_index))
3039 .any(|measure| measure.system_break)
3040 });
3041 let is_last_system = system_index + 1 == rows.len();
3042 let break_reason = if explicit_page_break {
3043 BreakReason::ExplicitPageBreak
3044 } else if explicit_system_break {
3045 BreakReason::ExplicitSystemBreak
3046 } else if is_last_system {
3047 BreakReason::EndOfScore
3048 } else {
3049 BreakReason::MeasureCapacity
3050 };
3051 let system = SystemLayout {
3052 address: SystemAddress {
3053 system_index,
3054 page_index,
3055 index_on_page: page_systems.len(),
3056 },
3057 system_index,
3058 page_index,
3059 measure_indices: row.measure_indices.clone(),
3060 measure_spans: measure_spans(&layout_score, &row.measure_indices),
3061 span_segments: span_segments(&layout.spans, &row.measure_indices),
3062 measure_marks: measure_marks(&layout_score, &row.measure_indices),
3063 top_mm: config.margin_top_mm
3064 + config.safe_top_mm
3065 + page_used_height_mm
3066 + spacer_height_mm,
3067 height_mm: scaled_system_height_mm,
3068 break_reason,
3069 };
3070 page_systems.push(system);
3071 page_used_height_mm += spacer_height_mm + scaled_system_height_mm;
3072
3073 let page_capacity = page_capacities
3074 .get(page_index)
3075 .copied()
3076 .unwrap_or(systems_per_page);
3077 let page_is_full = page_systems.len() >= page_capacity;
3078 if page_is_full || explicit_page_break {
3079 let page_break_reason = if explicit_page_break {
3080 BreakReason::ExplicitPageBreak
3081 } else if is_last_system {
3082 BreakReason::EndOfScore
3083 } else {
3084 BreakReason::PageCapacity
3085 };
3086 let page_number = match config.page_numbering {
3087 PageNumbering::None => None,
3088 PageNumbering::OneBased => Some(page_index + 1),
3089 };
3090 pages.push(build_page_layout(
3091 score,
3092 &layout_score,
3093 config,
3094 std::mem::take(&mut page_systems),
3095 page_index,
3096 page_number,
3097 width_mm,
3098 height_mm,
3099 content_width_mm,
3100 content_height_mm,
3101 page_break_reason,
3102 false,
3103 ));
3104 page_index += 1;
3105 page_used_height_mm = 0.0;
3106 }
3107 }
3108 if !page_systems.is_empty() || pages.is_empty() {
3109 let page_number = match config.page_numbering {
3110 PageNumbering::None => None,
3111 PageNumbering::OneBased => Some(page_index + 1),
3112 };
3113 pages.push(build_page_layout(
3114 score,
3115 &layout_score,
3116 config,
3117 page_systems,
3118 page_index,
3119 page_number,
3120 width_mm,
3121 height_mm,
3122 content_width_mm,
3123 content_height_mm,
3124 BreakReason::EndOfScore,
3125 false,
3126 ));
3127 }
3128
3129 if config.publication.title_page {
3130 for page in &mut pages {
3131 page.page_index += 1;
3132 page.address.page_index = page.page_index;
3133 page.page_number = match config.page_numbering {
3134 PageNumbering::None => None,
3135 PageNumbering::OneBased => Some(page.page_index + 1),
3136 };
3137 for system in &mut page.systems {
3138 system.page_index += 1;
3139 system.address.page_index = system.page_index;
3140 }
3141 page.publication = page_publication(
3142 score,
3143 &layout_score,
3144 config,
3145 &page.systems,
3146 false,
3147 page.page_number,
3148 );
3149 }
3150 let page_number = match config.page_numbering {
3151 PageNumbering::None => None,
3152 PageNumbering::OneBased => Some(1),
3153 };
3154 pages.insert(
3155 0,
3156 build_page_layout(
3157 score,
3158 &layout_score,
3159 config,
3160 Vec::new(),
3161 0,
3162 page_number,
3163 width_mm,
3164 height_mm,
3165 content_width_mm,
3166 content_height_mm,
3167 BreakReason::TitlePage,
3168 true,
3169 ),
3170 );
3171 }
3172
3173 let result = PrintLayoutResult {
3174 contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
3175 pages,
3176 };
3177 result.validate()?;
3178 Ok(result)
3179}
3180
3181pub fn compute_print_layout_for_view(
3188 score: &Score,
3189 config: &PrintConfig,
3190 view_id: &str,
3191) -> Result<PrintLayoutResult, PrintLayoutError> {
3192 let view = score
3193 .views
3194 .iter()
3195 .find(|view| view.id == view_id)
3196 .ok_or(PrintLayoutError::InvalidView)?;
3197 let mut view_config = config.clone();
3198 if let Some(measures_per_row) = view.layout.measures_per_row {
3199 view_config.measures_per_system = measures_per_row;
3200 }
3201
3202 let mut projected = score
3203 .resolve_view(view_id)
3204 .map_err(|_| PrintLayoutError::InvalidView)?;
3205 for (indices, is_page_break) in [
3206 (&view.layout.system_breaks, false),
3207 (&view.layout.page_breaks, true),
3208 ] {
3209 for &measure_index in indices {
3210 let mut found = false;
3211 for part in &mut projected.parts {
3212 for staff in &mut part.staves {
3213 if let Some(measure) = staff.measures.get_mut(measure_index) {
3214 found = true;
3215 if is_page_break {
3216 measure.page_break = true;
3217 } else {
3218 measure.system_break = true;
3219 }
3220 }
3221 }
3222 }
3223 if !found {
3224 return Err(PrintLayoutError::InvalidView);
3225 }
3226 }
3227 }
3228 compute_print_layout(&projected, &view_config)
3229}
3230
3231#[cfg(test)]
3232mod tests {
3233 use super::*;
3234 use acorde_core::{
3235 Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score,
3236 ScoreTemplate, ScoreView, Staff, Step, ViewStaffRef,
3237 };
3238
3239 fn score_with_measures(count: usize) -> Score {
3240 let mut score = Score::default();
3241 let mut part = Part::new("Piano", "Pno.");
3242 let mut staff = Staff::new(Clef::Treble);
3243 staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
3244 part.staves = vec![staff];
3245 score.parts = vec![part];
3246 score
3247 }
3248
3249 #[test]
3250 fn publication_metadata_accepts_legacy_partial_json() {
3251 let publication: PagePublication =
3252 serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
3253 .expect("legacy publication metadata should deserialize");
3254
3255 assert!(publication.is_title_page);
3256 assert_eq!(publication.title, "Legacy score");
3257 assert!(publication.movement_title.is_empty());
3258 assert!(publication.part_labels.is_empty());
3259 assert!(publication.part_groups.is_empty());
3260 assert!(publication.text_blocks.is_empty());
3261 }
3262
3263 #[test]
3264 fn layout_validation_rejects_unsupported_contract_version() {
3265 let score = score_with_measures(1);
3266 let mut result =
3267 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3268 result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
3269
3270 assert_eq!(
3271 result.validate(),
3272 Err(PrintLayoutError::UnsupportedContractVersion {
3273 found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
3274 })
3275 );
3276 }
3277
3278 #[test]
3279 fn paginates_rows_and_preserves_measure_indices() {
3280 let score = score_with_measures(5);
3281 let result = compute_print_layout(
3282 &score,
3283 &PrintConfig {
3284 measures_per_system: 2,
3285 systems_per_page: Some(2),
3286 ..PrintConfig::default()
3287 },
3288 )
3289 .expect("valid print config");
3290 assert_eq!(result.pages.len(), 2);
3291 assert_eq!(
3292 result.pages[0]
3293 .systems
3294 .iter()
3295 .map(|s| s.measure_indices.clone())
3296 .collect::<Vec<_>>(),
3297 vec![vec![0, 1], vec![2, 3]]
3298 );
3299 assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
3300 assert_eq!(result.pages[1].systems[0].page_index, 1);
3301 assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
3302 assert_eq!(
3303 result.pages[1].systems[0].break_reason,
3304 BreakReason::EndOfScore
3305 );
3306 assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
3307 }
3308
3309 #[test]
3310 fn forced_page_break_starts_next_system_on_next_page() {
3311 let mut score = score_with_measures(3);
3312 score.parts[0].staves[0].measures[0].page_break = true;
3313 let result = compute_print_layout(
3314 &score,
3315 &PrintConfig {
3316 measures_per_system: 3,
3317 systems_per_page: Some(8),
3318 ..PrintConfig::default()
3319 },
3320 )
3321 .expect("valid print config");
3322 assert_eq!(result.pages.len(), 2);
3323 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3324 assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
3325 assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
3326 assert_eq!(
3327 result.pages[0].systems[0].break_reason,
3328 BreakReason::ExplicitPageBreak
3329 );
3330 }
3331
3332 #[test]
3333 fn keep_together_range_is_not_split_across_systems() {
3334 let score = score_with_measures(5);
3335 let result = compute_print_layout(
3336 &score,
3337 &PrintConfig {
3338 measures_per_system: 3,
3339 systems_per_page: Some(8),
3340 keep_together: vec![KeepTogetherRange {
3341 first_measure: 1,
3342 last_measure: 2,
3343 }],
3344 ..PrintConfig::default()
3345 },
3346 )
3347 .expect("valid keep-together range");
3348 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3349 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
3350 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
3351 }
3352
3353 #[test]
3354 fn first_system_measure_capacity_is_preserved_in_print_layout() {
3355 let score = score_with_measures(5);
3356 let result = compute_print_layout(
3357 &score,
3358 &PrintConfig {
3359 measures_per_system: 3,
3360 first_system_measures: Some(1),
3361 systems_per_page: Some(8),
3362 ..PrintConfig::default()
3363 },
3364 )
3365 .expect("valid first-system capacity");
3366 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3367 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3368 assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
3369 }
3370
3371 #[test]
3372 fn pickup_policy_isolates_a_partial_first_measure() {
3373 let mut score = score_with_measures(4);
3374 score.parts[0].staves[0].measures[0].voices[0] =
3375 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3376 let result = compute_print_layout(
3377 &score,
3378 &PrintConfig {
3379 measures_per_system: 3,
3380 pickup_policy: PickupPolicy::DetectFirstMeasure,
3381 systems_per_page: Some(8),
3382 ..PrintConfig::default()
3383 },
3384 )
3385 .expect("valid pickup policy");
3386 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3387 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3388 }
3389
3390 #[test]
3391 fn pickup_policy_auto_isolates_a_partial_first_measure_by_default() {
3392 let mut score = score_with_measures(4);
3393 score.parts[0].staves[0].measures[0].voices[0] =
3394 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3395 let result = compute_print_layout(
3396 &score,
3397 &PrintConfig {
3398 measures_per_system: 3,
3399 systems_per_page: Some(8),
3400 ..PrintConfig::default()
3401 },
3402 )
3403 .expect("valid automatic pickup policy");
3404 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3405 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3406 }
3407
3408 #[test]
3409 fn system_exposes_physical_span_for_multi_rest_slot() {
3410 let mut score = score_with_measures(6);
3411 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3412 let result = compute_print_layout(&score, &PrintConfig::default())
3413 .expect("valid multi-rest print layout");
3414 assert_eq!(
3415 result.pages[0].systems[0].measure_spans[1],
3416 MeasureSpan {
3417 first_measure: 1,
3418 last_measure: 3,
3419 }
3420 );
3421 }
3422
3423 #[test]
3424 fn multirest_width_drives_system_breaking_without_splitting() {
3425 let mut score = score_with_measures(5);
3426 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3427 let result = compute_print_layout(
3428 &score,
3429 &PrintConfig {
3430 measures_per_system: 2,
3431 pickup_policy: PickupPolicy::Preserve,
3432 systems_per_page: Some(8),
3433 ..PrintConfig::default()
3434 },
3435 )
3436 .expect("valid multi-rest pagination");
3437 assert_eq!(
3438 result.pages[0]
3439 .systems
3440 .iter()
3441 .map(|system| system.measure_indices.clone())
3442 .collect::<Vec<_>>(),
3443 vec![vec![0], vec![1], vec![2, 3], vec![4]]
3444 );
3445 assert_eq!(
3446 result.pages[0].systems[1].measure_spans[0],
3447 MeasureSpan {
3448 first_measure: 1,
3449 last_measure: 3,
3450 }
3451 );
3452 }
3453
3454 #[test]
3455 fn system_exposes_cross_system_span_segments() {
3456 let mut score = score_with_measures(4);
3457 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3458 start.slur_start = true;
3459 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3460 end.slur_end = true;
3461 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3462 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3463 let result = compute_print_layout(
3464 &score,
3465 &PrintConfig {
3466 measures_per_system: 2,
3467 pickup_policy: PickupPolicy::Preserve,
3468 systems_per_page: Some(8),
3469 ..PrintConfig::default()
3470 },
3471 )
3472 .expect("valid cross-system span layout");
3473 assert_eq!(
3474 result.pages[0].systems[0].span_segments,
3475 vec![SpanSegment {
3476 span_index: 0,
3477 starts_here: true,
3478 ends_here: false,
3479 }]
3480 );
3481 assert_eq!(
3482 result.pages[0].systems[1].span_segments,
3483 vec![SpanSegment {
3484 span_index: 0,
3485 starts_here: false,
3486 ends_here: true,
3487 }]
3488 );
3489 }
3490
3491 #[test]
3492 fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
3493 let mut score = score_with_measures(4);
3494 let measures = &mut score.parts[0].staves[0].measures;
3495 measures[0].barline_right = Barline::RepeatEnd;
3496 measures[1].barline_left = Barline::RepeatStart;
3497 measures[2].volta = Some(acorde_core::VoltaBracket {
3498 number: 1,
3499 kind: "begin".to_string(),
3500 numbers: Vec::new(),
3501 });
3502 measures[2].navigation = Some("ToCoda".to_string());
3503 measures[2].rehearsal = Some("B".to_string());
3504 let result = compute_print_layout(
3505 &score,
3506 &PrintConfig {
3507 measures_per_system: 2,
3508 systems_per_page: Some(8),
3509 ..PrintConfig::default()
3510 },
3511 )
3512 .expect("valid measure mark layout");
3513 assert_eq!(
3514 result.pages[0].systems[0].measure_marks,
3515 vec![
3516 MeasureMark {
3517 measure_index: 0,
3518 repeat_start: false,
3519 repeat_end: true,
3520 volta_number: None,
3521 volta_kind: None,
3522 navigation: None,
3523 rehearsal: None,
3524 text_annotations: vec![],
3525 },
3526 MeasureMark {
3527 measure_index: 1,
3528 repeat_start: true,
3529 repeat_end: false,
3530 volta_number: None,
3531 volta_kind: None,
3532 navigation: None,
3533 rehearsal: None,
3534 text_annotations: vec![],
3535 },
3536 ]
3537 );
3538 assert_eq!(
3539 result.pages[0].systems[1].measure_marks,
3540 vec![MeasureMark {
3541 measure_index: 2,
3542 repeat_start: false,
3543 repeat_end: false,
3544 volta_number: Some(1),
3545 volta_kind: Some("begin".to_string()),
3546 navigation: Some("ToCoda".to_string()),
3547 rehearsal: Some("B".to_string()),
3548 text_annotations: vec![
3549 acorde_core::StyledText {
3550 style: acorde_core::TextStyle::RehearsalMark,
3551 text: "B".to_string(),
3552 placement: None,
3553 offset_x: None,
3554 offset_y: None,
3555 relative_x: None,
3556 relative_y: None,
3557 },
3558 acorde_core::StyledText {
3559 style: acorde_core::TextStyle::Generic,
3560 text: "ToCoda".to_string(),
3561 placement: None,
3562 offset_x: None,
3563 offset_y: None,
3564 relative_x: None,
3565 relative_y: None,
3566 },
3567 ],
3568 }]
3569 );
3570 }
3571
3572 #[test]
3573 fn system_exposes_explicit_measure_text_without_legacy_fields() {
3574 let mut score = score_with_measures(1);
3575 score.parts[0].staves[0].measures[0]
3576 .texts
3577 .push(acorde_core::StyledText {
3578 style: acorde_core::TextStyle::Expression,
3579 text: "dolce".to_string(),
3580 placement: Some("above".to_string()),
3581 offset_x: Some(2.0),
3582 offset_y: Some(-1.0),
3583 relative_x: None,
3584 relative_y: None,
3585 });
3586 let result = compute_print_layout(&score, &PrintConfig::default())
3587 .expect("valid explicit measure text layout");
3588 let annotations = &result.pages[0].systems[0].measure_marks[0].text_annotations;
3589 assert_eq!(annotations.len(), 1);
3590 assert_eq!(annotations[0].style, acorde_core::TextStyle::Expression);
3591 assert_eq!(annotations[0].text, "dolce");
3592 assert_eq!(annotations[0].placement.as_deref(), Some("above"));
3593 assert_eq!(annotations[0].offset_x, Some(2.0));
3594 assert_eq!(annotations[0].offset_y, Some(-1.0));
3595 }
3596
3597 #[test]
3598 fn page_aggregates_cross_system_span_ownership() {
3599 let mut score = score_with_measures(4);
3600 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3601 start.slur_start = true;
3602 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3603 end.slur_end = true;
3604 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3605 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3606 let result = compute_print_layout(
3607 &score,
3608 &PrintConfig {
3609 measures_per_system: 2,
3610 pickup_policy: PickupPolicy::Preserve,
3611 systems_per_page: Some(1),
3612 ..PrintConfig::default()
3613 },
3614 )
3615 .expect("valid page span layout");
3616 assert_eq!(
3617 result.pages[0].span_segments,
3618 vec![PageSpanSegment {
3619 span_index: 0,
3620 starts_here: true,
3621 ends_here: false,
3622 }]
3623 );
3624 assert_eq!(
3625 result.pages[1].span_segments,
3626 vec![PageSpanSegment {
3627 span_index: 0,
3628 starts_here: false,
3629 ends_here: true,
3630 }]
3631 );
3632 }
3633
3634 #[test]
3635 fn page_artifact_measure_span_borrows_system_spans() {
3636 let mut score = score_with_measures(4);
3637 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3638 start.slur_start = true;
3639 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3640 end.slur_end = true;
3641 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3642 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3643 let result = compute_print_layout(
3644 &score,
3645 &PrintConfig {
3646 measures_per_system: 2,
3647 pickup_policy: PickupPolicy::Preserve,
3648 systems_per_page: Some(1),
3649 ..PrintConfig::default()
3650 },
3651 )
3652 .expect("valid page artifact");
3653 let first = result
3654 .page(PageAddress { page_index: 0 })
3655 .expect("first page");
3656 assert_eq!(
3657 first.measure_span(),
3658 Some(MeasureSpan {
3659 first_measure: 0,
3660 last_measure: 1,
3661 })
3662 );
3663 assert!(first.has_span_continuation());
3664 assert!(result.page(PageAddress { page_index: 99 }).is_none());
3665 assert!(result.validate().is_ok());
3666 }
3667
3668 #[test]
3669 fn export_page_artifacts_reports_host_glyph_resource_requirement() {
3670 let result = compute_print_layout(
3671 &score_with_measures(1),
3672 &PrintConfig {
3673 glyph_resources: GlyphResourcePolicy::HostProvided("licensed-font-v1".into()),
3674 ..PrintConfig::default()
3675 },
3676 )
3677 .expect("valid host resource policy");
3678
3679 let artifacts = result
3680 .export_page_artifacts()
3681 .expect("host resource requirement is a diagnostic");
3682 assert_eq!(
3683 artifacts[0].diagnostics,
3684 vec![PageArtifactDiagnostic::GlyphResourceRequired]
3685 );
3686 assert_eq!(
3687 artifacts[0].layout.glyph_resources,
3688 GlyphResourcePolicy::HostProvided("licensed-font-v1".into())
3689 );
3690 }
3691
3692 #[test]
3693 fn page_artifact_diagnostics_report_glyph_overflow_sides() {
3694 let result = compute_print_layout(&score_with_measures(1), &PrintConfig::default())
3695 .expect("valid print layout");
3696 let page = &result.pages[0];
3697 assert_eq!(
3698 page.artifact_diagnostics(Some(GlyphExtents {
3699 left_mm: -1.0,
3700 top_mm: -2.0,
3701 right_mm: page.content_width_mm + 3.0,
3702 bottom_mm: page.content_height_mm + 4.0,
3703 })),
3704 vec![PageArtifactDiagnostic::GlyphOverflow {
3705 left: true,
3706 top: true,
3707 right: true,
3708 bottom: true,
3709 }]
3710 );
3711 assert!(page.artifact_diagnostics(None).is_empty());
3712 }
3713
3714 #[test]
3715 fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
3716 let mut score = score_with_measures(4);
3717 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3718 start.slur_start = true;
3719 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3720 end.slur_end = true;
3721 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3722 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3723 let result = compute_print_layout(
3724 &score,
3725 &PrintConfig {
3726 measures_per_system: 2,
3727 pickup_policy: PickupPolicy::Preserve,
3728 systems_per_page: Some(1),
3729 ..PrintConfig::default()
3730 },
3731 )
3732 .expect("valid print config");
3733
3734 let artifacts = result
3735 .export_page_artifacts()
3736 .expect("valid page artifacts");
3737 assert_eq!(artifacts.len(), 2);
3738 assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
3739 assert_eq!(artifacts[1].page_index, 1);
3740 assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
3741 assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
3742 assert_eq!(
3743 artifacts[0].measure_span,
3744 Some(MeasureSpan {
3745 first_measure: 0,
3746 last_measure: 1,
3747 })
3748 );
3749 assert_eq!(
3750 artifacts[0].diagnostics,
3751 vec![PageArtifactDiagnostic::SpanContinuation {
3752 span_index: 0,
3753 starts_here: true,
3754 ends_here: false,
3755 }]
3756 );
3757 assert_eq!(
3758 artifacts[1].diagnostics,
3759 vec![PageArtifactDiagnostic::SpanContinuation {
3760 span_index: 0,
3761 starts_here: false,
3762 ends_here: true,
3763 }]
3764 );
3765 }
3766
3767 #[test]
3768 fn export_page_artifacts_rejects_invalid_serialized_layout() {
3769 let score = score_with_measures(1);
3770 let mut result =
3771 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3772 result.pages[0].width_mm = f32::NAN;
3773
3774 assert!(matches!(
3775 result.export_page_artifacts(),
3776 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3777 ));
3778 }
3779
3780 #[test]
3781 fn page_lookup_rejects_mismatched_serialized_address() {
3782 let score = score_with_measures(1);
3783 let mut result =
3784 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3785 result.pages[0].address = PageAddress { page_index: 7 };
3786
3787 assert!(result.page(PageAddress { page_index: 0 }).is_none());
3788 assert_eq!(
3789 result.validate(),
3790 Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
3791 );
3792 }
3793
3794 #[test]
3795 fn layout_validation_rejects_mismatched_system_address() {
3796 let score = score_with_measures(2);
3797 let mut result = compute_print_layout(
3798 &score,
3799 &PrintConfig {
3800 measures_per_system: 1,
3801 ..PrintConfig::default()
3802 },
3803 )
3804 .expect("valid print config");
3805 result.pages[0].systems[0].address.index_on_page = 4;
3806
3807 assert_eq!(
3808 result.validate(),
3809 Err(PrintLayoutError::InvalidSystemAddress {
3810 page_index: 0,
3811 index_on_page: 0,
3812 system_index: 0,
3813 })
3814 );
3815 }
3816
3817 #[test]
3818 fn layout_validation_rejects_non_monotonic_page_number() {
3819 let score = score_with_measures(2);
3820 let mut result = compute_print_layout(
3821 &score,
3822 &PrintConfig {
3823 measures_per_system: 1,
3824 page_numbering: PageNumbering::OneBased,
3825 systems_per_page: Some(1),
3826 ..PrintConfig::default()
3827 },
3828 )
3829 .expect("valid print config");
3830 result.pages[1].page_number = Some(1);
3831
3832 assert_eq!(
3833 result.validate(),
3834 Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
3835 );
3836 }
3837
3838 #[test]
3839 fn layout_validation_rejects_inconsistent_title_page_metadata() {
3840 let score = score_with_measures(1);
3841 let mut result =
3842 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3843 result.pages[0].publication.is_title_page = true;
3844
3845 assert_eq!(
3846 result.validate(),
3847 Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
3848 );
3849 }
3850
3851 #[test]
3852 fn layout_validation_rejects_non_finite_page_geometry() {
3853 let score = score_with_measures(1);
3854 let mut result =
3855 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3856 result.pages[0].width_mm = f32::NAN;
3857
3858 assert_eq!(
3859 result.validate(),
3860 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3861 );
3862 }
3863
3864 #[test]
3865 fn layout_validation_rejects_non_positive_system_geometry() {
3866 let score = score_with_measures(1);
3867 let mut result =
3868 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3869 result.pages[0].systems[0].height_mm = 0.0;
3870
3871 assert_eq!(
3872 result.validate(),
3873 Err(PrintLayoutError::InvalidSystemGeometry {
3874 page_index: 0,
3875 index_on_page: 0,
3876 })
3877 );
3878 }
3879
3880 #[test]
3881 fn layout_validation_rejects_systems_past_the_page_or_on_top_of_each_other() {
3882 let score = score_with_measures(2);
3883 let config = PrintConfig {
3884 measures_per_system: 1,
3885 ..PrintConfig::default()
3886 };
3887 let mut result = compute_print_layout(&score, &config).expect("two valid systems");
3888 result.pages[0].systems[1].top_mm = 290.0;
3889 assert_eq!(
3890 result.validate(),
3891 Err(PrintLayoutError::InvalidSystemGeometry {
3892 page_index: 0,
3893 index_on_page: 1,
3894 })
3895 );
3896
3897 result.pages[0].systems[1].top_mm = result.pages[0].systems[0].top_mm + 1.0;
3898 assert_eq!(
3899 result.validate(),
3900 Err(PrintLayoutError::InvalidSystemGeometry {
3901 page_index: 0,
3902 index_on_page: 1,
3903 })
3904 );
3905 }
3906
3907 #[test]
3908 fn layout_validation_rejects_content_larger_than_page() {
3909 let score = score_with_measures(1);
3910 let mut result =
3911 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3912 result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
3913
3914 assert_eq!(
3915 result.validate(),
3916 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3917 );
3918 }
3919
3920 #[test]
3921 fn layout_validation_rejects_invalid_persisted_publication_metadata() {
3922 let score = score_with_measures(1);
3923 let mut result =
3924 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3925 result.pages[0]
3926 .publication
3927 .image_resources
3928 .push(PublicationImageResource {
3929 resource_key: "../unsafe".into(),
3930 alt_text: "Unsafe resource".into(),
3931 placement: PublicationImagePlacement::EveryPage,
3932 x_mm: 0.0,
3933 y_mm: 0.0,
3934 width_mm: 1.0,
3935 height_mm: 1.0,
3936 });
3937
3938 assert_eq!(
3939 result.validate(),
3940 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3941 );
3942
3943 result.pages[0].publication.image_resources.clear();
3944 result.pages[0].publication.frames.push(PublicationFrame {
3945 placement: PublicationFramePlacement::EveryPage,
3946 x_mm: 0.0,
3947 y_mm: 0.0,
3948 width_mm: 1.0,
3949 height_mm: 1.0,
3950 stroke_width_mm: f32::NAN,
3951 });
3952 assert_eq!(
3953 result.validate(),
3954 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3955 );
3956
3957 result.pages[0].publication.frames.clear();
3958 result.pages[0]
3959 .publication
3960 .sections
3961 .push(PublicationSection {
3962 first_measure: 0,
3963 title: " ".into(),
3964 start_on_new_page: false,
3965 });
3966 assert_eq!(
3967 result.validate(),
3968 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
3969 );
3970 }
3971
3972 #[test]
3973 fn notation_policy_keeps_volta_range_in_one_system() {
3974 let mut score = score_with_measures(4);
3975 score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
3976 number: 1,
3977 kind: "begin".to_string(),
3978 numbers: Vec::new(),
3979 });
3980 score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
3981 number: 1,
3982 kind: "end".to_string(),
3983 numbers: Vec::new(),
3984 });
3985 let result = compute_print_layout(
3986 &score,
3987 &PrintConfig {
3988 measures_per_system: 2,
3989 systems_per_page: Some(8),
3990 notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
3991 ..PrintConfig::default()
3992 },
3993 )
3994 .expect("valid volta-preserving layout");
3995 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3996 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
3997 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
3998 }
3999
4000 #[test]
4001 fn notation_policy_keeps_repeat_section_on_one_page() {
4002 let mut score = score_with_measures(5);
4003 score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
4004 score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
4005 let result = compute_print_layout(
4006 &score,
4007 &PrintConfig {
4008 measures_per_system: 2,
4009 systems_per_page: Some(2),
4010 notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
4011 ..PrintConfig::default()
4012 },
4013 )
4014 .expect("valid repeat-preserving layout");
4015 assert_eq!(result.pages[0].systems.len(), 1);
4016 assert_eq!(result.pages[1].systems.len(), 2);
4017 assert_eq!(
4018 result.pages[1]
4019 .systems
4020 .iter()
4021 .flat_map(|system| system.measure_indices.iter().copied())
4022 .collect::<Vec<_>>(),
4023 vec![2, 3, 4]
4024 );
4025 }
4026
4027 #[test]
4028 fn balance_policy_avoids_single_system_final_page() {
4029 let score = score_with_measures(5);
4030 let result = compute_print_layout(
4031 &score,
4032 &PrintConfig {
4033 measures_per_system: 1,
4034 systems_per_page: Some(4),
4035 final_page_policy: FinalPagePolicy::Balance,
4036 ..PrintConfig::default()
4037 },
4038 )
4039 .expect("valid balanced print config");
4040 assert_eq!(result.pages.len(), 2);
4041 assert_eq!(result.pages[0].systems.len(), 3);
4042 assert_eq!(result.pages[1].systems.len(), 2);
4043 }
4044
4045 #[test]
4046 fn balance_policy_preserves_explicit_page_breaks() {
4047 let mut score = score_with_measures(5);
4048 score.parts[0].staves[0].measures[1].page_break = true;
4049 let result = compute_print_layout(
4050 &score,
4051 &PrintConfig {
4052 measures_per_system: 1,
4053 systems_per_page: Some(4),
4054 final_page_policy: FinalPagePolicy::Balance,
4055 ..PrintConfig::default()
4056 },
4057 )
4058 .expect("valid explicit-break print config");
4059 assert_eq!(result.pages[0].systems.len(), 2);
4060 assert_eq!(result.pages[1].systems.len(), 3);
4061 }
4062
4063 #[test]
4064 fn keep_together_rejects_ranges_larger_than_system_capacity() {
4065 let score = score_with_measures(4);
4066 let error = compute_print_layout(
4067 &score,
4068 &PrintConfig {
4069 measures_per_system: 2,
4070 keep_together: vec![KeepTogetherRange {
4071 first_measure: 0,
4072 last_measure: 2,
4073 }],
4074 ..PrintConfig::default()
4075 },
4076 )
4077 .expect_err("range must fit in one system");
4078 assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
4079 }
4080
4081 #[test]
4082 fn keep_together_rejects_explicit_break_inside_range() {
4083 let mut score = score_with_measures(4);
4084 score.parts[0].staves[0].measures[1].system_break = true;
4085 let error = compute_print_layout(
4086 &score,
4087 &PrintConfig {
4088 measures_per_system: 3,
4089 keep_together: vec![KeepTogetherRange {
4090 first_measure: 0,
4091 last_measure: 2,
4092 }],
4093 ..PrintConfig::default()
4094 },
4095 )
4096 .expect_err("explicit break must win");
4097 assert_eq!(
4098 error,
4099 PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
4100 );
4101 }
4102
4103 #[test]
4104 fn rejects_margins_that_leave_no_page_area() {
4105 let score = score_with_measures(1);
4106 let error = compute_print_layout(
4107 &score,
4108 &PrintConfig {
4109 margin_left_mm: 200.0,
4110 ..PrintConfig::default()
4111 },
4112 )
4113 .expect_err("invalid page area");
4114 assert_eq!(error, PrintLayoutError::NoUsablePageArea);
4115 }
4116
4117 #[test]
4118 fn rejects_system_height_or_requested_capacity_that_clips_the_page() {
4119 let score = score_with_measures(2);
4120 for config in [
4121 PrintConfig {
4122 system_height_mm: 266.0,
4123 ..PrintConfig::default()
4124 },
4125 PrintConfig {
4126 system_height_mm: 140.0,
4127 systems_per_page: Some(2),
4128 ..PrintConfig::default()
4129 },
4130 ] {
4131 assert_eq!(
4132 compute_print_layout(&score, &config),
4133 Err(PrintLayoutError::InvalidSystemHeight)
4134 );
4135 }
4136 }
4137
4138 #[test]
4139 fn safe_area_reduces_content_and_bleed_is_exposed() {
4140 let score = score_with_measures(1);
4141 let result = compute_print_layout(
4142 &score,
4143 &PrintConfig {
4144 bleed_top_mm: 3.0,
4145 bleed_right_mm: 3.0,
4146 bleed_bottom_mm: 3.0,
4147 bleed_left_mm: 3.0,
4148 safe_top_mm: 5.0,
4149 safe_right_mm: 6.0,
4150 safe_bottom_mm: 7.0,
4151 safe_left_mm: 8.0,
4152 ..PrintConfig::default()
4153 },
4154 )
4155 .expect("valid print config");
4156 let page = &result.pages[0];
4157 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4158 assert_eq!(page.bleed_left_mm, 3.0);
4159 assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
4160 assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
4161 assert_eq!(page.systems[0].top_mm, 21.0);
4162 }
4163
4164 #[test]
4165 fn scale_changes_system_height_and_page_capacity() {
4166 let score = score_with_measures(10);
4167 let result = compute_print_layout(
4168 &score,
4169 &PrintConfig {
4170 scale: 2.0,
4171 measures_per_system: 1,
4172 systems_per_page: None,
4173 ..PrintConfig::default()
4174 },
4175 )
4176 .expect("valid print config");
4177 assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
4178 assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
4179 assert_eq!(result.pages.len(), 2);
4180 }
4181
4182 #[test]
4183 fn rejects_non_positive_scale() {
4184 let score = score_with_measures(1);
4185 let error = compute_print_layout(
4186 &score,
4187 &PrintConfig {
4188 scale: 0.0,
4189 ..PrintConfig::default()
4190 },
4191 )
4192 .expect_err("invalid scale");
4193 assert_eq!(error, PrintLayoutError::InvalidScale);
4194 }
4195
4196 #[test]
4197 fn page_numbering_is_configurable() {
4198 let score = score_with_measures(5);
4199 let numbered = compute_print_layout(
4200 &score,
4201 &PrintConfig {
4202 measures_per_system: 1,
4203 systems_per_page: Some(2),
4204 ..PrintConfig::default()
4205 },
4206 )
4207 .expect("valid print config");
4208 assert_eq!(numbered.pages[0].page_number, Some(1));
4209 assert_eq!(numbered.pages[1].page_number, Some(2));
4210
4211 let unnumbered = compute_print_layout(
4212 &score,
4213 &PrintConfig {
4214 page_numbering: PageNumbering::None,
4215 measures_per_system: 1,
4216 systems_per_page: Some(2),
4217 ..PrintConfig::default()
4218 },
4219 )
4220 .expect("valid print config");
4221 assert!(
4222 unnumbered
4223 .pages
4224 .iter()
4225 .all(|page| page.page_number.is_none())
4226 );
4227 }
4228
4229 #[test]
4230 fn rejects_invalid_publication_line_height() {
4231 let score = score_with_measures(1);
4232 let error = compute_print_layout(
4233 &score,
4234 &PrintConfig {
4235 publication: PublicationConfig {
4236 line_height_mm: 0.0,
4237 ..PublicationConfig::default()
4238 },
4239 ..PrintConfig::default()
4240 },
4241 )
4242 .expect_err("invalid publication line height");
4243 assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
4244 }
4245
4246 #[test]
4247 fn publication_image_resources_are_safe_and_page_scoped() {
4248 let score = score_with_measures(1);
4249 let config = PrintConfig {
4250 publication: PublicationConfig {
4251 title_page: true,
4252 image_resources: vec![
4253 PublicationImageResource {
4254 resource_key: "cover-art-v1".into(),
4255 alt_text: "Cover art".into(),
4256 placement: PublicationImagePlacement::TitlePage,
4257 x_mm: 10.0,
4258 y_mm: 10.0,
4259 width_mm: 30.0,
4260 height_mm: 20.0,
4261 },
4262 PublicationImageResource {
4263 resource_key: "publisher-mark".into(),
4264 alt_text: "Publisher mark".into(),
4265 placement: PublicationImagePlacement::MusicPages,
4266 x_mm: 160.0,
4267 y_mm: 10.0,
4268 width_mm: 20.0,
4269 height_mm: 10.0,
4270 },
4271 ],
4272 ..PublicationConfig::default()
4273 },
4274 ..PrintConfig::default()
4275 };
4276 let result = compute_print_layout(&score, &config).expect("valid image resources");
4277 assert_eq!(result.pages[0].publication.image_resources.len(), 1);
4278 assert_eq!(
4279 result.pages[0].publication.image_resources[0].resource_key,
4280 "cover-art-v1"
4281 );
4282 assert_eq!(result.pages[1].publication.image_resources.len(), 1);
4283 assert_eq!(
4284 result.pages[1].publication.image_resources[0].resource_key,
4285 "publisher-mark"
4286 );
4287
4288 let invalid = PrintConfig {
4289 publication: PublicationConfig {
4290 image_resources: vec![PublicationImageResource {
4291 resource_key: "../secret.png".into(),
4292 alt_text: "Unsafe path".into(),
4293 placement: PublicationImagePlacement::EveryPage,
4294 x_mm: 0.0,
4295 y_mm: 0.0,
4296 width_mm: 1.0,
4297 height_mm: 1.0,
4298 }],
4299 ..PublicationConfig::default()
4300 },
4301 ..PrintConfig::default()
4302 };
4303 assert_eq!(
4304 compute_print_layout(&score, &invalid),
4305 Err(PrintLayoutError::InvalidPublicationImageResource { index: 0 })
4306 );
4307 }
4308
4309 #[test]
4310 fn publication_frames_are_validated_and_page_scoped() {
4311 let score = score_with_measures(1);
4312 let config = PrintConfig {
4313 publication: PublicationConfig {
4314 title_page: true,
4315 frames: vec![
4316 PublicationFrame {
4317 placement: PublicationFramePlacement::TitlePage,
4318 x_mm: 8.0,
4319 y_mm: 8.0,
4320 width_mm: 194.0,
4321 height_mm: 281.0,
4322 stroke_width_mm: 0.4,
4323 },
4324 PublicationFrame {
4325 placement: PublicationFramePlacement::MusicPages,
4326 x_mm: 12.0,
4327 y_mm: 12.0,
4328 width_mm: 186.0,
4329 height_mm: 273.0,
4330 stroke_width_mm: 0.3,
4331 },
4332 ],
4333 ..PublicationConfig::default()
4334 },
4335 ..PrintConfig::default()
4336 };
4337 let result = compute_print_layout(&score, &config).expect("valid frames");
4338 assert_eq!(result.pages[0].publication.frames.len(), 1);
4339 assert_eq!(result.pages[1].publication.frames.len(), 1);
4340 assert_eq!(result.pages[0].publication.frames[0].stroke_width_mm, 0.4);
4341
4342 let invalid = PrintConfig {
4343 publication: PublicationConfig {
4344 frames: vec![PublicationFrame {
4345 placement: PublicationFramePlacement::EveryPage,
4346 x_mm: 0.0,
4347 y_mm: 0.0,
4348 width_mm: 211.0,
4349 height_mm: 297.0,
4350 stroke_width_mm: 0.0,
4351 }],
4352 ..PublicationConfig::default()
4353 },
4354 ..PrintConfig::default()
4355 };
4356 assert_eq!(
4357 compute_print_layout(&score, &invalid),
4358 Err(PrintLayoutError::InvalidPublicationFrame { index: 0 })
4359 );
4360 }
4361
4362 #[test]
4363 fn publication_spacers_consume_page_height_and_follow_systems() {
4364 let score = score_with_measures(4);
4365 let config = PrintConfig {
4366 measures_per_system: 4,
4367 systems_per_page: Some(2),
4368 system_height_mm: 130.0,
4369 publication: PublicationConfig {
4370 spacers: vec![PublicationSpacer {
4371 before_measure: 2,
4372 height_mm: 20.0,
4373 }],
4374 ..PublicationConfig::default()
4375 },
4376 ..PrintConfig::default()
4377 };
4378 let result = compute_print_layout(&score, &config).expect("valid publication spacer");
4379 assert_eq!(result.pages.len(), 2);
4380 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4381 assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4382 assert_eq!(result.pages[1].systems[0].top_mm, 36.0);
4383 assert_eq!(result.pages[1].publication.spacers.len(), 1);
4384 assert_eq!(result.pages[1].publication.spacers[0].before_measure, 2);
4385 result
4386 .validate()
4387 .expect("persisted spacer metadata is valid");
4388
4389 let invalid = PrintConfig {
4390 system_height_mm: 260.0,
4391 publication: PublicationConfig {
4392 spacers: vec![PublicationSpacer {
4393 before_measure: 0,
4394 height_mm: 10.0,
4395 }],
4396 ..PublicationConfig::default()
4397 },
4398 ..PrintConfig::default()
4399 };
4400 assert_eq!(
4401 compute_print_layout(&score, &invalid),
4402 Err(PrintLayoutError::InvalidPublicationSpacer { index: 0 })
4403 );
4404 }
4405
4406 #[test]
4407 fn publication_sections_split_systems_and_can_start_a_page() {
4408 let score = score_with_measures(5);
4409 let config = PrintConfig {
4410 measures_per_system: 4,
4411 systems_per_page: Some(2),
4412 publication: PublicationConfig {
4413 sections: vec![PublicationSection {
4414 first_measure: 2,
4415 title: "Second movement".into(),
4416 start_on_new_page: true,
4417 }],
4418 ..PublicationConfig::default()
4419 },
4420 ..PrintConfig::default()
4421 };
4422 let result = compute_print_layout(&score, &config).expect("valid publication section");
4423 assert_eq!(result.pages.len(), 2);
4424 assert_eq!(result.pages[0].break_reason, BreakReason::SectionBreak);
4425 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4426 assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4427 assert_eq!(result.pages[1].publication.sections.len(), 1);
4428 assert_eq!(
4429 result.pages[1].publication.sections[0].title,
4430 "Second movement"
4431 );
4432
4433 let invalid = PrintConfig {
4434 publication: PublicationConfig {
4435 sections: vec![PublicationSection {
4436 first_measure: 5,
4437 title: "Outside score".into(),
4438 start_on_new_page: false,
4439 }],
4440 ..PublicationConfig::default()
4441 },
4442 ..PrintConfig::default()
4443 };
4444 assert_eq!(
4445 compute_print_layout(&score, &invalid),
4446 Err(PrintLayoutError::InvalidPublicationSection { index: 0 })
4447 );
4448 }
4449
4450 #[test]
4451 fn rejects_empty_host_glyph_resource_key() {
4452 let score = score_with_measures(1);
4453 let error = compute_print_layout(
4454 &score,
4455 &PrintConfig {
4456 glyph_resources: GlyphResourcePolicy::HostProvided(" ".into()),
4457 ..PrintConfig::default()
4458 },
4459 )
4460 .expect_err("empty host resource key");
4461 assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
4462 }
4463
4464 #[test]
4465 fn glyph_resource_descriptor_requires_reproducible_metadata() {
4466 let descriptor = GlyphResourceDescriptor {
4467 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4468 resource_key: "publisher-font-v2".into(),
4469 metrics_contract_version: 1,
4470 license_notice: "licensed by publisher".into(),
4471 fallback: GlyphFallbackPolicy::UseResource("acorde-vector-glyphs-v1".into()),
4472 };
4473 assert_eq!(descriptor.validate(), Ok(()));
4474 }
4475
4476 #[test]
4477 fn glyph_resource_descriptor_rejects_missing_license_and_self_fallback() {
4478 let missing_license = GlyphResourceDescriptor {
4479 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4480 resource_key: "font".into(),
4481 metrics_contract_version: 1,
4482 license_notice: " ".into(),
4483 fallback: GlyphFallbackPolicy::Reject,
4484 };
4485 assert_eq!(
4486 missing_license.validate(),
4487 Err(GlyphResourceDescriptorError::EmptyLicenseNotice)
4488 );
4489
4490 let self_fallback = GlyphResourceDescriptor {
4491 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4492 resource_key: "font".into(),
4493 metrics_contract_version: 1,
4494 license_notice: "licensed".into(),
4495 fallback: GlyphFallbackPolicy::UseResource("font".into()),
4496 };
4497 assert_eq!(
4498 self_fallback.validate(),
4499 Err(GlyphResourceDescriptorError::FallbackMatchesPrimary)
4500 );
4501 }
4502
4503 #[test]
4504 fn print_color_and_crop_policies_are_exposed_per_page() {
4505 let score = score_with_measures(1);
4506 let result = compute_print_layout(
4507 &score,
4508 &PrintConfig {
4509 color_policy: PrintColorPolicy::Preserve,
4510 crop_mark_policy: CropMarkPolicy::BleedEdges,
4511 ..PrintConfig::default()
4512 },
4513 )
4514 .expect("valid print config");
4515 let page = &result.pages[0];
4516 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4517 assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
4518 assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
4519 }
4520
4521 #[test]
4522 fn glyph_resource_policy_is_exposed_per_page() {
4523 let score = score_with_measures(1);
4524 let result = compute_print_layout(
4525 &score,
4526 &PrintConfig {
4527 glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
4528 ..PrintConfig::default()
4529 },
4530 )
4531 .expect("valid print config");
4532 assert_eq!(
4533 result.pages[0].glyph_resources,
4534 GlyphResourcePolicy::HostProvided("music-font-v1".into())
4535 );
4536 }
4537
4538 #[test]
4539 fn publication_metadata_is_deterministic_and_page_scoped() {
4540 let mut score = score_with_measures(3);
4541 score.metadata.title = "Suite".into();
4542 score.metadata.movement_title = "I. Prelude".into();
4543 score.metadata.composer = "Composer".into();
4544 score.metadata.copyright = "© 2026 Composer".into();
4545 score.metadata.lyricist = "Lyricist".into();
4546 score.metadata.copyright = "Copyright".into();
4547 score.parts.push(Part::new("Strings", "Str."));
4548 score.part_groups.push(PartGroup {
4549 first_part: 0,
4550 last_part: 1,
4551 symbol: PartGroupSymbol::Bracket,
4552 barlines_connect: true,
4553 });
4554 for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
4555 measure.number = (index + 1) as u32;
4556 }
4557 let result = compute_print_layout(
4558 &score,
4559 &PrintConfig {
4560 measures_per_system: 2,
4561 systems_per_page: Some(1),
4562 publication: PublicationConfig {
4563 running_title: Some("Suite — Composer".into()),
4564 header_text: Some("Suite".into()),
4565 footer_text: Some("Copyright".into()),
4566 page_number_in_footer: true,
4567 header_alignment: PublicationTextAlignment::Center,
4568 footer_alignment: PublicationTextAlignment::Right,
4569 ..PublicationConfig::default()
4570 },
4571 ..PrintConfig::default()
4572 },
4573 )
4574 .expect("valid print config");
4575 assert_eq!(result.pages[0].publication.title, "Suite");
4576 assert_eq!(
4577 result.pages[0].publication.running_title.as_deref(),
4578 Some("Suite — Composer")
4579 );
4580 assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
4581 assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
4582 assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
4583 assert_eq!(result.pages[0].publication.part_groups.len(), 1);
4584 assert_eq!(
4585 result.pages[0].publication.part_groups[0].symbol,
4586 PartGroupSymbol::Bracket
4587 );
4588 assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
4589 assert_eq!(
4590 result.pages[0].publication.text_blocks[0].role,
4591 PublicationTextRole::Header
4592 );
4593 assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
4594 assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
4595 assert_eq!(
4596 result.pages[0].publication.text_blocks[1].role,
4597 PublicationTextRole::Footer
4598 );
4599 assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
4600 assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
4601 assert_eq!(
4602 result.pages[0].publication.text_blocks[0].alignment,
4603 PublicationTextAlignment::Center
4604 );
4605 assert_eq!(
4606 result.pages[0].publication.text_blocks[1].alignment,
4607 PublicationTextAlignment::Right
4608 );
4609 let artifacts = result
4610 .export_page_artifacts()
4611 .expect("publication pages export without host resources");
4612 assert_eq!(artifacts.len(), result.pages.len());
4613 assert_eq!(artifacts[0].layout.publication, result.pages[0].publication);
4614 assert!(
4615 artifacts
4616 .iter()
4617 .all(|artifact| artifact.diagnostics.is_empty())
4618 );
4619 }
4620
4621 #[test]
4622 fn publication_templates_select_odd_even_text_and_can_omit_a_side() {
4623 let score = score_with_measures(3);
4624 let result = compute_print_layout(
4625 &score,
4626 &PrintConfig {
4627 measures_per_system: 1,
4628 systems_per_page: Some(1),
4629 publication: PublicationConfig {
4630 header_text: Some("legacy header".into()),
4631 footer_text: Some("legacy footer".into()),
4632 header_template: PublicationPageTemplate {
4633 odd: Some("Odd header".into()),
4634 even: Some("Even header".into()),
4635 },
4636 footer_template: PublicationPageTemplate {
4637 odd: Some("Odd footer".into()),
4638 even: None,
4639 },
4640 ..PublicationConfig::default()
4641 },
4642 ..PrintConfig::default()
4643 },
4644 )
4645 .expect("valid print layout");
4646 let first_texts: Vec<_> = result.pages[0]
4647 .publication
4648 .text_blocks
4649 .iter()
4650 .map(|block| block.text.as_str())
4651 .collect();
4652 let second_texts: Vec<_> = result.pages[1]
4653 .publication
4654 .text_blocks
4655 .iter()
4656 .map(|block| block.text.as_str())
4657 .collect();
4658 assert_eq!(first_texts, vec!["Odd header", "Odd footer"]);
4659 assert_eq!(second_texts, vec!["Even header"]);
4660 }
4661
4662 #[test]
4663 fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
4664 let mut score = score_with_measures(2);
4665 let mut part = Part::new("Flute", "Fl.");
4666 let mut staff = Staff::new(Clef::Treble);
4667 staff.measures = vec![Measure::empty(4, 4); 5];
4668 part.staves = vec![staff];
4669 score.parts.push(part);
4670
4671 let extracted = compute_print_layout(
4672 &score,
4673 &PrintConfig {
4674 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
4675 measures_per_system: 2,
4676 systems_per_page: Some(1),
4677 ..PrintConfig::default()
4678 },
4679 )
4680 .expect("valid extracted part");
4681 assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
4682 assert_eq!(extracted.pages.len(), 3);
4683 assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
4684
4685 let error = compute_print_layout(
4686 &score,
4687 &PrintConfig {
4688 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
4689 ..PrintConfig::default()
4690 },
4691 )
4692 .expect_err("missing extracted part");
4693 assert_eq!(error, PrintLayoutError::InvalidPartIndex);
4694 }
4695
4696 #[test]
4697 fn title_page_is_inserted_without_consuming_music_page_capacity() {
4698 let mut score = score_with_measures(3);
4699 score.texts.push(StyledText {
4700 style: TextStyle::Expression,
4701 text: "Dedication".into(),
4702 placement: None,
4703 offset_x: None,
4704 offset_y: None,
4705 relative_x: None,
4706 relative_y: None,
4707 });
4708 score.metadata.title = "Suite".into();
4709 score.metadata.movement_title = "I. Prelude".into();
4710 score.metadata.composer = "Composer".into();
4711 score.metadata.copyright = "© 2026 Composer".into();
4712 let result = compute_print_layout(
4713 &score,
4714 &PrintConfig {
4715 systems_per_page: Some(1),
4716 measures_per_system: 2,
4717 publication: PublicationConfig {
4718 title_page: true,
4719 ..PublicationConfig::default()
4720 },
4721 ..PrintConfig::default()
4722 },
4723 )
4724 .expect("valid title page config");
4725 assert_eq!(result.pages.len(), 3);
4726 assert!(result.pages[0].systems.is_empty());
4727 assert!(result.pages[0].publication.is_title_page);
4728 assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
4729 assert_eq!(result.pages[0].page_number, Some(1));
4730 assert_eq!(result.pages[1].page_number, Some(2));
4731 assert_eq!(result.pages[1].systems[0].page_index, 1);
4732 assert!(!result.pages[1].publication.is_title_page);
4733 assert_eq!(result.pages[0].publication.score_texts, score.texts);
4734 assert!(result.validate().is_ok());
4735 assert_eq!(
4736 result.pages[0]
4737 .publication
4738 .text_blocks
4739 .iter()
4740 .map(|block| block.role)
4741 .collect::<Vec<_>>(),
4742 vec![
4743 PublicationTextRole::Title,
4744 PublicationTextRole::Subtitle,
4745 PublicationTextRole::Credit,
4746 PublicationTextRole::Copyright
4747 ]
4748 );
4749 }
4750
4751 #[test]
4752 fn print_presets_are_versioned_and_select_the_expected_scope() {
4753 assert_eq!(PrintPreset::A4Score.schema_version(), 1);
4754 assert_eq!(
4755 PrintPreset::A4Score.config().part_layout,
4756 PartLayoutPolicy::FullScore
4757 );
4758 assert_eq!(
4759 PrintPreset::LetterPart { part_index: 2 }
4760 .config()
4761 .part_layout,
4762 PartLayoutPolicy::ExtractedPart { part_index: 2 }
4763 );
4764 assert_eq!(
4765 PrintPreset::LetterScore.config().paper_size,
4766 PaperSize::Letter
4767 );
4768 assert!(
4769 PrintPreset::A4Score
4770 .config_with_title_page(true)
4771 .publication
4772 .title_page
4773 );
4774 assert!(!PrintPreset::A4Score.config().publication.title_page);
4775 assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 1);
4776 }
4777
4778 #[test]
4779 fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
4780 let metrics = GlyphMetrics {
4781 advance_mm: 4.0,
4782 left_mm: -1.0,
4783 top_mm: -2.0,
4784 width_mm: 2.0,
4785 height_mm: 4.0,
4786 };
4787 let mut placements = vec![
4788 GlyphPlacement {
4789 resource_key: "high".into(),
4790 metrics,
4791 x_mm: 10.0,
4792 y_mm: 20.0,
4793 priority: 10,
4794 },
4795 GlyphPlacement {
4796 resource_key: "low".into(),
4797 metrics,
4798 x_mm: 10.0,
4799 y_mm: 20.0,
4800 priority: 1,
4801 },
4802 ];
4803 let moved = resolve_glyph_collisions(&mut placements, 1.0);
4804 assert_eq!(moved, 1);
4805 assert_eq!(placements[0].y_mm, 20.0);
4806 assert_eq!(placements[1].y_mm, 25.0);
4807 }
4808
4809 #[test]
4810 fn class_aware_collision_resolution_uses_stable_semantic_tie_breakers() {
4811 let metrics = GlyphMetrics {
4812 advance_mm: 4.0,
4813 left_mm: -1.0,
4814 top_mm: -2.0,
4815 width_mm: 2.0,
4816 height_mm: 4.0,
4817 };
4818 let mut placements = vec![
4819 GlyphPlacement {
4820 resource_key: "annotation".into(),
4821 metrics,
4822 x_mm: 10.0,
4823 y_mm: 20.0,
4824 priority: 1,
4825 },
4826 GlyphPlacement {
4827 resource_key: "critical".into(),
4828 metrics,
4829 x_mm: 10.0,
4830 y_mm: 20.0,
4831 priority: 1,
4832 },
4833 ];
4834 let classes = [
4835 GlyphCollisionClass::Annotation,
4836 GlyphCollisionClass::Critical,
4837 ];
4838 assert_eq!(
4839 resolve_glyph_collisions_with_classes(&mut placements, &classes, 1.0),
4840 Ok(1)
4841 );
4842 assert_eq!(placements[0].y_mm, 25.0);
4843 assert_eq!(placements[1].y_mm, 20.0);
4844 assert_eq!(
4845 resolve_glyph_horizontal_collisions_with_classes(
4846 &mut placements,
4847 &[GlyphCollisionClass::Annotation],
4848 1.0,
4849 ),
4850 Err(GlyphPlacementError::CollisionClassCount {
4851 placements: 2,
4852 classes: 1,
4853 })
4854 );
4855 }
4856
4857 #[test]
4858 fn constrained_collision_pass_honors_annotation_escape_lanes() {
4859 let metrics = GlyphMetrics {
4860 advance_mm: 4.0,
4861 left_mm: -1.0,
4862 top_mm: -2.0,
4863 width_mm: 2.0,
4864 height_mm: 4.0,
4865 };
4866 let mut placements = vec![
4867 GlyphPlacement {
4868 resource_key: "notation".into(),
4869 metrics,
4870 x_mm: 10.0,
4871 y_mm: 20.0,
4872 priority: 1,
4873 },
4874 GlyphPlacement {
4875 resource_key: "lyric".into(),
4876 metrics,
4877 x_mm: 10.0,
4878 y_mm: 20.0,
4879 priority: 1,
4880 },
4881 GlyphPlacement {
4882 resource_key: "rehearsal".into(),
4883 metrics,
4884 x_mm: 10.0,
4885 y_mm: 20.0,
4886 priority: 1,
4887 },
4888 GlyphPlacement {
4889 resource_key: "tab".into(),
4890 metrics,
4891 x_mm: 10.0,
4892 y_mm: 20.0,
4893 priority: 1,
4894 },
4895 ];
4896 let classes = [
4897 GlyphCollisionClass::Critical,
4898 GlyphCollisionClass::Annotation,
4899 GlyphCollisionClass::Annotation,
4900 GlyphCollisionClass::Annotation,
4901 ];
4902 let directions = [
4903 GlyphCollisionDirection::Down,
4904 GlyphCollisionDirection::Down,
4905 GlyphCollisionDirection::Up,
4906 GlyphCollisionDirection::Right,
4907 ];
4908
4909 assert_eq!(
4910 resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
4911 Ok(3)
4912 );
4913 assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
4914 assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
4915 assert_eq!((placements[2].x_mm, placements[2].y_mm), (10.0, 15.0));
4916 assert_eq!((placements[3].x_mm, placements[3].y_mm), (13.0, 20.0));
4917
4918 let before = placements.clone();
4919 assert_eq!(
4920 resolve_glyph_collisions_constrained(
4921 &mut placements,
4922 &classes,
4923 &[GlyphCollisionDirection::Down],
4924 1.0
4925 ),
4926 Err(GlyphPlacementError::CollisionDirectionCount {
4927 placements: 4,
4928 directions: 1,
4929 })
4930 );
4931 assert_eq!(placements, before);
4932 }
4933
4934 #[test]
4935 fn constrained_collision_pass_keeps_fixed_obstacles_in_place() {
4936 let metrics = GlyphMetrics {
4937 advance_mm: 4.0,
4938 left_mm: -1.0,
4939 top_mm: -2.0,
4940 width_mm: 2.0,
4941 height_mm: 4.0,
4942 };
4943 let mut placements = vec![
4944 GlyphPlacement {
4945 resource_key: "resolved-annotation".into(),
4946 metrics,
4947 x_mm: 10.0,
4948 y_mm: 20.0,
4949 priority: 2,
4950 },
4951 GlyphPlacement {
4952 resource_key: "measure-text".into(),
4953 metrics,
4954 x_mm: 10.0,
4955 y_mm: 20.0,
4956 priority: 1,
4957 },
4958 ];
4959 let classes = [
4960 GlyphCollisionClass::Critical,
4961 GlyphCollisionClass::Annotation,
4962 ];
4963 let directions = [
4964 GlyphCollisionDirection::Fixed,
4965 GlyphCollisionDirection::Down,
4966 ];
4967
4968 assert_eq!(
4969 resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
4970 Ok(1)
4971 );
4972 assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
4973 assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
4974 }
4975
4976 #[test]
4977 fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
4978 let metrics = GlyphMetrics {
4979 advance_mm: 4.0,
4980 left_mm: -1.0,
4981 top_mm: -1.0,
4982 width_mm: 2.0,
4983 height_mm: 2.0,
4984 };
4985 let mut placements = vec![
4986 GlyphPlacement {
4987 resource_key: "high".into(),
4988 metrics,
4989 x_mm: 10.0,
4990 y_mm: 20.0,
4991 priority: 10,
4992 },
4993 GlyphPlacement {
4994 resource_key: "low".into(),
4995 metrics,
4996 x_mm: 10.0,
4997 y_mm: 0.0,
4998 priority: 1,
4999 },
5000 ];
5001 assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
5002 assert_eq!(placements[1].y_mm, 0.0);
5003 }
5004
5005 #[test]
5006 fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
5007 let mut placements = vec![GlyphPlacement {
5008 resource_key: "test".into(),
5009 metrics: GlyphMetrics {
5010 advance_mm: 1.0,
5011 left_mm: 0.0,
5012 top_mm: 0.0,
5013 width_mm: 1.0,
5014 height_mm: 1.0,
5015 },
5016 x_mm: 0.0,
5017 y_mm: 0.0,
5018 priority: 0,
5019 }];
5020 assert_eq!(validate_glyph_placements(&placements), Ok(()));
5021 placements[0].x_mm = f32::NAN;
5022 assert_eq!(
5023 validate_glyph_placements(&placements),
5024 Err(GlyphPlacementError::NonFinite { index: 0 })
5025 );
5026 placements[0].x_mm = 0.0;
5027 placements[0].metrics.width_mm = -1.0;
5028 assert_eq!(
5029 validate_glyph_placements(&placements),
5030 Err(GlyphPlacementError::NegativeExtent { index: 0 })
5031 );
5032 }
5033
5034 #[test]
5035 fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
5036 let metrics = GlyphMetrics {
5037 advance_mm: 4.0,
5038 left_mm: -1.0,
5039 top_mm: -1.0,
5040 width_mm: 2.0,
5041 height_mm: 2.0,
5042 };
5043 let mut placements = vec![
5044 GlyphPlacement {
5045 resource_key: "high".into(),
5046 metrics,
5047 x_mm: 10.0,
5048 y_mm: 20.0,
5049 priority: 10,
5050 },
5051 GlyphPlacement {
5052 resource_key: "low".into(),
5053 metrics,
5054 x_mm: 10.0,
5055 y_mm: 20.0,
5056 priority: 1,
5057 },
5058 GlyphPlacement {
5059 resource_key: "far".into(),
5060 metrics,
5061 x_mm: 10.0,
5062 y_mm: 30.0,
5063 priority: 1,
5064 },
5065 ];
5066 assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
5067 assert_eq!(placements[0].x_mm, 10.0);
5068 assert_eq!(placements[1].x_mm, 13.0);
5069 assert_eq!(placements[2].x_mm, 10.0);
5070 }
5071
5072 #[test]
5073 fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
5074 let metrics = GlyphMetrics {
5075 advance_mm: 1.0,
5076 left_mm: 0.0,
5077 top_mm: 0.0,
5078 width_mm: 1.0,
5079 height_mm: 1.0,
5080 };
5081 let mut placements = vec![
5082 GlyphPlacement {
5083 resource_key: "second".into(),
5084 metrics,
5085 x_mm: 20.0,
5086 y_mm: 0.0,
5087 priority: 0,
5088 },
5089 GlyphPlacement {
5090 resource_key: "first".into(),
5091 metrics,
5092 x_mm: 10.0,
5093 y_mm: 0.0,
5094 priority: 0,
5095 },
5096 GlyphPlacement {
5097 resource_key: "third".into(),
5098 metrics,
5099 x_mm: 30.0,
5100 y_mm: 0.0,
5101 priority: 0,
5102 },
5103 ];
5104 assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
5105 assert_eq!(placements[0].x_mm, 23.0);
5106 assert_eq!(placements[1].x_mm, 10.0);
5107 assert_eq!(placements[2].x_mm, 36.0);
5108 assert_eq!(
5109 distribute_glyph_spacing(&mut placements, f32::NAN),
5110 Err(GlyphPlacementError::NonFiniteSpacing)
5111 );
5112 let before = placements.clone();
5113 assert_eq!(
5114 distribute_glyph_spacing(&mut placements, f32::MAX),
5115 Err(GlyphPlacementError::NonFiniteSpacing)
5116 );
5117 assert_eq!(placements, before);
5118 }
5119
5120 #[test]
5121 fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
5122 let mut placement = GlyphPlacement {
5123 resource_key: " ".into(),
5124 metrics: GlyphMetrics {
5125 advance_mm: 1.0,
5126 left_mm: 0.0,
5127 top_mm: 0.0,
5128 width_mm: 1.0,
5129 height_mm: 1.0,
5130 },
5131 x_mm: 0.0,
5132 y_mm: 0.0,
5133 priority: 0,
5134 };
5135 assert_eq!(
5136 validate_glyph_placements(&[placement.clone()]),
5137 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5138 );
5139 placement.resource_key = "glyph".into();
5140 placement.metrics.advance_mm = -1.0;
5141 assert_eq!(
5142 validate_glyph_placements(&[placement]),
5143 Err(GlyphPlacementError::NegativeAdvance { index: 0 })
5144 );
5145 }
5146
5147 #[test]
5148 fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
5149 let mut placements = vec![GlyphPlacement {
5150 resource_key: String::new(),
5151 metrics: GlyphMetrics {
5152 advance_mm: 1.0,
5153 left_mm: 0.0,
5154 top_mm: 0.0,
5155 width_mm: 1.0,
5156 height_mm: 1.0,
5157 },
5158 x_mm: 0.0,
5159 y_mm: 0.0,
5160 priority: 0,
5161 }];
5162 assert_eq!(
5163 resolve_glyph_collisions_checked(&mut placements, 1.0),
5164 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5165 );
5166 assert_eq!(
5167 resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
5168 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5169 );
5170 assert_eq!(placements[0].x_mm, 0.0);
5171 assert_eq!(placements[0].y_mm, 0.0);
5172 }
5173
5174 #[test]
5175 fn checked_collision_resolvers_reject_non_finite_gap() {
5176 let metrics = GlyphMetrics {
5177 advance_mm: 1.0,
5178 left_mm: 0.0,
5179 top_mm: 0.0,
5180 width_mm: 1.0,
5181 height_mm: 1.0,
5182 };
5183 let original = vec![GlyphPlacement {
5184 resource_key: "glyph".into(),
5185 metrics,
5186 x_mm: 0.0,
5187 y_mm: 0.0,
5188 priority: 0,
5189 }];
5190 let mut vertical = original.clone();
5191 assert_eq!(
5192 resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
5193 Err(GlyphPlacementError::NonFiniteSpacing)
5194 );
5195 assert_eq!(vertical, original);
5196
5197 let mut horizontal = original.clone();
5198 assert_eq!(
5199 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
5200 Err(GlyphPlacementError::NonFiniteSpacing)
5201 );
5202 assert_eq!(horizontal, original);
5203 }
5204
5205 #[test]
5206 fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
5207 let metrics = GlyphMetrics {
5208 advance_mm: 1.0,
5209 left_mm: 0.0,
5210 top_mm: 0.0,
5211 width_mm: f32::MAX / 2.0,
5212 height_mm: f32::MAX / 2.0,
5213 };
5214 let original = vec![
5215 GlyphPlacement {
5216 resource_key: "high".into(),
5217 metrics,
5218 x_mm: 0.0,
5219 y_mm: 0.0,
5220 priority: 1,
5221 },
5222 GlyphPlacement {
5223 resource_key: "low".into(),
5224 metrics,
5225 x_mm: 0.0,
5226 y_mm: 0.0,
5227 priority: 0,
5228 },
5229 ];
5230 let mut vertical = original.clone();
5231 assert_eq!(
5232 resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
5233 Err(GlyphPlacementError::NonFinite { index: 1 })
5234 );
5235 assert_eq!(vertical, original);
5236
5237 let mut horizontal = original.clone();
5238 assert_eq!(
5239 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
5240 Err(GlyphPlacementError::NonFinite { index: 1 })
5241 );
5242 assert_eq!(horizontal, original);
5243 }
5244
5245 #[test]
5246 fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
5247 let metrics = GlyphMetrics {
5248 advance_mm: 1.0,
5249 left_mm: -1.0,
5250 top_mm: -2.0,
5251 width_mm: 3.0,
5252 height_mm: 4.0,
5253 };
5254 let placements = vec![
5255 GlyphPlacement {
5256 resource_key: "a".into(),
5257 metrics,
5258 x_mm: 10.0,
5259 y_mm: 20.0,
5260 priority: 0,
5261 },
5262 GlyphPlacement {
5263 resource_key: "b".into(),
5264 metrics,
5265 x_mm: 30.0,
5266 y_mm: 5.0,
5267 priority: 0,
5268 },
5269 ];
5270 assert_eq!(
5271 glyph_extents(&placements),
5272 Ok(Some(GlyphExtents {
5273 left_mm: 9.0,
5274 top_mm: 3.0,
5275 right_mm: 32.0,
5276 bottom_mm: 22.0,
5277 }))
5278 );
5279 let extents = glyph_extents(&placements).unwrap().unwrap();
5280 assert_eq!(extents.width_mm(), 23.0);
5281 assert_eq!(extents.height_mm(), 19.0);
5282 assert_eq!(glyph_extents(&[]), Ok(None));
5283 }
5284
5285 #[test]
5286 fn glyph_extents_reject_derived_bound_overflow() {
5287 let placements = [GlyphPlacement {
5288 resource_key: "edge".into(),
5289 metrics: GlyphMetrics {
5290 advance_mm: 1.0,
5291 left_mm: 0.0,
5292 top_mm: 0.0,
5293 width_mm: f32::MAX,
5294 height_mm: 1.0,
5295 },
5296 x_mm: f32::MAX,
5297 y_mm: 0.0,
5298 priority: 0,
5299 }];
5300 assert_eq!(
5301 glyph_extents(&placements),
5302 Err(GlyphPlacementError::NonFinite { index: 0 })
5303 );
5304 }
5305
5306 #[test]
5307 fn page_render_tree_preserves_canonical_note_and_rest_addresses() {
5308 let mut score = score_with_measures(1);
5309 score.parts[0].staves[0].measures[0].voices[0] = vec![
5310 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5311 Note::rest(Duration::Quarter),
5312 ];
5313 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5314 let trees = layout
5315 .export_page_render_trees(&score)
5316 .expect("render trees");
5317 assert_eq!(trees.len(), 1);
5318 assert_eq!(trees[0].contract_version, PAGE_RENDER_TREE_CONTRACT_VERSION);
5319 assert_eq!(trees[0].validate(&score), Ok(()));
5320 assert!(trees[0].nodes.iter().any(|node| matches!(
5321 (&node.address, &node.kind),
5322 (PageRenderAddress::Note(address), PageRenderNodeKind::Note)
5323 if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 0
5324 )));
5325 assert!(trees[0].nodes.iter().any(|node| matches!(
5326 (&node.address, &node.kind),
5327 (PageRenderAddress::Note(address), PageRenderNodeKind::Rest)
5328 if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 1
5329 )));
5330 let restored: Vec<PageRenderTree> =
5331 serde_json::from_str(&serde_json::to_string(&trees).expect("trees serialize"))
5332 .expect("trees deserialize");
5333 assert_eq!(restored, trees);
5334 let mut invalid = restored[0].clone();
5335 invalid.nodes[0]
5336 .system
5337 .as_mut()
5338 .expect("system node")
5339 .page_index = 99;
5340 assert!(matches!(
5341 invalid.validate(&score),
5342 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5343 ));
5344
5345 let mut wrong_note_kind = restored[0].clone();
5346 wrong_note_kind.nodes[0].kind = PageRenderNodeKind::Rest;
5347 assert!(matches!(
5348 wrong_note_kind.validate(&score),
5349 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5350 ));
5351
5352 let mut missing_system = restored[0].clone();
5353 missing_system.nodes[0].system = None;
5354 assert!(matches!(
5355 missing_system.validate(&score),
5356 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5357 ));
5358 }
5359
5360 #[test]
5361 fn page_render_tree_for_linked_view_keeps_source_part_addresses() {
5362 let mut score = score_with_measures(1);
5363 score.parts[0].staves[0].measures[0].voices[0] =
5364 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5365 score
5366 .views
5367 .push(ScoreView::linked_part("piano", "Piano", 0));
5368 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5369 let trees = layout
5370 .export_page_render_trees_for_view(&score, "piano")
5371 .expect("view trees");
5372 assert_eq!(trees[0].view_id.as_deref(), Some("piano"));
5373 assert!(trees[0].nodes.iter().all(|node| match &node.address {
5374 PageRenderAddress::Note(address) => address.part == 0,
5375 _ => true,
5376 }));
5377 assert!(
5378 layout
5379 .export_page_render_trees_for_view(&score, "missing")
5380 .is_err()
5381 );
5382 }
5383
5384 #[test]
5385 fn page_render_tree_for_linked_view_omits_hidden_staff_nodes() {
5386 let mut score = Score::template(ScoreTemplate::Piano);
5387 score.parts[0].staves[0].measures[0].voices[0] =
5388 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5389 score.parts[0].staves[1].measures[0].voices[0] =
5390 vec![Note::new(Pitch::new(Step::C, 3), Duration::Quarter)];
5391 let mut view = ScoreView::linked_part("piano", "Piano", 0);
5392 view.layout
5393 .hidden_staves
5394 .push(ViewStaffRef { part: 0, staff: 1 });
5395 score.views.push(view);
5396
5397 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5398 let trees = layout
5399 .export_page_render_trees_for_view(&score, "piano")
5400 .expect("view trees");
5401
5402 let note_staves = trees
5403 .iter()
5404 .flat_map(|tree| tree.nodes.iter())
5405 .filter_map(|node| match &node.address {
5406 PageRenderAddress::Note(address) => Some(address.staff),
5407 _ => None,
5408 })
5409 .collect::<Vec<_>>();
5410 assert!(!note_staves.is_empty());
5411 assert!(note_staves.iter().all(|&staff| staff == 0));
5412 assert!(trees.iter().all(|tree| tree.validate(&score).is_ok()));
5413
5414 let mut invalid = trees[0].clone();
5415 invalid.nodes.push(PageRenderNode {
5416 address: PageRenderAddress::Note(NoteAddr {
5417 part: 0,
5418 staff: 1,
5419 measure: 0,
5420 voice: 0,
5421 note: 0,
5422 }),
5423 kind: PageRenderNodeKind::Note,
5424 system: None,
5425 });
5426 assert!(matches!(
5427 invalid.validate(&score),
5428 Err(PrintLayoutError::InvalidRenderTreeNode { .. })
5429 ));
5430 }
5431
5432 #[test]
5433 fn print_layout_for_linked_view_applies_local_breaks_without_mutating_score() {
5434 let mut score = score_with_measures(4);
5435 let mut view = ScoreView::linked_part("part", "Part", 0);
5436 view.layout.measures_per_row = Some(3);
5437 view.layout.system_breaks.push(1);
5438 score.views.push(view);
5439
5440 let layout = compute_print_layout_for_view(&score, &PrintConfig::default(), "part")
5441 .expect("view layout");
5442 let systems = layout
5443 .pages
5444 .iter()
5445 .flat_map(|page| page.systems.iter())
5446 .collect::<Vec<_>>();
5447 assert_eq!(
5448 systems
5449 .iter()
5450 .map(|system| system.measure_indices.clone())
5451 .collect::<Vec<_>>(),
5452 vec![vec![0, 1], vec![2, 3]]
5453 );
5454 assert!(!score.parts[0].staves[0].measures[1].system_break);
5455 }
5456
5457 #[test]
5458 fn page_render_tree_keeps_page_scoped_resource_addresses() {
5459 let score = score_with_measures(1);
5460 let config = PrintConfig {
5461 publication: PublicationConfig {
5462 title_page: true,
5463 image_resources: vec![
5464 PublicationImageResource {
5465 resource_key: "cover-art-v1".into(),
5466 alt_text: "Cover".into(),
5467 placement: PublicationImagePlacement::TitlePage,
5468 x_mm: 10.0,
5469 y_mm: 10.0,
5470 width_mm: 30.0,
5471 height_mm: 20.0,
5472 },
5473 PublicationImageResource {
5474 resource_key: "publisher-mark".into(),
5475 alt_text: "Mark".into(),
5476 placement: PublicationImagePlacement::MusicPages,
5477 x_mm: 160.0,
5478 y_mm: 10.0,
5479 width_mm: 20.0,
5480 height_mm: 10.0,
5481 },
5482 ],
5483 frames: vec![PublicationFrame {
5484 placement: PublicationFramePlacement::EveryPage,
5485 x_mm: 5.0,
5486 y_mm: 5.0,
5487 width_mm: 200.0,
5488 height_mm: 287.0,
5489 stroke_width_mm: 0.5,
5490 }],
5491 ..PublicationConfig::default()
5492 },
5493 ..PrintConfig::default()
5494 };
5495 let layout = compute_print_layout(&score, &config).expect("layout");
5496 let trees = layout
5497 .export_page_render_trees(&score)
5498 .expect("render trees");
5499 assert!(trees[0].nodes.iter().any(|node| matches!(
5500 (&node.address, &node.kind),
5501 (
5502 PageRenderAddress::Resource { page_index: 0, resource_key },
5503 PageRenderNodeKind::Resource,
5504 ) if resource_key == "cover-art-v1"
5505 )));
5506 assert!(trees[1].nodes.iter().any(|node| matches!(
5507 (&node.address, &node.kind),
5508 (
5509 PageRenderAddress::Resource { page_index: 1, resource_key },
5510 PageRenderNodeKind::Resource,
5511 ) if resource_key == "publisher-mark"
5512 )));
5513 assert!(
5514 trees
5515 .iter()
5516 .enumerate()
5517 .all(|(page_index, tree)| tree.nodes.iter().any(|node| matches!(
5518 (&node.address, &node.kind),
5519 (
5520 PageRenderAddress::Frame { page_index: address_page, frame_index: 0 },
5521 PageRenderNodeKind::Frame,
5522 ) if *address_page == page_index
5523 )))
5524 );
5525 }
5526}