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