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