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