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 position_beats: None,
2207 style,
2208 text: text.to_owned(),
2209 placement: None,
2210 offset_x: None,
2211 offset_y: None,
2212 relative_x: None,
2213 relative_y: None,
2214 });
2215 }
2216 entries
2217}
2218
2219fn volta_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2220 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2221 return Vec::new();
2222 };
2223 let mut ranges = Vec::new();
2224 let mut start = None;
2225 for (index, measure) in staff.measures.iter().enumerate() {
2226 let Some(volta) = measure.volta.as_ref() else {
2227 continue;
2228 };
2229 if matches!(volta.kind.as_str(), "begin" | "begin_end") {
2230 start = Some(index);
2231 }
2232 if matches!(volta.kind.as_str(), "end" | "begin_end")
2233 && let Some(first_measure) = start.take()
2234 {
2235 ranges.push(KeepTogetherRange {
2236 first_measure,
2237 last_measure: index,
2238 });
2239 }
2240 }
2241 ranges
2242}
2243
2244fn repeat_ranges(score: &Score) -> Vec<KeepTogetherRange> {
2245 let Some(staff) = score.parts.first().and_then(|part| part.staves.first()) else {
2246 return Vec::new();
2247 };
2248 let mut ranges = Vec::new();
2249 let mut start = None;
2250 for (index, measure) in staff.measures.iter().enumerate() {
2251 if matches!(
2252 measure.barline_left,
2253 Barline::RepeatStart | Barline::RepeatBoth
2254 ) {
2255 start = Some(index);
2256 }
2257 if matches!(
2258 measure.barline_right,
2259 Barline::RepeatEnd | Barline::RepeatBoth
2260 ) {
2261 ranges.push(KeepTogetherRange {
2262 first_measure: start.take().unwrap_or(0),
2263 last_measure: index,
2264 });
2265 }
2266 }
2267 ranges
2268}
2269
2270fn repeat_system_ranges(score: &Score, rows: &[crate::RowLayout]) -> Vec<(usize, usize)> {
2271 repeat_ranges(score)
2272 .into_iter()
2273 .filter_map(|range| {
2274 let first = rows
2275 .iter()
2276 .position(|row| row.measure_indices.contains(&range.first_measure))?;
2277 let last = rows
2278 .iter()
2279 .position(|row| row.measure_indices.contains(&range.last_measure))?;
2280 Some((first, last))
2281 })
2282 .collect()
2283}
2284
2285fn page_span_segments(systems: &[SystemLayout]) -> Vec<PageSpanSegment> {
2286 let mut segments = Vec::new();
2287 for system in systems {
2288 for segment in &system.span_segments {
2289 if let Some(existing) = segments
2290 .iter_mut()
2291 .find(|existing: &&mut PageSpanSegment| existing.span_index == segment.span_index)
2292 {
2293 existing.ends_here |= segment.ends_here;
2294 } else {
2295 segments.push(PageSpanSegment {
2296 span_index: segment.span_index,
2297 starts_here: segment.starts_here,
2298 ends_here: segment.ends_here,
2299 });
2300 }
2301 }
2302 }
2303 segments
2304}
2305
2306fn page_measure_marks(systems: &[SystemLayout]) -> Vec<MeasureMark> {
2307 systems
2308 .iter()
2309 .flat_map(|system| system.measure_marks.iter().cloned())
2310 .collect()
2311}
2312
2313fn page_publication(
2314 score: &Score,
2315 measure_score: &Score,
2316 config: &PrintConfig,
2317 systems: &[SystemLayout],
2318 is_title_page: bool,
2319 page_number: Option<usize>,
2320) -> PagePublication {
2321 let metadata = &score.metadata;
2322 let part_labels = if config.publication.show_part_names {
2323 let parts = match config.part_layout {
2324 PartLayoutPolicy::FullScore => score.parts.iter().enumerate().collect::<Vec<_>>(),
2325 PartLayoutPolicy::ExtractedPart { part_index } => score
2326 .parts
2327 .get(part_index)
2328 .into_iter()
2329 .enumerate()
2330 .map(|(index, part)| (part_index + index, part))
2331 .collect(),
2332 };
2333 parts
2334 .into_iter()
2335 .map(|(part_index, part)| PartLabel {
2336 part_index,
2337 name: part.name.clone(),
2338 short_name: part.short_name.clone(),
2339 })
2340 .collect()
2341 } else {
2342 Vec::new()
2343 };
2344 let part_groups = if matches!(config.part_layout, PartLayoutPolicy::FullScore) {
2345 score
2346 .part_groups
2347 .iter()
2348 .map(|group| PartGroupMark {
2349 first_part: group.first_part,
2350 last_part: group.last_part,
2351 symbol: group.symbol.clone(),
2352 barlines_connect: group.barlines_connect,
2353 })
2354 .collect()
2355 } else {
2356 Vec::new()
2357 };
2358 let measure_numbers = if config.publication.show_measure_numbers {
2359 let staff = measure_score
2360 .parts
2361 .first()
2362 .and_then(|part| part.staves.first());
2363 systems
2364 .iter()
2365 .flat_map(|system| system.measure_indices.iter().copied())
2366 .filter_map(|index| staff.and_then(|staff| staff.measures.get(index)))
2367 .map(|measure| measure.number)
2368 .collect()
2369 } else {
2370 Vec::new()
2371 };
2372 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2373 let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape) {
2374 (paper_height, paper_width)
2375 } else {
2376 (paper_width, paper_height)
2377 };
2378 let mut text_blocks = Vec::new();
2379 let header_text = if config.publication.header_template.is_configured() {
2380 config.publication.header_template.resolve(page_number)
2381 } else {
2382 config
2383 .publication
2384 .header_text
2385 .as_ref()
2386 .or(config.publication.running_title.as_ref())
2387 };
2388 if !is_title_page && let Some(text) = header_text {
2389 text_blocks.push(PublicationTextBlock {
2390 role: PublicationTextRole::Header,
2391 text: text.clone(),
2392 x_mm: config.margin_left_mm + config.safe_left_mm,
2393 y_mm: config.margin_top_mm,
2394 width_mm: page_width
2395 - config.margin_left_mm
2396 - config.margin_right_mm
2397 - config.safe_left_mm
2398 - config.safe_right_mm,
2399 height_mm: config.publication.line_height_mm,
2400 alignment: config.publication.header_alignment,
2401 });
2402 }
2403 let footer_text = if config.publication.footer_template.is_configured() {
2404 config.publication.footer_template.resolve(page_number)
2405 } else {
2406 config.publication.footer_text.as_ref()
2407 };
2408 if let Some(text) = footer_text {
2409 text_blocks.push(PublicationTextBlock {
2410 role: PublicationTextRole::Footer,
2411 text: text.clone(),
2412 x_mm: config.margin_left_mm + config.safe_left_mm,
2413 y_mm: page_height - config.margin_bottom_mm,
2414 width_mm: page_width
2415 - config.margin_left_mm
2416 - config.margin_right_mm
2417 - config.safe_left_mm
2418 - config.safe_right_mm,
2419 height_mm: config.publication.line_height_mm,
2420 alignment: config.publication.footer_alignment,
2421 });
2422 }
2423 if config.publication.page_number_in_footer
2424 && let Some(page_number) = page_number
2425 {
2426 let (paper_width, paper_height) = config.paper_size.dimensions_mm();
2427 let (page_width, page_height) = if matches!(config.orientation, PageOrientation::Landscape)
2428 {
2429 (paper_height, paper_width)
2430 } else {
2431 (paper_width, paper_height)
2432 };
2433 text_blocks.push(PublicationTextBlock {
2434 role: PublicationTextRole::Footer,
2435 text: page_number.to_string(),
2436 x_mm: config.margin_left_mm + config.safe_left_mm,
2437 y_mm: page_height - config.margin_bottom_mm,
2438 width_mm: page_width
2439 - config.margin_left_mm
2440 - config.margin_right_mm
2441 - config.safe_left_mm
2442 - config.safe_right_mm,
2443 height_mm: config.publication.line_height_mm,
2444 alignment: config.publication.footer_alignment,
2445 });
2446 }
2447 if is_title_page {
2448 let content_height = page_height
2449 - config.margin_top_mm
2450 - config.margin_bottom_mm
2451 - config.safe_top_mm
2452 - config.safe_bottom_mm;
2453 let title_x = config.margin_left_mm + config.safe_left_mm;
2454 let title_width = page_width
2455 - config.margin_left_mm
2456 - config.margin_right_mm
2457 - config.safe_left_mm
2458 - config.safe_right_mm;
2459 let title_y = config.margin_top_mm + config.safe_top_mm + content_height * 0.30;
2460 if !metadata.title.trim().is_empty() {
2461 text_blocks.push(PublicationTextBlock {
2462 role: PublicationTextRole::Title,
2463 text: metadata.title.clone(),
2464 x_mm: title_x,
2465 y_mm: title_y,
2466 width_mm: title_width,
2467 height_mm: config.publication.line_height_mm,
2468 alignment: config.publication.title_alignment,
2469 });
2470 }
2471 if !metadata.movement_title.trim().is_empty() {
2472 text_blocks.push(PublicationTextBlock {
2473 role: PublicationTextRole::Subtitle,
2474 text: metadata.movement_title.clone(),
2475 x_mm: title_x,
2476 y_mm: title_y + config.publication.line_height_mm * 2.5,
2477 width_mm: title_width,
2478 height_mm: config.publication.line_height_mm,
2479 alignment: config.publication.title_alignment,
2480 });
2481 }
2482 let credit = match (metadata.composer.trim(), metadata.lyricist.trim()) {
2483 (composer, lyricist) if !composer.is_empty() && !lyricist.is_empty() => {
2484 format!("{composer} / {lyricist}")
2485 }
2486 (composer, _lyricist) if !composer.is_empty() => composer.to_string(),
2487 (_, lyricist) => lyricist.to_string(),
2488 };
2489 if !credit.is_empty() {
2490 text_blocks.push(PublicationTextBlock {
2491 role: PublicationTextRole::Credit,
2492 text: credit,
2493 x_mm: title_x,
2494 y_mm: title_y + config.publication.line_height_mm * 5.0,
2495 width_mm: title_width,
2496 height_mm: config.publication.line_height_mm,
2497 alignment: config.publication.title_alignment,
2498 });
2499 }
2500 if !metadata.copyright.trim().is_empty() {
2501 text_blocks.push(PublicationTextBlock {
2502 role: PublicationTextRole::Copyright,
2503 text: metadata.copyright.clone(),
2504 x_mm: title_x,
2505 y_mm: page_height - config.margin_bottom_mm,
2506 width_mm: title_width,
2507 height_mm: config.publication.line_height_mm,
2508 alignment: config.publication.title_alignment,
2509 });
2510 }
2511 }
2512 let image_resources = config
2513 .publication
2514 .image_resources
2515 .iter()
2516 .filter(|image| {
2517 matches!(
2518 (is_title_page, image.placement),
2519 (
2520 true,
2521 PublicationImagePlacement::TitlePage | PublicationImagePlacement::EveryPage
2522 ) | (
2523 false,
2524 PublicationImagePlacement::MusicPages | PublicationImagePlacement::EveryPage
2525 )
2526 )
2527 })
2528 .cloned()
2529 .collect();
2530 let sections = if is_title_page {
2531 Vec::new()
2532 } else {
2533 config
2534 .publication
2535 .sections
2536 .iter()
2537 .filter(|section| {
2538 systems
2539 .iter()
2540 .any(|system| system.measure_indices.contains(§ion.first_measure))
2541 })
2542 .cloned()
2543 .collect()
2544 };
2545 let spacers = if is_title_page {
2546 Vec::new()
2547 } else {
2548 config
2549 .publication
2550 .spacers
2551 .iter()
2552 .filter(|spacer| {
2553 systems
2554 .iter()
2555 .any(|system| system.measure_indices.contains(&spacer.before_measure))
2556 })
2557 .cloned()
2558 .collect()
2559 };
2560 let frames = config
2561 .publication
2562 .frames
2563 .iter()
2564 .filter(|frame| {
2565 matches!(
2566 (is_title_page, frame.placement),
2567 (
2568 true,
2569 PublicationFramePlacement::TitlePage | PublicationFramePlacement::EveryPage
2570 ) | (
2571 false,
2572 PublicationFramePlacement::MusicPages | PublicationFramePlacement::EveryPage
2573 )
2574 )
2575 })
2576 .cloned()
2577 .collect();
2578 PagePublication {
2579 is_title_page,
2580 title: metadata.title.clone(),
2581 movement_title: metadata.movement_title.clone(),
2582 composer: metadata.composer.clone(),
2583 lyricist: metadata.lyricist.clone(),
2584 copyright: metadata.copyright.clone(),
2585 running_title: config.publication.running_title.clone(),
2586 score_texts: score.texts.clone(),
2587 part_labels,
2588 part_groups,
2589 measure_numbers,
2590 text_blocks,
2591 image_resources,
2592 sections,
2593 spacers,
2594 frames,
2595 }
2596}
2597
2598#[allow(clippy::too_many_arguments)]
2599fn build_page_layout(
2600 score: &Score,
2601 layout_score: &Score,
2602 config: &PrintConfig,
2603 systems: Vec<SystemLayout>,
2604 page_index: usize,
2605 page_number: Option<usize>,
2606 width_mm: f32,
2607 height_mm: f32,
2608 content_width_mm: f32,
2609 content_height_mm: f32,
2610 break_reason: BreakReason,
2611 is_title_page: bool,
2612) -> PageLayout {
2613 let publication = page_publication(
2614 score,
2615 layout_score,
2616 config,
2617 &systems,
2618 is_title_page,
2619 page_number,
2620 );
2621 let span_segments = if is_title_page {
2622 Vec::new()
2623 } else {
2624 page_span_segments(&systems)
2625 };
2626 let measure_marks = if is_title_page {
2627 Vec::new()
2628 } else {
2629 page_measure_marks(&systems)
2630 };
2631 PageLayout {
2632 address: PageAddress { page_index },
2633 page_index,
2634 page_number,
2635 color_policy: config.color_policy,
2636 crop_mark_policy: config.crop_mark_policy,
2637 glyph_resources: config.glyph_resources.clone(),
2638 publication,
2639 width_mm,
2640 height_mm,
2641 content_left_mm: config.margin_left_mm + config.safe_left_mm,
2642 content_width_mm,
2643 content_height_mm,
2644 bleed_top_mm: config.bleed_top_mm,
2645 bleed_right_mm: config.bleed_right_mm,
2646 bleed_bottom_mm: config.bleed_bottom_mm,
2647 bleed_left_mm: config.bleed_left_mm,
2648 span_segments,
2649 measure_marks,
2650 systems,
2651 break_reason,
2652 }
2653}
2654
2655fn score_for_part_layout(
2656 score: &Score,
2657 policy: PartLayoutPolicy,
2658) -> Result<Score, PrintLayoutError> {
2659 let PartLayoutPolicy::ExtractedPart { part_index } = policy else {
2660 return Ok(score.clone());
2661 };
2662 let Some(part) = score.parts.get(part_index) else {
2663 return Err(PrintLayoutError::InvalidPartIndex);
2664 };
2665 let mut selected = score.clone();
2666 selected.parts = vec![part.clone()];
2667 selected.part_groups.clear();
2668 Ok(selected)
2669}
2670
2671fn validate_publication_sections(
2672 score: &Score,
2673 sections: &[PublicationSection],
2674 keep_together: &[KeepTogetherRange],
2675) -> Result<(), PrintLayoutError> {
2676 let measure_count = score
2677 .parts
2678 .first()
2679 .and_then(|part| part.staves.first())
2680 .map_or(0, |staff| staff.measures.len());
2681 let mut previous_start = None;
2682 for (index, section) in sections.iter().enumerate() {
2683 if section.first_measure >= measure_count
2684 || section.title.trim().is_empty()
2685 || section.title.len() > 1024
2686 || previous_start.is_some_and(|previous| previous >= section.first_measure)
2687 {
2688 return Err(PrintLayoutError::InvalidPublicationSection { index });
2689 }
2690 if keep_together.iter().any(|range| {
2691 range.first_measure < section.first_measure
2692 && section.first_measure <= range.last_measure
2693 }) {
2694 return Err(PrintLayoutError::PublicationSectionConflictsWithKeepTogether { index });
2695 }
2696 previous_start = Some(section.first_measure);
2697 }
2698 Ok(())
2699}
2700
2701fn validate_publication_spacers(
2702 score: &Score,
2703 spacers: &[PublicationSpacer],
2704 keep_together: &[KeepTogetherRange],
2705 content_height_mm: f32,
2706 scaled_system_height_mm: f32,
2707) -> Result<(), PrintLayoutError> {
2708 let measure_count = score
2709 .parts
2710 .first()
2711 .and_then(|part| part.staves.first())
2712 .map_or(0, |staff| staff.measures.len());
2713 let mut previous_measure = None;
2714 for (index, spacer) in spacers.iter().enumerate() {
2715 if spacer.before_measure >= measure_count
2716 || !spacer.height_mm.is_finite()
2717 || spacer.height_mm <= 0.0
2718 || spacer.height_mm + scaled_system_height_mm > content_height_mm
2719 || previous_measure.is_some_and(|previous| previous >= spacer.before_measure)
2720 || keep_together.iter().any(|range| {
2721 range.first_measure < spacer.before_measure
2722 && spacer.before_measure <= range.last_measure
2723 })
2724 {
2725 return Err(PrintLayoutError::InvalidPublicationSpacer { index });
2726 }
2727 previous_measure = Some(spacer.before_measure);
2728 }
2729 Ok(())
2730}
2731
2732fn spacer_height_before_measure(spacers: &[PublicationSpacer], measure_index: usize) -> f32 {
2733 spacers
2734 .iter()
2735 .find(|spacer| spacer.before_measure == measure_index)
2736 .map_or(0.0, |spacer| spacer.height_mm)
2737}
2738
2739fn split_rows_at_measure_starts(
2740 rows: Vec<crate::RowLayout>,
2741 starts: &[usize],
2742) -> Vec<crate::RowLayout> {
2743 if starts.is_empty() {
2744 return rows;
2745 }
2746 let mut split_rows = Vec::with_capacity(rows.len() + starts.len());
2747 for row in rows {
2748 let mut cuts = vec![0, row.measure_indices.len()];
2749 for start in starts {
2750 if let Some(position) = row.measure_indices.iter().position(|index| index == start) {
2751 cuts.push(position);
2752 }
2753 }
2754 cuts.sort_unstable();
2755 cuts.dedup();
2756 for window in cuts.windows(2) {
2757 if window[0] < window[1] {
2758 split_rows.push(crate::RowLayout {
2759 measure_indices: row.measure_indices[window[0]..window[1]].to_vec(),
2760 });
2761 }
2762 }
2763 }
2764 split_rows
2765}
2766
2767fn publication_image_resource_is_valid(
2768 image: &PublicationImageResource,
2769 width_mm: f32,
2770 height_mm: f32,
2771) -> bool {
2772 let safe_key = !image.resource_key.trim().is_empty()
2773 && image.resource_key.len() <= 256
2774 && !image.resource_key.contains("..")
2775 && !image.resource_key.contains(['/', '\\', ':']);
2776 let safe_alt_text = !image.alt_text.trim().is_empty() && image.alt_text.len() <= 4096;
2777 let finite_geometry = [image.x_mm, image.y_mm, image.width_mm, image.height_mm]
2778 .iter()
2779 .all(|value| value.is_finite());
2780 let fits_page = image.x_mm >= 0.0
2781 && image.y_mm >= 0.0
2782 && image.width_mm > 0.0
2783 && image.height_mm > 0.0
2784 && image.x_mm + image.width_mm <= width_mm
2785 && image.y_mm + image.height_mm <= height_mm;
2786 safe_key && safe_alt_text && finite_geometry && fits_page
2787}
2788
2789fn publication_frame_is_valid(frame: &PublicationFrame, width_mm: f32, height_mm: f32) -> bool {
2790 let finite_geometry = [
2791 frame.x_mm,
2792 frame.y_mm,
2793 frame.width_mm,
2794 frame.height_mm,
2795 frame.stroke_width_mm,
2796 ]
2797 .iter()
2798 .all(|value| value.is_finite());
2799 let fits_page = frame.x_mm >= 0.0
2800 && frame.y_mm >= 0.0
2801 && frame.width_mm > 0.0
2802 && frame.height_mm > 0.0
2803 && frame.stroke_width_mm > 0.0
2804 && frame.x_mm + frame.width_mm <= width_mm
2805 && frame.y_mm + frame.height_mm <= height_mm;
2806 finite_geometry && fits_page
2807}
2808
2809fn publication_page_sections_are_valid(sections: &[PublicationSection]) -> bool {
2810 sections.iter().enumerate().all(|(index, section)| {
2811 !section.title.trim().is_empty()
2812 && section.title.len() <= 1024
2813 && (index == 0 || sections[index - 1].first_measure < section.first_measure)
2814 })
2815}
2816
2817fn publication_page_spacers_are_valid(spacers: &[PublicationSpacer]) -> bool {
2818 spacers.iter().enumerate().all(|(index, spacer)| {
2819 spacer.height_mm.is_finite()
2820 && spacer.height_mm > 0.0
2821 && (index == 0 || spacers[index - 1].before_measure < spacer.before_measure)
2822 })
2823}
2824
2825fn validate_print_config(config: &PrintConfig) -> Result<(f32, f32, f32), PrintLayoutError> {
2826 let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
2827 if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
2828 return Err(PrintLayoutError::InvalidPaperDimensions);
2829 }
2830 if matches!(config.orientation, PageOrientation::Landscape) {
2831 std::mem::swap(&mut width_mm, &mut height_mm);
2832 }
2833
2834 let margins = [
2835 config.margin_top_mm,
2836 config.margin_right_mm,
2837 config.margin_bottom_mm,
2838 config.margin_left_mm,
2839 config.bleed_top_mm,
2840 config.bleed_right_mm,
2841 config.bleed_bottom_mm,
2842 config.bleed_left_mm,
2843 config.safe_top_mm,
2844 config.safe_right_mm,
2845 config.safe_bottom_mm,
2846 config.safe_left_mm,
2847 ];
2848 if margins
2849 .iter()
2850 .any(|value| !value.is_finite() || *value < 0.0)
2851 {
2852 return Err(PrintLayoutError::InvalidMargins);
2853 }
2854 if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
2855 return Err(PrintLayoutError::InvalidSystemHeight);
2856 }
2857 if !config.scale.is_finite() || config.scale <= 0.0 {
2858 return Err(PrintLayoutError::InvalidScale);
2859 }
2860 let scaled_system_height_mm = config.system_height_mm * config.scale;
2861 if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
2862 return Err(PrintLayoutError::InvalidScale);
2863 }
2864 if !config.publication.line_height_mm.is_finite() || config.publication.line_height_mm <= 0.0 {
2865 return Err(PrintLayoutError::InvalidPublicationLineHeight);
2866 }
2867 for (index, image) in config.publication.image_resources.iter().enumerate() {
2868 if !publication_image_resource_is_valid(image, width_mm, height_mm) {
2869 return Err(PrintLayoutError::InvalidPublicationImageResource { index });
2870 }
2871 }
2872 for (index, frame) in config.publication.frames.iter().enumerate() {
2873 if !publication_frame_is_valid(frame, width_mm, height_mm) {
2874 return Err(PrintLayoutError::InvalidPublicationFrame { index });
2875 }
2876 }
2877 if matches!(&config.glyph_resources, GlyphResourcePolicy::HostProvided(key) if key.trim().is_empty())
2878 {
2879 return Err(PrintLayoutError::InvalidGlyphResourceKey);
2880 }
2881 Ok((width_mm, height_mm, scaled_system_height_mm))
2882}
2883
2884fn print_density_rows(
2889 score: &Score,
2890 vis_slots: &[usize],
2891 max_columns: usize,
2892 first_row_limit: Option<usize>,
2893 content_width_mm: f32,
2894 print_size_factor: f32,
2895) -> Vec<RowLayout> {
2896 if vis_slots.is_empty() {
2897 return vec![RowLayout {
2898 measure_indices: Vec::new(),
2899 }];
2900 }
2901
2902 let mut baseline_costs = build_rows(score, vis_slots, 4, None)
2903 .iter()
2904 .map(|row| {
2905 row.measure_indices
2906 .iter()
2907 .map(|&measure| notation_density_units(score, measure))
2908 .sum::<usize>()
2909 })
2910 .collect::<Vec<_>>();
2911 baseline_costs.sort_unstable();
2912 let percentile_index = (baseline_costs.len() * 9 - 1) / 10;
2913 let budget =
2914 ((baseline_costs[percentile_index] as f32 * content_width_mm / 182.0 / print_size_factor)
2915 .round()
2916 .max(3.0)) as usize;
2917 let first_row_budget = ((budget as f32 * 0.85).round().max(3.0)) as usize;
2920
2921 let mut rows = Vec::new();
2922 let mut row = Vec::new();
2923 let mut row_columns = 0usize;
2924 let mut row_cost = 0usize;
2925 let mut spans = Vec::<(usize, usize)>::new();
2926 for &measure in vis_slots {
2927 if let Some((last, columns)) = spans.last_mut()
2928 && *last == measure
2929 {
2930 *columns += 1;
2931 } else {
2932 spans.push((measure, 1));
2933 }
2934 }
2935 for (measure, columns) in spans {
2936 let cost = notation_density_units(score, measure).max(columns * 3);
2937 let limit = if rows.is_empty() {
2938 first_row_limit.unwrap_or(max_columns).max(1)
2939 } else {
2940 max_columns
2941 };
2942 let row_budget = if rows.is_empty() {
2943 first_row_budget
2944 } else {
2945 budget
2946 };
2947 if !row.is_empty()
2948 && (row_columns.saturating_add(columns) > limit
2949 || row_cost.saturating_add(cost) > row_budget)
2950 {
2951 rows.push(RowLayout {
2952 measure_indices: std::mem::take(&mut row),
2953 });
2954 row_columns = 0;
2955 row_cost = 0;
2956 }
2957 row.push(measure);
2958 row_columns = row_columns.saturating_add(columns);
2959 row_cost = row_cost.saturating_add(cost);
2960 if score
2961 .parts
2962 .iter()
2963 .flat_map(|part| &part.staves)
2964 .filter_map(|staff| staff.measures.get(measure))
2965 .any(|measure| measure.system_break || measure.page_break)
2966 {
2967 rows.push(RowLayout {
2968 measure_indices: std::mem::take(&mut row),
2969 });
2970 row_columns = 0;
2971 row_cost = 0;
2972 }
2973 }
2974 if !row.is_empty() {
2975 rows.push(RowLayout {
2976 measure_indices: row,
2977 });
2978 }
2979 rows
2980}
2981
2982pub fn compute_print_layout(
2984 score: &Score,
2985 config: &PrintConfig,
2986) -> Result<PrintLayoutResult, PrintLayoutError> {
2987 let layout_score = score_for_part_layout(score, config.part_layout)?;
2988 let (width_mm, height_mm, configured_system_height_mm) = validate_print_config(config)?;
2989 let staff_count = layout_score
2990 .parts
2991 .iter()
2992 .map(|part| part.staves.len())
2993 .sum::<usize>();
2994 const TWO_STAFF_BASELINE_HEIGHT_MM: f32 = 40.0;
2999 const TWO_STAFF_MIN_HEIGHT_MM: f32 = 50.0;
3000 const TWO_STAFF_DENSITY_HEIGHT_MM: f32 = 50.0;
3001 let grand_staff_has_below_text = layout_score.parts.len() == 1
3002 && layout_score.parts[0].staves.len() == 2
3003 && layout_score.parts[0].staves[0]
3004 .measures
3005 .iter()
3006 .any(|measure| {
3007 measure.texts.iter().any(|text| {
3008 text.placement
3009 .as_deref()
3010 .is_some_and(|placement| placement.eq_ignore_ascii_case("below"))
3011 })
3012 });
3013 let staff_clearance_mm = if staff_count <= 1 {
3014 0.0
3015 } else {
3016 (24.0 + staff_count.saturating_sub(1) as f32 * 16.0).max(if staff_count == 2 {
3017 TWO_STAFF_MIN_HEIGHT_MM
3018 + if grand_staff_has_below_text {
3019 10.0
3020 } else {
3021 0.0
3022 }
3023 } else {
3024 0.0
3025 })
3026 };
3027 let has_multiple_lyric_verses =
3028 layout_score
3029 .parts
3030 .iter()
3031 .flat_map(|part| &part.staves)
3032 .any(|staff| {
3033 staff
3034 .measures
3035 .iter()
3036 .flat_map(|measure| &measure.voices)
3037 .flatten()
3038 .any(|note| !note.additional_lyrics.is_empty())
3039 });
3040 let staff_clearance_mm =
3043 staff_clearance_mm + if has_multiple_lyric_verses { 16.0 } else { 0.0 };
3044 let scaled_system_height_mm = match config.system_height_policy {
3045 SystemHeightPolicy::StaffAware => {
3046 configured_system_height_mm.max(staff_clearance_mm * config.scale)
3047 }
3048 SystemHeightPolicy::Fixed => configured_system_height_mm,
3049 };
3050 if !scaled_system_height_mm.is_finite() {
3051 return Err(PrintLayoutError::InvalidSystemHeight);
3052 }
3053
3054 let content_width_mm = width_mm
3055 - config.margin_left_mm
3056 - config.margin_right_mm
3057 - config.safe_left_mm
3058 - config.safe_right_mm;
3059 let content_height_mm = height_mm
3060 - config.margin_top_mm
3061 - config.margin_bottom_mm
3062 - config.safe_top_mm
3063 - config.safe_bottom_mm;
3064 if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
3065 return Err(PrintLayoutError::NoUsablePageArea);
3066 }
3067
3068 if scaled_system_height_mm > content_height_mm {
3069 return Err(PrintLayoutError::InvalidSystemHeight);
3070 }
3071 let has_opening_credits = !config.publication.title_page
3075 && !score.metadata.title.trim().is_empty()
3076 && (!score.metadata.movement_title.trim().is_empty()
3077 || !score.metadata.composer.trim().is_empty());
3078 let opening_credit_reserve_mm = if has_opening_credits {
3079 16.0_f32.min((content_height_mm - scaled_system_height_mm).max(0.0))
3080 } else {
3081 0.0
3082 };
3083 let geometric_capacity = (content_height_mm / scaled_system_height_mm).floor() as usize;
3084 if config
3085 .systems_per_page
3086 .is_some_and(|requested| requested.max(1) > geometric_capacity)
3087 {
3088 return Err(PrintLayoutError::InvalidSystemHeight);
3089 }
3090 let systems_per_page = config.systems_per_page.unwrap_or(geometric_capacity).max(1);
3091 let first_row_measures = config.first_system_measures.or_else(|| {
3092 (matches!(config.pickup_policy, PickupPolicy::DetectFirstMeasure)
3093 && has_first_measure_pickup(&layout_score))
3094 .then_some(1)
3095 });
3096 let mut layout = compute_layout(
3097 &layout_score,
3098 &LayoutConfig {
3099 measures_per_row: config.measures_per_system.max(1),
3100 first_row_measures,
3101 ..LayoutConfig::default()
3102 },
3103 );
3104 if config.system_break_strategy == PrintSystemBreakStrategy::NotationDensity {
3105 layout.rows = print_density_rows(
3106 &layout_score,
3107 &layout.vis_slots,
3108 config.measures_per_system.max(1),
3109 first_row_measures,
3110 content_width_mm,
3111 if staff_count == 2 {
3112 (scaled_system_height_mm.min(TWO_STAFF_DENSITY_HEIGHT_MM)
3113 / TWO_STAFF_BASELINE_HEIGHT_MM)
3114 .max(1.0)
3115 } else {
3116 1.0
3117 },
3118 );
3119 }
3120
3121 let mut keep_together = config.keep_together.clone();
3122 if matches!(
3123 config.notation_break_policy,
3124 NotationBreakPolicy::KeepVoltaTogether
3125 ) {
3126 keep_together.extend(volta_ranges(&layout_score));
3127 }
3128 let rows = apply_keep_together(
3129 &layout_score,
3130 layout.rows,
3131 &keep_together,
3132 config.measures_per_system.max(1),
3133 )?;
3134 validate_publication_sections(&layout_score, &config.publication.sections, &keep_together)?;
3135 validate_publication_spacers(
3136 &layout_score,
3137 &config.publication.spacers,
3138 &keep_together,
3139 content_height_mm,
3140 scaled_system_height_mm,
3141 )?;
3142 let rows = split_rows_at_measure_starts(
3143 rows,
3144 &config
3145 .publication
3146 .sections
3147 .iter()
3148 .map(|section| section.first_measure)
3149 .chain(
3150 config
3151 .publication
3152 .spacers
3153 .iter()
3154 .map(|spacer| spacer.before_measure),
3155 )
3156 .collect::<Vec<_>>(),
3157 );
3158 let first_spacer_mm = rows
3159 .first()
3160 .and_then(|row| row.measure_indices.first())
3161 .map_or(0.0, |measure_index| {
3162 spacer_height_before_measure(&config.publication.spacers, *measure_index)
3163 });
3164 let opening_credit_reserve_mm = opening_credit_reserve_mm
3165 .min((content_height_mm - scaled_system_height_mm - first_spacer_mm).max(0.0));
3166
3167 let has_explicit_page_break = rows.iter().any(|row| {
3168 row.measure_indices.last().is_some_and(|&measure_index| {
3169 layout_score
3170 .parts
3171 .iter()
3172 .flat_map(|part| part.staves.iter())
3173 .filter_map(|staff| staff.measures.get(measure_index))
3174 .any(|measure| measure.page_break)
3175 })
3176 });
3177 let repeat_system_ranges = if matches!(
3178 config.notation_break_policy,
3179 NotationBreakPolicy::KeepRepeatsTogether
3180 ) {
3181 repeat_system_ranges(&layout_score, &rows)
3182 } else {
3183 Vec::new()
3184 };
3185 if repeat_system_ranges
3186 .iter()
3187 .any(|(first, last)| last.saturating_sub(*first).saturating_add(1) > systems_per_page)
3188 {
3189 return Err(PrintLayoutError::RepeatRangeExceedsPageCapacity);
3190 }
3191 let page_capacities = if matches!(config.final_page_policy, FinalPagePolicy::Balance)
3192 && !has_explicit_page_break
3193 && systems_per_page > 1
3194 && rows.len() > systems_per_page
3195 && repeat_system_ranges.is_empty()
3196 && !config
3197 .publication
3198 .sections
3199 .iter()
3200 .any(|section| section.start_on_new_page)
3201 && config.publication.spacers.is_empty()
3202 {
3203 let page_count = rows.len().div_ceil(systems_per_page);
3204 let base = rows.len() / page_count;
3205 let remainder = rows.len() % page_count;
3206 (0..page_count)
3207 .map(|index| base + usize::from(index < remainder))
3208 .collect::<Vec<_>>()
3209 } else {
3210 Vec::new()
3211 };
3212
3213 let mut pages = Vec::new();
3214 let mut page_systems = Vec::new();
3215 let mut page_used_height_mm = opening_credit_reserve_mm;
3216 let mut page_index = 0;
3217 for (system_index, row) in rows.iter().enumerate() {
3218 let repeat_starts_here = repeat_system_ranges
3219 .iter()
3220 .any(|(first, _)| *first == system_index);
3221 let section_starts_on_new_page =
3222 row.measure_indices.first().is_some_and(|measure_index| {
3223 config.publication.sections.iter().any(|section| {
3224 section.first_measure == *measure_index && section.start_on_new_page
3225 })
3226 });
3227 let spacer_height_mm = row.measure_indices.first().map_or(0.0, |measure_index| {
3228 spacer_height_before_measure(&config.publication.spacers, *measure_index)
3229 });
3230 let spacer_requires_new_page = !page_systems.is_empty()
3231 && page_used_height_mm + spacer_height_mm + scaled_system_height_mm > content_height_mm;
3232 if (repeat_starts_here || section_starts_on_new_page || spacer_requires_new_page)
3233 && !page_systems.is_empty()
3234 {
3235 let page_number = match config.page_numbering {
3236 PageNumbering::None => None,
3237 PageNumbering::OneBased => Some(page_index + 1),
3238 };
3239 pages.push(build_page_layout(
3240 score,
3241 &layout_score,
3242 config,
3243 std::mem::take(&mut page_systems),
3244 page_index,
3245 page_number,
3246 width_mm,
3247 height_mm,
3248 content_width_mm,
3249 content_height_mm,
3250 if section_starts_on_new_page {
3251 BreakReason::SectionBreak
3252 } else {
3253 BreakReason::PageCapacity
3254 },
3255 false,
3256 ));
3257 page_index += 1;
3258 page_used_height_mm = 0.0;
3259 }
3260 let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
3261 layout_score
3262 .parts
3263 .iter()
3264 .flat_map(|part| part.staves.iter())
3265 .filter_map(|staff| staff.measures.get(measure_index))
3266 .any(|measure| measure.page_break)
3267 });
3268 let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
3269 layout_score
3270 .parts
3271 .iter()
3272 .flat_map(|part| part.staves.iter())
3273 .filter_map(|staff| staff.measures.get(measure_index))
3274 .any(|measure| measure.system_break)
3275 });
3276 let is_last_system = system_index + 1 == rows.len();
3277 let break_reason = if explicit_page_break {
3278 BreakReason::ExplicitPageBreak
3279 } else if explicit_system_break {
3280 BreakReason::ExplicitSystemBreak
3281 } else if is_last_system {
3282 BreakReason::EndOfScore
3283 } else {
3284 BreakReason::MeasureCapacity
3285 };
3286 let system = SystemLayout {
3287 address: SystemAddress {
3288 system_index,
3289 page_index,
3290 index_on_page: page_systems.len(),
3291 },
3292 system_index,
3293 page_index,
3294 measure_indices: row.measure_indices.clone(),
3295 measure_spans: measure_spans(&layout_score, &row.measure_indices),
3296 span_segments: span_segments(&layout.spans, &row.measure_indices),
3297 measure_marks: measure_marks(&layout_score, &row.measure_indices),
3298 top_mm: config.margin_top_mm
3299 + config.safe_top_mm
3300 + page_used_height_mm
3301 + spacer_height_mm,
3302 height_mm: scaled_system_height_mm,
3303 break_reason,
3304 };
3305 page_systems.push(system);
3306 page_used_height_mm += spacer_height_mm + scaled_system_height_mm;
3307
3308 let page_capacity = page_capacities
3309 .get(page_index)
3310 .copied()
3311 .unwrap_or(systems_per_page);
3312 let page_is_full = page_systems.len() >= page_capacity;
3313 if page_is_full || explicit_page_break {
3314 let page_break_reason = if explicit_page_break {
3315 BreakReason::ExplicitPageBreak
3316 } else if is_last_system {
3317 BreakReason::EndOfScore
3318 } else {
3319 BreakReason::PageCapacity
3320 };
3321 let page_number = match config.page_numbering {
3322 PageNumbering::None => None,
3323 PageNumbering::OneBased => Some(page_index + 1),
3324 };
3325 pages.push(build_page_layout(
3326 score,
3327 &layout_score,
3328 config,
3329 std::mem::take(&mut page_systems),
3330 page_index,
3331 page_number,
3332 width_mm,
3333 height_mm,
3334 content_width_mm,
3335 content_height_mm,
3336 page_break_reason,
3337 false,
3338 ));
3339 page_index += 1;
3340 page_used_height_mm = 0.0;
3341 }
3342 }
3343 if !page_systems.is_empty() || pages.is_empty() {
3344 let page_number = match config.page_numbering {
3345 PageNumbering::None => None,
3346 PageNumbering::OneBased => Some(page_index + 1),
3347 };
3348 pages.push(build_page_layout(
3349 score,
3350 &layout_score,
3351 config,
3352 page_systems,
3353 page_index,
3354 page_number,
3355 width_mm,
3356 height_mm,
3357 content_width_mm,
3358 content_height_mm,
3359 BreakReason::EndOfScore,
3360 false,
3361 ));
3362 }
3363
3364 if config.publication.title_page {
3365 for page in &mut pages {
3366 page.page_index += 1;
3367 page.address.page_index = page.page_index;
3368 page.page_number = match config.page_numbering {
3369 PageNumbering::None => None,
3370 PageNumbering::OneBased => Some(page.page_index + 1),
3371 };
3372 for system in &mut page.systems {
3373 system.page_index += 1;
3374 system.address.page_index = system.page_index;
3375 }
3376 page.publication = page_publication(
3377 score,
3378 &layout_score,
3379 config,
3380 &page.systems,
3381 false,
3382 page.page_number,
3383 );
3384 }
3385 let page_number = match config.page_numbering {
3386 PageNumbering::None => None,
3387 PageNumbering::OneBased => Some(1),
3388 };
3389 pages.insert(
3390 0,
3391 build_page_layout(
3392 score,
3393 &layout_score,
3394 config,
3395 Vec::new(),
3396 0,
3397 page_number,
3398 width_mm,
3399 height_mm,
3400 content_width_mm,
3401 content_height_mm,
3402 BreakReason::TitlePage,
3403 true,
3404 ),
3405 );
3406 }
3407
3408 let result = PrintLayoutResult {
3409 contract_version: PRINT_LAYOUT_CONTRACT_VERSION,
3410 pages,
3411 };
3412 result.validate()?;
3413 Ok(result)
3414}
3415
3416pub fn compute_print_layout_for_view(
3423 score: &Score,
3424 config: &PrintConfig,
3425 view_id: &str,
3426) -> Result<PrintLayoutResult, PrintLayoutError> {
3427 let view = score
3428 .views
3429 .iter()
3430 .find(|view| view.id == view_id)
3431 .ok_or(PrintLayoutError::InvalidView)?;
3432 let mut view_config = config.clone();
3433 if let Some(measures_per_row) = view.layout.measures_per_row {
3434 view_config.measures_per_system = measures_per_row;
3435 }
3436
3437 let mut projected = score
3438 .resolve_view(view_id)
3439 .map_err(|_| PrintLayoutError::InvalidView)?;
3440 for (indices, is_page_break) in [
3441 (&view.layout.system_breaks, false),
3442 (&view.layout.page_breaks, true),
3443 ] {
3444 for &measure_index in indices {
3445 let mut found = false;
3446 for part in &mut projected.parts {
3447 for staff in &mut part.staves {
3448 if let Some(measure) = staff.measures.get_mut(measure_index) {
3449 found = true;
3450 if is_page_break {
3451 measure.page_break = true;
3452 } else {
3453 measure.system_break = true;
3454 }
3455 }
3456 }
3457 }
3458 if !found {
3459 return Err(PrintLayoutError::InvalidView);
3460 }
3461 }
3462 }
3463 compute_print_layout(&projected, &view_config)
3464}
3465
3466#[cfg(test)]
3467mod tests {
3468 use super::*;
3469 use acorde_core::{
3470 Clef, Duration, Measure, Note, Part, PartGroup, PartGroupSymbol, Pitch, Score,
3471 ScoreTemplate, ScoreView, Staff, Step, ViewStaffRef,
3472 };
3473
3474 fn score_with_measures(count: usize) -> Score {
3475 let mut score = Score::default();
3476 let mut part = Part::new("Piano", "Pno.");
3477 let mut staff = Staff::new(Clef::Treble);
3478 staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
3479 part.staves = vec![staff];
3480 score.parts = vec![part];
3481 score
3482 }
3483
3484 #[test]
3485 fn opening_credits_reserve_first_page_space_without_overflowing_systems() {
3486 let mut score = score_with_measures(8);
3487 score.metadata.title = "Suite".into();
3488 score.metadata.movement_title = "Prelude".into();
3489 score.metadata.composer = "Composer".into();
3490 let layout = compute_print_layout(
3491 &score,
3492 &PrintConfig {
3493 measures_per_system: 1,
3494 system_height_mm: 50.0,
3495 system_height_policy: SystemHeightPolicy::Fixed,
3496 ..PrintConfig::default()
3497 },
3498 )
3499 .unwrap();
3500 assert_eq!(layout.pages.len(), 2);
3501 assert_eq!(layout.pages[0].systems.len(), 4);
3502 assert_eq!(layout.pages[0].systems[0].top_mm, 32.0);
3503 assert_eq!(layout.pages[1].systems[0].top_mm, 16.0);
3504 for page in &layout.pages {
3505 assert!(page.systems.last().unwrap().top_mm + 50.0 <= 281.0);
3506 }
3507 }
3508
3509 #[test]
3510 fn density_breaks_use_more_space_for_sparse_bars_and_isolate_busy_bars() {
3511 let mut score = score_with_measures(16);
3512 score.parts[0].staves[0].measures[8].voices[0] = (0..16)
3513 .map(|_| Note::new(Pitch::new(Step::C, 4), Duration::Sixteenth))
3514 .collect();
3515 let config = PrintConfig {
3516 measures_per_system: 7,
3517 system_break_strategy: PrintSystemBreakStrategy::NotationDensity,
3518 ..PrintConfig::default()
3519 };
3520 let layout = compute_print_layout(&score, &config).unwrap();
3521 let rows = layout
3522 .pages
3523 .iter()
3524 .flat_map(|page| page.systems.iter())
3525 .map(|system| system.measure_indices.clone())
3526 .collect::<Vec<_>>();
3527 assert_eq!(rows[0], vec![0, 1, 2, 3, 4, 5, 6]);
3528 assert_eq!(
3529 rows.iter().flatten().copied().collect::<Vec<_>>(),
3530 (0..16).collect::<Vec<_>>()
3531 );
3532 assert!(rows.iter().find(|row| row.contains(&8)).unwrap().len() < 7);
3533 }
3534
3535 #[test]
3536 fn density_breaks_preserve_authored_system_breaks_and_first_system_capacity() {
3537 let mut score = score_with_measures(8);
3538 score.parts[0].staves[0].measures[3].system_break = true;
3539 let layout = compute_print_layout(
3540 &score,
3541 &PrintConfig {
3542 measures_per_system: 7,
3543 first_system_measures: Some(2),
3544 system_break_strategy: PrintSystemBreakStrategy::NotationDensity,
3545 ..PrintConfig::default()
3546 },
3547 )
3548 .unwrap();
3549 let rows = layout
3550 .pages
3551 .iter()
3552 .flat_map(|page| page.systems.iter())
3553 .map(|system| system.measure_indices.clone())
3554 .collect::<Vec<_>>();
3555 assert_eq!(rows[0], vec![0, 1]);
3556 assert_eq!(rows[1], vec![2, 3]);
3557 assert_eq!(
3558 rows.iter().flatten().copied().collect::<Vec<_>>(),
3559 (0..8).collect::<Vec<_>>()
3560 );
3561 }
3562
3563 #[test]
3564 fn legacy_print_config_json_defaults_to_fixed_breaks() {
3565 let mut value = serde_json::to_value(PrintConfig::default()).unwrap();
3566 value
3567 .as_object_mut()
3568 .unwrap()
3569 .remove("system_break_strategy");
3570 let restored: PrintConfig = serde_json::from_value(value).unwrap();
3571 assert_eq!(
3572 restored.system_break_strategy,
3573 PrintSystemBreakStrategy::FixedMeasureCount
3574 );
3575 }
3576
3577 #[test]
3578 fn publication_metadata_accepts_legacy_partial_json() {
3579 let publication: PagePublication =
3580 serde_json::from_str(r#"{"is_title_page":true,"title":"Legacy score"}"#)
3581 .expect("legacy publication metadata should deserialize");
3582
3583 assert!(publication.is_title_page);
3584 assert_eq!(publication.title, "Legacy score");
3585 assert!(publication.movement_title.is_empty());
3586 assert!(publication.part_labels.is_empty());
3587 assert!(publication.part_groups.is_empty());
3588 assert!(publication.text_blocks.is_empty());
3589 }
3590
3591 #[test]
3592 fn layout_validation_rejects_unsupported_contract_version() {
3593 let score = score_with_measures(1);
3594 let mut result =
3595 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
3596 result.contract_version = PRINT_LAYOUT_CONTRACT_VERSION - 1;
3597
3598 assert_eq!(
3599 result.validate(),
3600 Err(PrintLayoutError::UnsupportedContractVersion {
3601 found: PRINT_LAYOUT_CONTRACT_VERSION - 1,
3602 })
3603 );
3604 }
3605
3606 #[test]
3607 fn paginates_rows_and_preserves_measure_indices() {
3608 let score = score_with_measures(5);
3609 let result = compute_print_layout(
3610 &score,
3611 &PrintConfig {
3612 measures_per_system: 2,
3613 systems_per_page: Some(2),
3614 ..PrintConfig::default()
3615 },
3616 )
3617 .expect("valid print config");
3618 assert_eq!(result.pages.len(), 2);
3619 assert_eq!(
3620 result.pages[0]
3621 .systems
3622 .iter()
3623 .map(|s| s.measure_indices.clone())
3624 .collect::<Vec<_>>(),
3625 vec![vec![0, 1], vec![2, 3]]
3626 );
3627 assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
3628 assert_eq!(result.pages[1].systems[0].page_index, 1);
3629 assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
3630 assert_eq!(
3631 result.pages[1].systems[0].break_reason,
3632 BreakReason::EndOfScore
3633 );
3634 assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
3635 }
3636
3637 #[test]
3638 fn forced_page_break_starts_next_system_on_next_page() {
3639 let mut score = score_with_measures(3);
3640 score.parts[0].staves[0].measures[0].page_break = true;
3641 let result = compute_print_layout(
3642 &score,
3643 &PrintConfig {
3644 measures_per_system: 3,
3645 systems_per_page: Some(8),
3646 ..PrintConfig::default()
3647 },
3648 )
3649 .expect("valid print config");
3650 assert_eq!(result.pages.len(), 2);
3651 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3652 assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
3653 assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
3654 assert_eq!(
3655 result.pages[0].systems[0].break_reason,
3656 BreakReason::ExplicitPageBreak
3657 );
3658 }
3659
3660 #[test]
3661 fn keep_together_range_is_not_split_across_systems() {
3662 let score = score_with_measures(5);
3663 let result = compute_print_layout(
3664 &score,
3665 &PrintConfig {
3666 measures_per_system: 3,
3667 systems_per_page: Some(8),
3668 keep_together: vec![KeepTogetherRange {
3669 first_measure: 1,
3670 last_measure: 2,
3671 }],
3672 ..PrintConfig::default()
3673 },
3674 )
3675 .expect("valid keep-together range");
3676 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3677 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
3678 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3, 4]);
3679 }
3680
3681 #[test]
3682 fn first_system_measure_capacity_is_preserved_in_print_layout() {
3683 let score = score_with_measures(5);
3684 let result = compute_print_layout(
3685 &score,
3686 &PrintConfig {
3687 measures_per_system: 3,
3688 first_system_measures: Some(1),
3689 systems_per_page: Some(8),
3690 ..PrintConfig::default()
3691 },
3692 )
3693 .expect("valid first-system capacity");
3694 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3695 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3696 assert_eq!(result.pages[0].systems[2].measure_indices, vec![4]);
3697 }
3698
3699 #[test]
3700 fn pickup_policy_isolates_a_partial_first_measure() {
3701 let mut score = score_with_measures(4);
3702 score.parts[0].staves[0].measures[0].voices[0] =
3703 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3704 let result = compute_print_layout(
3705 &score,
3706 &PrintConfig {
3707 measures_per_system: 3,
3708 pickup_policy: PickupPolicy::DetectFirstMeasure,
3709 systems_per_page: Some(8),
3710 ..PrintConfig::default()
3711 },
3712 )
3713 .expect("valid pickup policy");
3714 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
3715 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2, 3]);
3716 }
3717
3718 #[test]
3719 fn pickup_policy_auto_keeps_a_partial_first_measure_with_following_music() {
3720 let mut score = score_with_measures(4);
3721 score.parts[0].staves[0].measures[0].voices[0] =
3722 vec![acorde_core::Note::rest(acorde_core::Duration::Quarter)];
3723 let result = compute_print_layout(
3724 &score,
3725 &PrintConfig {
3726 measures_per_system: 3,
3727 systems_per_page: Some(8),
3728 ..PrintConfig::default()
3729 },
3730 )
3731 .expect("valid automatic pickup policy");
3732 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1, 2]);
3733 assert_eq!(result.pages[0].systems[1].measure_indices, vec![3]);
3734 }
3735
3736 #[test]
3737 fn page_validation_rejects_content_origin_past_paper_edge() {
3738 let score = score_with_measures(1);
3739 let mut result = compute_print_layout(&score, &PrintConfig::default()).unwrap();
3740 result.pages[0].content_left_mm = result.pages[0].width_mm;
3741 assert_eq!(
3742 result.validate(),
3743 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
3744 );
3745 }
3746
3747 #[test]
3748 fn system_exposes_physical_span_for_multi_rest_slot() {
3749 let mut score = score_with_measures(6);
3750 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3751 let result = compute_print_layout(&score, &PrintConfig::default())
3752 .expect("valid multi-rest print layout");
3753 assert_eq!(
3754 result.pages[0].systems[0].measure_spans[1],
3755 MeasureSpan {
3756 first_measure: 1,
3757 last_measure: 3,
3758 }
3759 );
3760 }
3761
3762 #[test]
3763 fn multirest_width_drives_system_breaking_without_splitting() {
3764 let mut score = score_with_measures(5);
3765 score.parts[0].staves[0].measures[1].multi_rest_count = Some(3);
3766 let result = compute_print_layout(
3767 &score,
3768 &PrintConfig {
3769 measures_per_system: 2,
3770 pickup_policy: PickupPolicy::Preserve,
3771 systems_per_page: Some(8),
3772 ..PrintConfig::default()
3773 },
3774 )
3775 .expect("valid multi-rest pagination");
3776 assert_eq!(
3777 result.pages[0]
3778 .systems
3779 .iter()
3780 .map(|system| system.measure_indices.clone())
3781 .collect::<Vec<_>>(),
3782 vec![vec![0], vec![1], vec![2, 3], vec![4]]
3783 );
3784 assert_eq!(
3785 result.pages[0].systems[1].measure_spans[0],
3786 MeasureSpan {
3787 first_measure: 1,
3788 last_measure: 3,
3789 }
3790 );
3791 }
3792
3793 #[test]
3794 fn system_exposes_cross_system_span_segments() {
3795 let mut score = score_with_measures(4);
3796 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3797 start.slur_start = true;
3798 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3799 end.slur_end = true;
3800 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3801 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3802 let result = compute_print_layout(
3803 &score,
3804 &PrintConfig {
3805 measures_per_system: 2,
3806 pickup_policy: PickupPolicy::Preserve,
3807 systems_per_page: Some(8),
3808 ..PrintConfig::default()
3809 },
3810 )
3811 .expect("valid cross-system span layout");
3812 assert_eq!(
3813 result.pages[0].systems[0].span_segments,
3814 vec![SpanSegment {
3815 span_index: 0,
3816 starts_here: true,
3817 ends_here: false,
3818 }]
3819 );
3820 assert_eq!(
3821 result.pages[0].systems[1].span_segments,
3822 vec![SpanSegment {
3823 span_index: 0,
3824 starts_here: false,
3825 ends_here: true,
3826 }]
3827 );
3828 }
3829
3830 #[test]
3831 fn system_exposes_repeat_volta_navigation_and_rehearsal_marks() {
3832 let mut score = score_with_measures(4);
3833 let measures = &mut score.parts[0].staves[0].measures;
3834 measures[0].barline_right = Barline::RepeatEnd;
3835 measures[1].barline_left = Barline::RepeatStart;
3836 measures[2].volta = Some(acorde_core::VoltaBracket {
3837 number: 1,
3838 kind: "begin".to_string(),
3839 numbers: Vec::new(),
3840 });
3841 measures[2].navigation = Some("ToCoda".to_string());
3842 measures[2].rehearsal = Some("B".to_string());
3843 let result = compute_print_layout(
3844 &score,
3845 &PrintConfig {
3846 measures_per_system: 2,
3847 systems_per_page: Some(8),
3848 ..PrintConfig::default()
3849 },
3850 )
3851 .expect("valid measure mark layout");
3852 assert_eq!(
3853 result.pages[0].systems[0].measure_marks,
3854 vec![
3855 MeasureMark {
3856 measure_index: 0,
3857 repeat_start: false,
3858 repeat_end: true,
3859 volta_number: None,
3860 volta_kind: None,
3861 navigation: None,
3862 rehearsal: None,
3863 text_annotations: vec![],
3864 },
3865 MeasureMark {
3866 measure_index: 1,
3867 repeat_start: true,
3868 repeat_end: false,
3869 volta_number: None,
3870 volta_kind: None,
3871 navigation: None,
3872 rehearsal: None,
3873 text_annotations: vec![],
3874 },
3875 ]
3876 );
3877 assert_eq!(
3878 result.pages[0].systems[1].measure_marks,
3879 vec![MeasureMark {
3880 measure_index: 2,
3881 repeat_start: false,
3882 repeat_end: false,
3883 volta_number: Some(1),
3884 volta_kind: Some("begin".to_string()),
3885 navigation: Some("ToCoda".to_string()),
3886 rehearsal: Some("B".to_string()),
3887 text_annotations: vec![
3888 acorde_core::StyledText {
3889 position_beats: None,
3890 style: acorde_core::TextStyle::RehearsalMark,
3891 text: "B".to_string(),
3892 placement: None,
3893 offset_x: None,
3894 offset_y: None,
3895 relative_x: None,
3896 relative_y: None,
3897 },
3898 acorde_core::StyledText {
3899 position_beats: None,
3900 style: acorde_core::TextStyle::Generic,
3901 text: "ToCoda".to_string(),
3902 placement: None,
3903 offset_x: None,
3904 offset_y: None,
3905 relative_x: None,
3906 relative_y: None,
3907 },
3908 ],
3909 }]
3910 );
3911 }
3912
3913 #[test]
3914 fn system_exposes_explicit_measure_text_without_legacy_fields() {
3915 let mut score = score_with_measures(1);
3916 score.parts[0].staves[0].measures[0]
3917 .texts
3918 .push(acorde_core::StyledText {
3919 position_beats: None,
3920 style: acorde_core::TextStyle::Expression,
3921 text: "dolce".to_string(),
3922 placement: Some("above".to_string()),
3923 offset_x: Some(2.0),
3924 offset_y: Some(-1.0),
3925 relative_x: None,
3926 relative_y: None,
3927 });
3928 let result = compute_print_layout(&score, &PrintConfig::default())
3929 .expect("valid explicit measure text layout");
3930 let annotations = &result.pages[0].systems[0].measure_marks[0].text_annotations;
3931 assert_eq!(annotations.len(), 1);
3932 assert_eq!(annotations[0].style, acorde_core::TextStyle::Expression);
3933 assert_eq!(annotations[0].text, "dolce");
3934 assert_eq!(annotations[0].placement.as_deref(), Some("above"));
3935 assert_eq!(annotations[0].offset_x, Some(2.0));
3936 assert_eq!(annotations[0].offset_y, Some(-1.0));
3937 }
3938
3939 #[test]
3940 fn page_aggregates_cross_system_span_ownership() {
3941 let mut score = score_with_measures(4);
3942 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3943 start.slur_start = true;
3944 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3945 end.slur_end = true;
3946 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3947 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3948 let result = compute_print_layout(
3949 &score,
3950 &PrintConfig {
3951 measures_per_system: 2,
3952 pickup_policy: PickupPolicy::Preserve,
3953 systems_per_page: Some(1),
3954 ..PrintConfig::default()
3955 },
3956 )
3957 .expect("valid page span layout");
3958 assert_eq!(
3959 result.pages[0].span_segments,
3960 vec![PageSpanSegment {
3961 span_index: 0,
3962 starts_here: true,
3963 ends_here: false,
3964 }]
3965 );
3966 assert_eq!(
3967 result.pages[1].span_segments,
3968 vec![PageSpanSegment {
3969 span_index: 0,
3970 starts_here: false,
3971 ends_here: true,
3972 }]
3973 );
3974 }
3975
3976 #[test]
3977 fn page_artifact_measure_span_borrows_system_spans() {
3978 let mut score = score_with_measures(4);
3979 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
3980 start.slur_start = true;
3981 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
3982 end.slur_end = true;
3983 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
3984 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
3985 let result = compute_print_layout(
3986 &score,
3987 &PrintConfig {
3988 measures_per_system: 2,
3989 pickup_policy: PickupPolicy::Preserve,
3990 systems_per_page: Some(1),
3991 ..PrintConfig::default()
3992 },
3993 )
3994 .expect("valid page artifact");
3995 let first = result
3996 .page(PageAddress { page_index: 0 })
3997 .expect("first page");
3998 assert_eq!(
3999 first.measure_span(),
4000 Some(MeasureSpan {
4001 first_measure: 0,
4002 last_measure: 1,
4003 })
4004 );
4005 assert!(first.has_span_continuation());
4006 assert!(result.page(PageAddress { page_index: 99 }).is_none());
4007 assert!(result.validate().is_ok());
4008 }
4009
4010 #[test]
4011 fn export_page_artifacts_reports_host_glyph_resource_requirement() {
4012 let result = compute_print_layout(
4013 &score_with_measures(1),
4014 &PrintConfig {
4015 glyph_resources: GlyphResourcePolicy::HostProvided("licensed-font-v1".into()),
4016 ..PrintConfig::default()
4017 },
4018 )
4019 .expect("valid host resource policy");
4020
4021 let artifacts = result
4022 .export_page_artifacts()
4023 .expect("host resource requirement is a diagnostic");
4024 assert_eq!(
4025 artifacts[0].diagnostics,
4026 vec![PageArtifactDiagnostic::GlyphResourceRequired]
4027 );
4028 assert_eq!(
4029 artifacts[0].layout.glyph_resources,
4030 GlyphResourcePolicy::HostProvided("licensed-font-v1".into())
4031 );
4032 }
4033
4034 #[test]
4035 fn page_artifact_diagnostics_report_glyph_overflow_sides() {
4036 let result = compute_print_layout(&score_with_measures(1), &PrintConfig::default())
4037 .expect("valid print layout");
4038 let page = &result.pages[0];
4039 assert_eq!(
4040 page.artifact_diagnostics(Some(GlyphExtents {
4041 left_mm: -1.0,
4042 top_mm: -2.0,
4043 right_mm: page.content_width_mm + 3.0,
4044 bottom_mm: page.content_height_mm + 4.0,
4045 })),
4046 vec![PageArtifactDiagnostic::GlyphOverflow {
4047 left: true,
4048 top: true,
4049 right: true,
4050 bottom: true,
4051 }]
4052 );
4053 assert!(page.artifact_diagnostics(None).is_empty());
4054 }
4055
4056 #[test]
4057 fn export_page_artifacts_preserves_order_dimensions_and_continuation_diagnostics() {
4058 let mut score = score_with_measures(4);
4059 let mut start = Note::new(Pitch::new(Step::C, 4), Duration::Quarter);
4060 start.slur_start = true;
4061 let mut end = Note::new(Pitch::new(Step::D, 4), Duration::Quarter);
4062 end.slur_end = true;
4063 score.parts[0].staves[0].measures[0].voices[0] = vec![start];
4064 score.parts[0].staves[0].measures[3].voices[0] = vec![end];
4065 let result = compute_print_layout(
4066 &score,
4067 &PrintConfig {
4068 measures_per_system: 2,
4069 pickup_policy: PickupPolicy::Preserve,
4070 systems_per_page: Some(1),
4071 ..PrintConfig::default()
4072 },
4073 )
4074 .expect("valid print config");
4075
4076 let artifacts = result
4077 .export_page_artifacts()
4078 .expect("valid page artifacts");
4079 assert_eq!(artifacts.len(), 2);
4080 assert_eq!(artifacts[0].address, PageAddress { page_index: 0 });
4081 assert_eq!(artifacts[1].page_index, 1);
4082 assert_eq!(artifacts[0].width_mm, result.pages[0].width_mm);
4083 assert_eq!(artifacts[0].height_mm, result.pages[0].height_mm);
4084 assert_eq!(
4085 artifacts[0].measure_span,
4086 Some(MeasureSpan {
4087 first_measure: 0,
4088 last_measure: 1,
4089 })
4090 );
4091 assert_eq!(
4092 artifacts[0].diagnostics,
4093 vec![PageArtifactDiagnostic::SpanContinuation {
4094 span_index: 0,
4095 starts_here: true,
4096 ends_here: false,
4097 }]
4098 );
4099 assert_eq!(
4100 artifacts[1].diagnostics,
4101 vec![PageArtifactDiagnostic::SpanContinuation {
4102 span_index: 0,
4103 starts_here: false,
4104 ends_here: true,
4105 }]
4106 );
4107 }
4108
4109 #[test]
4110 fn export_page_artifacts_rejects_invalid_serialized_layout() {
4111 let score = score_with_measures(1);
4112 let mut result =
4113 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
4114 result.pages[0].width_mm = f32::NAN;
4115
4116 assert!(matches!(
4117 result.export_page_artifacts(),
4118 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
4119 ));
4120 }
4121
4122 #[test]
4123 fn page_lookup_rejects_mismatched_serialized_address() {
4124 let score = score_with_measures(1);
4125 let mut result =
4126 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
4127 result.pages[0].address = PageAddress { page_index: 7 };
4128
4129 assert!(result.page(PageAddress { page_index: 0 }).is_none());
4130 assert_eq!(
4131 result.validate(),
4132 Err(PrintLayoutError::InvalidPageAddress { page_index: 0 })
4133 );
4134 }
4135
4136 #[test]
4137 fn layout_validation_rejects_mismatched_system_address() {
4138 let score = score_with_measures(2);
4139 let mut result = compute_print_layout(
4140 &score,
4141 &PrintConfig {
4142 measures_per_system: 1,
4143 ..PrintConfig::default()
4144 },
4145 )
4146 .expect("valid print config");
4147 result.pages[0].systems[0].address.index_on_page = 4;
4148
4149 assert_eq!(
4150 result.validate(),
4151 Err(PrintLayoutError::InvalidSystemAddress {
4152 page_index: 0,
4153 index_on_page: 0,
4154 system_index: 0,
4155 })
4156 );
4157 }
4158
4159 #[test]
4160 fn layout_validation_rejects_non_monotonic_page_number() {
4161 let score = score_with_measures(2);
4162 let mut result = compute_print_layout(
4163 &score,
4164 &PrintConfig {
4165 measures_per_system: 1,
4166 page_numbering: PageNumbering::OneBased,
4167 systems_per_page: Some(1),
4168 ..PrintConfig::default()
4169 },
4170 )
4171 .expect("valid print config");
4172 result.pages[1].page_number = Some(1);
4173
4174 assert_eq!(
4175 result.validate(),
4176 Err(PrintLayoutError::InvalidPageNumber { page_index: 1 })
4177 );
4178 }
4179
4180 #[test]
4181 fn layout_validation_rejects_inconsistent_title_page_metadata() {
4182 let score = score_with_measures(1);
4183 let mut result =
4184 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
4185 result.pages[0].publication.is_title_page = true;
4186
4187 assert_eq!(
4188 result.validate(),
4189 Err(PrintLayoutError::InvalidTitlePage { page_index: 0 })
4190 );
4191 }
4192
4193 #[test]
4194 fn layout_validation_rejects_non_finite_page_geometry() {
4195 let score = score_with_measures(1);
4196 let mut result =
4197 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
4198 result.pages[0].width_mm = f32::NAN;
4199
4200 assert_eq!(
4201 result.validate(),
4202 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
4203 );
4204 }
4205
4206 #[test]
4207 fn layout_validation_rejects_non_positive_system_geometry() {
4208 let score = score_with_measures(1);
4209 let mut result =
4210 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
4211 result.pages[0].systems[0].height_mm = 0.0;
4212
4213 assert_eq!(
4214 result.validate(),
4215 Err(PrintLayoutError::InvalidSystemGeometry {
4216 page_index: 0,
4217 index_on_page: 0,
4218 })
4219 );
4220 }
4221
4222 #[test]
4223 fn layout_validation_rejects_systems_past_the_page_or_on_top_of_each_other() {
4224 let score = score_with_measures(2);
4225 let config = PrintConfig {
4226 measures_per_system: 1,
4227 ..PrintConfig::default()
4228 };
4229 let mut result = compute_print_layout(&score, &config).expect("two valid systems");
4230 result.pages[0].systems[1].top_mm = 290.0;
4231 assert_eq!(
4232 result.validate(),
4233 Err(PrintLayoutError::InvalidSystemGeometry {
4234 page_index: 0,
4235 index_on_page: 1,
4236 })
4237 );
4238
4239 result.pages[0].systems[1].top_mm = result.pages[0].systems[0].top_mm + 1.0;
4240 assert_eq!(
4241 result.validate(),
4242 Err(PrintLayoutError::InvalidSystemGeometry {
4243 page_index: 0,
4244 index_on_page: 1,
4245 })
4246 );
4247 }
4248
4249 #[test]
4250 fn layout_validation_rejects_content_larger_than_page() {
4251 let score = score_with_measures(1);
4252 let mut result =
4253 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
4254 result.pages[0].content_width_mm = result.pages[0].width_mm + 1.0;
4255
4256 assert_eq!(
4257 result.validate(),
4258 Err(PrintLayoutError::InvalidPageGeometry { page_index: 0 })
4259 );
4260 }
4261
4262 #[test]
4263 fn layout_validation_rejects_invalid_persisted_publication_metadata() {
4264 let score = score_with_measures(1);
4265 let mut result =
4266 compute_print_layout(&score, &PrintConfig::default()).expect("valid print config");
4267 result.pages[0]
4268 .publication
4269 .image_resources
4270 .push(PublicationImageResource {
4271 resource_key: "../unsafe".into(),
4272 alt_text: "Unsafe resource".into(),
4273 placement: PublicationImagePlacement::EveryPage,
4274 x_mm: 0.0,
4275 y_mm: 0.0,
4276 width_mm: 1.0,
4277 height_mm: 1.0,
4278 });
4279
4280 assert_eq!(
4281 result.validate(),
4282 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
4283 );
4284
4285 result.pages[0].publication.image_resources.clear();
4286 result.pages[0].publication.frames.push(PublicationFrame {
4287 placement: PublicationFramePlacement::EveryPage,
4288 x_mm: 0.0,
4289 y_mm: 0.0,
4290 width_mm: 1.0,
4291 height_mm: 1.0,
4292 stroke_width_mm: f32::NAN,
4293 });
4294 assert_eq!(
4295 result.validate(),
4296 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
4297 );
4298
4299 result.pages[0].publication.frames.clear();
4300 result.pages[0]
4301 .publication
4302 .sections
4303 .push(PublicationSection {
4304 first_measure: 0,
4305 title: " ".into(),
4306 start_on_new_page: false,
4307 });
4308 assert_eq!(
4309 result.validate(),
4310 Err(PrintLayoutError::InvalidPublicationMetadata { page_index: 0 })
4311 );
4312 }
4313
4314 #[test]
4315 fn notation_policy_keeps_volta_range_in_one_system() {
4316 let mut score = score_with_measures(4);
4317 score.parts[0].staves[0].measures[1].volta = Some(acorde_core::VoltaBracket {
4318 number: 1,
4319 kind: "begin".to_string(),
4320 numbers: Vec::new(),
4321 });
4322 score.parts[0].staves[0].measures[2].volta = Some(acorde_core::VoltaBracket {
4323 number: 1,
4324 kind: "end".to_string(),
4325 numbers: Vec::new(),
4326 });
4327 let result = compute_print_layout(
4328 &score,
4329 &PrintConfig {
4330 measures_per_system: 2,
4331 systems_per_page: Some(8),
4332 notation_break_policy: NotationBreakPolicy::KeepVoltaTogether,
4333 ..PrintConfig::default()
4334 },
4335 )
4336 .expect("valid volta-preserving layout");
4337 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
4338 assert_eq!(result.pages[0].systems[1].measure_indices, vec![1, 2]);
4339 assert_eq!(result.pages[0].systems[2].measure_indices, vec![3]);
4340 }
4341
4342 #[test]
4343 fn notation_policy_keeps_repeat_section_on_one_page() {
4344 let mut score = score_with_measures(5);
4345 score.parts[0].staves[0].measures[2].barline_left = Barline::RepeatStart;
4346 score.parts[0].staves[0].measures[4].barline_right = Barline::RepeatEnd;
4347 let result = compute_print_layout(
4348 &score,
4349 &PrintConfig {
4350 measures_per_system: 2,
4351 systems_per_page: Some(2),
4352 notation_break_policy: NotationBreakPolicy::KeepRepeatsTogether,
4353 ..PrintConfig::default()
4354 },
4355 )
4356 .expect("valid repeat-preserving layout");
4357 assert_eq!(result.pages[0].systems.len(), 1);
4358 assert_eq!(result.pages[1].systems.len(), 2);
4359 assert_eq!(
4360 result.pages[1]
4361 .systems
4362 .iter()
4363 .flat_map(|system| system.measure_indices.iter().copied())
4364 .collect::<Vec<_>>(),
4365 vec![2, 3, 4]
4366 );
4367 }
4368
4369 #[test]
4370 fn balance_policy_avoids_single_system_final_page() {
4371 let score = score_with_measures(5);
4372 let result = compute_print_layout(
4373 &score,
4374 &PrintConfig {
4375 measures_per_system: 1,
4376 systems_per_page: Some(4),
4377 final_page_policy: FinalPagePolicy::Balance,
4378 ..PrintConfig::default()
4379 },
4380 )
4381 .expect("valid balanced print config");
4382 assert_eq!(result.pages.len(), 2);
4383 assert_eq!(result.pages[0].systems.len(), 3);
4384 assert_eq!(result.pages[1].systems.len(), 2);
4385 }
4386
4387 #[test]
4388 fn balance_policy_preserves_explicit_page_breaks() {
4389 let mut score = score_with_measures(5);
4390 score.parts[0].staves[0].measures[1].page_break = true;
4391 let result = compute_print_layout(
4392 &score,
4393 &PrintConfig {
4394 measures_per_system: 1,
4395 systems_per_page: Some(4),
4396 final_page_policy: FinalPagePolicy::Balance,
4397 ..PrintConfig::default()
4398 },
4399 )
4400 .expect("valid explicit-break print config");
4401 assert_eq!(result.pages[0].systems.len(), 2);
4402 assert_eq!(result.pages[1].systems.len(), 3);
4403 }
4404
4405 #[test]
4406 fn keep_together_rejects_ranges_larger_than_system_capacity() {
4407 let score = score_with_measures(4);
4408 let error = compute_print_layout(
4409 &score,
4410 &PrintConfig {
4411 measures_per_system: 2,
4412 keep_together: vec![KeepTogetherRange {
4413 first_measure: 0,
4414 last_measure: 2,
4415 }],
4416 ..PrintConfig::default()
4417 },
4418 )
4419 .expect_err("range must fit in one system");
4420 assert_eq!(error, PrintLayoutError::KeepTogetherExceedsSystemCapacity);
4421 }
4422
4423 #[test]
4424 fn keep_together_rejects_explicit_break_inside_range() {
4425 let mut score = score_with_measures(4);
4426 score.parts[0].staves[0].measures[1].system_break = true;
4427 let error = compute_print_layout(
4428 &score,
4429 &PrintConfig {
4430 measures_per_system: 3,
4431 keep_together: vec![KeepTogetherRange {
4432 first_measure: 0,
4433 last_measure: 2,
4434 }],
4435 ..PrintConfig::default()
4436 },
4437 )
4438 .expect_err("explicit break must win");
4439 assert_eq!(
4440 error,
4441 PrintLayoutError::KeepTogetherConflictsWithExplicitBreak
4442 );
4443 }
4444
4445 #[test]
4446 fn rejects_margins_that_leave_no_page_area() {
4447 let score = score_with_measures(1);
4448 let error = compute_print_layout(
4449 &score,
4450 &PrintConfig {
4451 margin_left_mm: 200.0,
4452 ..PrintConfig::default()
4453 },
4454 )
4455 .expect_err("invalid page area");
4456 assert_eq!(error, PrintLayoutError::NoUsablePageArea);
4457 }
4458
4459 #[test]
4460 fn rejects_system_height_or_requested_capacity_that_clips_the_page() {
4461 let score = score_with_measures(2);
4462 for config in [
4463 PrintConfig {
4464 system_height_mm: 266.0,
4465 ..PrintConfig::default()
4466 },
4467 PrintConfig {
4468 system_height_mm: 140.0,
4469 systems_per_page: Some(2),
4470 ..PrintConfig::default()
4471 },
4472 ] {
4473 assert_eq!(
4474 compute_print_layout(&score, &config),
4475 Err(PrintLayoutError::InvalidSystemHeight)
4476 );
4477 }
4478 }
4479
4480 #[test]
4481 fn multi_staff_systems_reserve_clearance_before_paginating() {
4482 let mut score = score_with_measures(10);
4483 let mut lower = Staff::new(Clef::Bass);
4484 lower.measures = vec![Measure::empty(4, 4); 10];
4485 score.parts[0].staves.push(lower);
4486 let config = PrintConfig {
4487 measures_per_system: 1,
4488 ..PrintConfig::default()
4489 };
4490 let layout = compute_print_layout(&score, &config).expect("piano print layout");
4491 assert_eq!(layout.pages[0].systems[0].height_mm, 50.0);
4492 assert_eq!(layout.pages[0].systems.len(), 5);
4493 assert_eq!(layout.pages[1].systems.len(), 5);
4494 assert_eq!(layout.pages[0].systems[1].top_mm, 66.0);
4495
4496 let fixed = compute_print_layout(
4497 &score,
4498 &PrintConfig {
4499 system_height_policy: SystemHeightPolicy::Fixed,
4500 ..config.clone()
4501 },
4502 )
4503 .expect("caller-measured fixed-height layout");
4504 assert_eq!(fixed.pages[0].systems[0].height_mm, 24.0);
4505 assert_eq!(fixed.pages.len(), 1);
4506
4507 assert_eq!(
4508 compute_print_layout(
4509 &score,
4510 &PrintConfig {
4511 systems_per_page: Some(7),
4512 ..config
4513 }
4514 ),
4515 Err(PrintLayoutError::InvalidSystemHeight)
4516 );
4517 }
4518
4519 #[test]
4520 fn grand_staff_with_below_direction_reserves_text_lane() {
4521 let mut score = score_with_measures(1);
4522 let mut lower = Staff::new(Clef::Bass);
4523 lower.measures = vec![Measure::empty(4, 4)];
4524 score.parts[0].staves.push(lower);
4525 score.parts[0].staves[0].measures[0]
4526 .texts
4527 .push(acorde_core::StyledText {
4528 position_beats: None,
4529 style: acorde_core::TextStyle::Expression,
4530 text: "poco cresc.".to_owned(),
4531 placement: Some("below".to_owned()),
4532 offset_x: None,
4533 offset_y: None,
4534 relative_x: None,
4535 relative_y: None,
4536 });
4537 let layout = compute_print_layout(&score, &PrintConfig::default()).unwrap();
4538 assert_eq!(layout.pages[0].systems[0].height_mm, 60.0);
4539 }
4540
4541 #[test]
4542 fn extracted_single_staff_uses_its_own_system_clearance() {
4543 let mut score = score_with_measures(2);
4544 let mut second = Part::new("Violin", "Vln.");
4545 let mut staff = Staff::new(Clef::Treble);
4546 staff.measures = vec![Measure::empty(4, 4); 2];
4547 second.staves.push(staff);
4548 score.parts.push(second);
4549 let full = compute_print_layout(&score, &PrintConfig::default()).unwrap();
4550 assert_eq!(full.pages[0].systems[0].height_mm, 50.0);
4551 let part = compute_print_layout(
4552 &score,
4553 &PrintConfig {
4554 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
4555 ..PrintConfig::default()
4556 },
4557 )
4558 .unwrap();
4559 assert_eq!(part.pages[0].systems[0].height_mm, 24.0);
4560 }
4561
4562 #[test]
4563 fn safe_area_reduces_content_and_bleed_is_exposed() {
4564 let score = score_with_measures(1);
4565 let result = compute_print_layout(
4566 &score,
4567 &PrintConfig {
4568 bleed_top_mm: 3.0,
4569 bleed_right_mm: 3.0,
4570 bleed_bottom_mm: 3.0,
4571 bleed_left_mm: 3.0,
4572 safe_top_mm: 5.0,
4573 safe_right_mm: 6.0,
4574 safe_bottom_mm: 7.0,
4575 safe_left_mm: 8.0,
4576 ..PrintConfig::default()
4577 },
4578 )
4579 .expect("valid print config");
4580 let page = &result.pages[0];
4581 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4582 assert_eq!(page.bleed_left_mm, 3.0);
4583 assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
4584 assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
4585 assert_eq!(page.systems[0].top_mm, 21.0);
4586 }
4587
4588 #[test]
4589 fn scale_changes_system_height_and_page_capacity() {
4590 let score = score_with_measures(10);
4591 let result = compute_print_layout(
4592 &score,
4593 &PrintConfig {
4594 scale: 2.0,
4595 measures_per_system: 1,
4596 systems_per_page: None,
4597 ..PrintConfig::default()
4598 },
4599 )
4600 .expect("valid print config");
4601 assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
4602 assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
4603 assert_eq!(result.pages.len(), 2);
4604 }
4605
4606 #[test]
4607 fn rejects_non_positive_scale() {
4608 let score = score_with_measures(1);
4609 let error = compute_print_layout(
4610 &score,
4611 &PrintConfig {
4612 scale: 0.0,
4613 ..PrintConfig::default()
4614 },
4615 )
4616 .expect_err("invalid scale");
4617 assert_eq!(error, PrintLayoutError::InvalidScale);
4618 }
4619
4620 #[test]
4621 fn page_numbering_is_configurable() {
4622 let score = score_with_measures(5);
4623 let numbered = compute_print_layout(
4624 &score,
4625 &PrintConfig {
4626 measures_per_system: 1,
4627 systems_per_page: Some(2),
4628 ..PrintConfig::default()
4629 },
4630 )
4631 .expect("valid print config");
4632 assert_eq!(numbered.pages[0].page_number, Some(1));
4633 assert_eq!(numbered.pages[1].page_number, Some(2));
4634
4635 let unnumbered = compute_print_layout(
4636 &score,
4637 &PrintConfig {
4638 page_numbering: PageNumbering::None,
4639 measures_per_system: 1,
4640 systems_per_page: Some(2),
4641 ..PrintConfig::default()
4642 },
4643 )
4644 .expect("valid print config");
4645 assert!(
4646 unnumbered
4647 .pages
4648 .iter()
4649 .all(|page| page.page_number.is_none())
4650 );
4651 }
4652
4653 #[test]
4654 fn rejects_invalid_publication_line_height() {
4655 let score = score_with_measures(1);
4656 let error = compute_print_layout(
4657 &score,
4658 &PrintConfig {
4659 publication: PublicationConfig {
4660 line_height_mm: 0.0,
4661 ..PublicationConfig::default()
4662 },
4663 ..PrintConfig::default()
4664 },
4665 )
4666 .expect_err("invalid publication line height");
4667 assert_eq!(error, PrintLayoutError::InvalidPublicationLineHeight);
4668 }
4669
4670 #[test]
4671 fn publication_image_resources_are_safe_and_page_scoped() {
4672 let score = score_with_measures(1);
4673 let config = PrintConfig {
4674 publication: PublicationConfig {
4675 title_page: true,
4676 image_resources: vec![
4677 PublicationImageResource {
4678 resource_key: "cover-art-v1".into(),
4679 alt_text: "Cover art".into(),
4680 placement: PublicationImagePlacement::TitlePage,
4681 x_mm: 10.0,
4682 y_mm: 10.0,
4683 width_mm: 30.0,
4684 height_mm: 20.0,
4685 },
4686 PublicationImageResource {
4687 resource_key: "publisher-mark".into(),
4688 alt_text: "Publisher mark".into(),
4689 placement: PublicationImagePlacement::MusicPages,
4690 x_mm: 160.0,
4691 y_mm: 10.0,
4692 width_mm: 20.0,
4693 height_mm: 10.0,
4694 },
4695 ],
4696 ..PublicationConfig::default()
4697 },
4698 ..PrintConfig::default()
4699 };
4700 let result = compute_print_layout(&score, &config).expect("valid image resources");
4701 assert_eq!(result.pages[0].publication.image_resources.len(), 1);
4702 assert_eq!(
4703 result.pages[0].publication.image_resources[0].resource_key,
4704 "cover-art-v1"
4705 );
4706 assert_eq!(result.pages[1].publication.image_resources.len(), 1);
4707 assert_eq!(
4708 result.pages[1].publication.image_resources[0].resource_key,
4709 "publisher-mark"
4710 );
4711
4712 let invalid = PrintConfig {
4713 publication: PublicationConfig {
4714 image_resources: vec![PublicationImageResource {
4715 resource_key: "../secret.png".into(),
4716 alt_text: "Unsafe path".into(),
4717 placement: PublicationImagePlacement::EveryPage,
4718 x_mm: 0.0,
4719 y_mm: 0.0,
4720 width_mm: 1.0,
4721 height_mm: 1.0,
4722 }],
4723 ..PublicationConfig::default()
4724 },
4725 ..PrintConfig::default()
4726 };
4727 assert_eq!(
4728 compute_print_layout(&score, &invalid),
4729 Err(PrintLayoutError::InvalidPublicationImageResource { index: 0 })
4730 );
4731 }
4732
4733 #[test]
4734 fn publication_frames_are_validated_and_page_scoped() {
4735 let score = score_with_measures(1);
4736 let config = PrintConfig {
4737 publication: PublicationConfig {
4738 title_page: true,
4739 frames: vec![
4740 PublicationFrame {
4741 placement: PublicationFramePlacement::TitlePage,
4742 x_mm: 8.0,
4743 y_mm: 8.0,
4744 width_mm: 194.0,
4745 height_mm: 281.0,
4746 stroke_width_mm: 0.4,
4747 },
4748 PublicationFrame {
4749 placement: PublicationFramePlacement::MusicPages,
4750 x_mm: 12.0,
4751 y_mm: 12.0,
4752 width_mm: 186.0,
4753 height_mm: 273.0,
4754 stroke_width_mm: 0.3,
4755 },
4756 ],
4757 ..PublicationConfig::default()
4758 },
4759 ..PrintConfig::default()
4760 };
4761 let result = compute_print_layout(&score, &config).expect("valid frames");
4762 assert_eq!(result.pages[0].publication.frames.len(), 1);
4763 assert_eq!(result.pages[1].publication.frames.len(), 1);
4764 assert_eq!(result.pages[0].publication.frames[0].stroke_width_mm, 0.4);
4765
4766 let invalid = PrintConfig {
4767 publication: PublicationConfig {
4768 frames: vec![PublicationFrame {
4769 placement: PublicationFramePlacement::EveryPage,
4770 x_mm: 0.0,
4771 y_mm: 0.0,
4772 width_mm: 211.0,
4773 height_mm: 297.0,
4774 stroke_width_mm: 0.0,
4775 }],
4776 ..PublicationConfig::default()
4777 },
4778 ..PrintConfig::default()
4779 };
4780 assert_eq!(
4781 compute_print_layout(&score, &invalid),
4782 Err(PrintLayoutError::InvalidPublicationFrame { index: 0 })
4783 );
4784 }
4785
4786 #[test]
4787 fn publication_spacers_consume_page_height_and_follow_systems() {
4788 let score = score_with_measures(4);
4789 let config = PrintConfig {
4790 measures_per_system: 4,
4791 systems_per_page: Some(2),
4792 system_height_mm: 130.0,
4793 publication: PublicationConfig {
4794 spacers: vec![PublicationSpacer {
4795 before_measure: 2,
4796 height_mm: 20.0,
4797 }],
4798 ..PublicationConfig::default()
4799 },
4800 ..PrintConfig::default()
4801 };
4802 let result = compute_print_layout(&score, &config).expect("valid publication spacer");
4803 assert_eq!(result.pages.len(), 2);
4804 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4805 assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4806 assert_eq!(result.pages[1].systems[0].top_mm, 36.0);
4807 assert_eq!(result.pages[1].publication.spacers.len(), 1);
4808 assert_eq!(result.pages[1].publication.spacers[0].before_measure, 2);
4809 result
4810 .validate()
4811 .expect("persisted spacer metadata is valid");
4812
4813 let invalid = PrintConfig {
4814 system_height_mm: 260.0,
4815 publication: PublicationConfig {
4816 spacers: vec![PublicationSpacer {
4817 before_measure: 0,
4818 height_mm: 10.0,
4819 }],
4820 ..PublicationConfig::default()
4821 },
4822 ..PrintConfig::default()
4823 };
4824 assert_eq!(
4825 compute_print_layout(&score, &invalid),
4826 Err(PrintLayoutError::InvalidPublicationSpacer { index: 0 })
4827 );
4828 }
4829
4830 #[test]
4831 fn publication_sections_split_systems_and_can_start_a_page() {
4832 let score = score_with_measures(5);
4833 let config = PrintConfig {
4834 measures_per_system: 4,
4835 systems_per_page: Some(2),
4836 publication: PublicationConfig {
4837 sections: vec![PublicationSection {
4838 first_measure: 2,
4839 title: "Second movement".into(),
4840 start_on_new_page: true,
4841 }],
4842 ..PublicationConfig::default()
4843 },
4844 ..PrintConfig::default()
4845 };
4846 let result = compute_print_layout(&score, &config).expect("valid publication section");
4847 assert_eq!(result.pages.len(), 2);
4848 assert_eq!(result.pages[0].break_reason, BreakReason::SectionBreak);
4849 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0, 1]);
4850 assert_eq!(result.pages[1].systems[0].measure_indices, vec![2, 3]);
4851 assert_eq!(result.pages[1].publication.sections.len(), 1);
4852 assert_eq!(
4853 result.pages[1].publication.sections[0].title,
4854 "Second movement"
4855 );
4856
4857 let invalid = PrintConfig {
4858 publication: PublicationConfig {
4859 sections: vec![PublicationSection {
4860 first_measure: 5,
4861 title: "Outside score".into(),
4862 start_on_new_page: false,
4863 }],
4864 ..PublicationConfig::default()
4865 },
4866 ..PrintConfig::default()
4867 };
4868 assert_eq!(
4869 compute_print_layout(&score, &invalid),
4870 Err(PrintLayoutError::InvalidPublicationSection { index: 0 })
4871 );
4872 }
4873
4874 #[test]
4875 fn rejects_empty_host_glyph_resource_key() {
4876 let score = score_with_measures(1);
4877 let error = compute_print_layout(
4878 &score,
4879 &PrintConfig {
4880 glyph_resources: GlyphResourcePolicy::HostProvided(" ".into()),
4881 ..PrintConfig::default()
4882 },
4883 )
4884 .expect_err("empty host resource key");
4885 assert_eq!(error, PrintLayoutError::InvalidGlyphResourceKey);
4886 }
4887
4888 #[test]
4889 fn glyph_resource_descriptor_requires_reproducible_metadata() {
4890 let descriptor = GlyphResourceDescriptor {
4891 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4892 resource_key: "publisher-font-v2".into(),
4893 metrics_contract_version: 1,
4894 license_notice: "licensed by publisher".into(),
4895 fallback: GlyphFallbackPolicy::UseResource("acorde-vector-glyphs-v1".into()),
4896 };
4897 assert_eq!(descriptor.validate(), Ok(()));
4898 }
4899
4900 #[test]
4901 fn glyph_resource_descriptor_rejects_missing_license_and_self_fallback() {
4902 let missing_license = GlyphResourceDescriptor {
4903 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4904 resource_key: "font".into(),
4905 metrics_contract_version: 1,
4906 license_notice: " ".into(),
4907 fallback: GlyphFallbackPolicy::Reject,
4908 };
4909 assert_eq!(
4910 missing_license.validate(),
4911 Err(GlyphResourceDescriptorError::EmptyLicenseNotice)
4912 );
4913
4914 let self_fallback = GlyphResourceDescriptor {
4915 contract_version: GLYPH_RESOURCE_CONTRACT_VERSION,
4916 resource_key: "font".into(),
4917 metrics_contract_version: 1,
4918 license_notice: "licensed".into(),
4919 fallback: GlyphFallbackPolicy::UseResource("font".into()),
4920 };
4921 assert_eq!(
4922 self_fallback.validate(),
4923 Err(GlyphResourceDescriptorError::FallbackMatchesPrimary)
4924 );
4925 }
4926
4927 #[test]
4928 fn print_color_and_crop_policies_are_exposed_per_page() {
4929 let score = score_with_measures(1);
4930 let result = compute_print_layout(
4931 &score,
4932 &PrintConfig {
4933 color_policy: PrintColorPolicy::Preserve,
4934 crop_mark_policy: CropMarkPolicy::BleedEdges,
4935 ..PrintConfig::default()
4936 },
4937 )
4938 .expect("valid print config");
4939 let page = &result.pages[0];
4940 assert_eq!(result.contract_version, PRINT_LAYOUT_CONTRACT_VERSION);
4941 assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
4942 assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
4943 }
4944
4945 #[test]
4946 fn glyph_resource_policy_is_exposed_per_page() {
4947 let score = score_with_measures(1);
4948 let result = compute_print_layout(
4949 &score,
4950 &PrintConfig {
4951 glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
4952 ..PrintConfig::default()
4953 },
4954 )
4955 .expect("valid print config");
4956 assert_eq!(
4957 result.pages[0].glyph_resources,
4958 GlyphResourcePolicy::HostProvided("music-font-v1".into())
4959 );
4960 }
4961
4962 #[test]
4963 fn publication_metadata_is_deterministic_and_page_scoped() {
4964 let mut score = score_with_measures(3);
4965 score.metadata.title = "Suite".into();
4966 score.metadata.movement_title = "I. Prelude".into();
4967 score.metadata.composer = "Composer".into();
4968 score.metadata.copyright = "© 2026 Composer".into();
4969 score.metadata.lyricist = "Lyricist".into();
4970 score.metadata.copyright = "Copyright".into();
4971 score.parts.push(Part::new("Strings", "Str."));
4972 score.part_groups.push(PartGroup {
4973 first_part: 0,
4974 last_part: 1,
4975 symbol: PartGroupSymbol::Bracket,
4976 barlines_connect: true,
4977 });
4978 for (index, measure) in score.parts[0].staves[0].measures.iter_mut().enumerate() {
4979 measure.number = (index + 1) as u32;
4980 }
4981 let result = compute_print_layout(
4982 &score,
4983 &PrintConfig {
4984 measures_per_system: 2,
4985 systems_per_page: Some(1),
4986 publication: PublicationConfig {
4987 running_title: Some("Suite — Composer".into()),
4988 header_text: Some("Suite".into()),
4989 footer_text: Some("Copyright".into()),
4990 page_number_in_footer: true,
4991 header_alignment: PublicationTextAlignment::Center,
4992 footer_alignment: PublicationTextAlignment::Right,
4993 ..PublicationConfig::default()
4994 },
4995 ..PrintConfig::default()
4996 },
4997 )
4998 .expect("valid print config");
4999 assert_eq!(result.pages[0].publication.title, "Suite");
5000 assert_eq!(
5001 result.pages[0].publication.running_title.as_deref(),
5002 Some("Suite — Composer")
5003 );
5004 assert_eq!(result.pages[0].publication.measure_numbers, vec![1, 2]);
5005 assert_eq!(result.pages[1].publication.measure_numbers, vec![3]);
5006 assert_eq!(result.pages[0].publication.part_labels[0].name, "Piano");
5007 assert_eq!(result.pages[0].publication.part_groups.len(), 1);
5008 assert_eq!(
5009 result.pages[0].publication.part_groups[0].symbol,
5010 PartGroupSymbol::Bracket
5011 );
5012 assert_eq!(result.pages[0].publication.text_blocks.len(), 3);
5013 assert_eq!(
5014 result.pages[0].publication.text_blocks[0].role,
5015 PublicationTextRole::Header
5016 );
5017 assert_eq!(result.pages[0].publication.text_blocks[0].x_mm, 14.0);
5018 assert_eq!(result.pages[0].publication.text_blocks[0].width_mm, 182.0);
5019 assert_eq!(
5020 result.pages[0].publication.text_blocks[1].role,
5021 PublicationTextRole::Footer
5022 );
5023 assert_eq!(result.pages[0].publication.text_blocks[2].text, "1");
5024 assert_eq!(result.pages[0].publication.text_blocks[0].height_mm, 4.0);
5025 assert_eq!(
5026 result.pages[0].publication.text_blocks[0].alignment,
5027 PublicationTextAlignment::Center
5028 );
5029 assert_eq!(
5030 result.pages[0].publication.text_blocks[1].alignment,
5031 PublicationTextAlignment::Right
5032 );
5033 let artifacts = result
5034 .export_page_artifacts()
5035 .expect("publication pages export without host resources");
5036 assert_eq!(artifacts.len(), result.pages.len());
5037 assert_eq!(artifacts[0].layout.publication, result.pages[0].publication);
5038 assert!(
5039 artifacts
5040 .iter()
5041 .all(|artifact| artifact.diagnostics.is_empty())
5042 );
5043 }
5044
5045 #[test]
5046 fn publication_templates_select_odd_even_text_and_can_omit_a_side() {
5047 let score = score_with_measures(3);
5048 let result = compute_print_layout(
5049 &score,
5050 &PrintConfig {
5051 measures_per_system: 1,
5052 systems_per_page: Some(1),
5053 publication: PublicationConfig {
5054 header_text: Some("legacy header".into()),
5055 footer_text: Some("legacy footer".into()),
5056 header_template: PublicationPageTemplate {
5057 odd: Some("Odd header".into()),
5058 even: Some("Even header".into()),
5059 },
5060 footer_template: PublicationPageTemplate {
5061 odd: Some("Odd footer".into()),
5062 even: None,
5063 },
5064 ..PublicationConfig::default()
5065 },
5066 ..PrintConfig::default()
5067 },
5068 )
5069 .expect("valid print layout");
5070 let first_texts: Vec<_> = result.pages[0]
5071 .publication
5072 .text_blocks
5073 .iter()
5074 .map(|block| block.text.as_str())
5075 .collect();
5076 let second_texts: Vec<_> = result.pages[1]
5077 .publication
5078 .text_blocks
5079 .iter()
5080 .map(|block| block.text.as_str())
5081 .collect();
5082 assert_eq!(first_texts, vec!["Odd header", "Odd footer"]);
5083 assert_eq!(second_texts, vec!["Even header"]);
5084 }
5085
5086 #[test]
5087 fn extracted_part_policy_scopes_layout_and_rejects_missing_part() {
5088 let mut score = score_with_measures(2);
5089 let mut part = Part::new("Flute", "Fl.");
5090 let mut staff = Staff::new(Clef::Treble);
5091 staff.measures = vec![Measure::empty(4, 4); 5];
5092 part.staves = vec![staff];
5093 score.parts.push(part);
5094
5095 let extracted = compute_print_layout(
5096 &score,
5097 &PrintConfig {
5098 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 1 },
5099 measures_per_system: 2,
5100 systems_per_page: Some(1),
5101 ..PrintConfig::default()
5102 },
5103 )
5104 .expect("valid extracted part");
5105 assert_eq!(extracted.pages[0].systems[0].measure_indices, vec![0, 1]);
5106 assert_eq!(extracted.pages.len(), 3);
5107 assert_eq!(extracted.pages[0].publication.part_labels[0].name, "Flute");
5108
5109 let error = compute_print_layout(
5110 &score,
5111 &PrintConfig {
5112 part_layout: PartLayoutPolicy::ExtractedPart { part_index: 2 },
5113 ..PrintConfig::default()
5114 },
5115 )
5116 .expect_err("missing extracted part");
5117 assert_eq!(error, PrintLayoutError::InvalidPartIndex);
5118 }
5119
5120 #[test]
5121 fn title_page_is_inserted_without_consuming_music_page_capacity() {
5122 let mut score = score_with_measures(3);
5123 score.texts.push(StyledText {
5124 position_beats: None,
5125 style: TextStyle::Expression,
5126 text: "Dedication".into(),
5127 placement: None,
5128 offset_x: None,
5129 offset_y: None,
5130 relative_x: None,
5131 relative_y: None,
5132 });
5133 score.metadata.title = "Suite".into();
5134 score.metadata.movement_title = "I. Prelude".into();
5135 score.metadata.composer = "Composer".into();
5136 score.metadata.copyright = "© 2026 Composer".into();
5137 let result = compute_print_layout(
5138 &score,
5139 &PrintConfig {
5140 systems_per_page: Some(1),
5141 measures_per_system: 2,
5142 publication: PublicationConfig {
5143 title_page: true,
5144 ..PublicationConfig::default()
5145 },
5146 ..PrintConfig::default()
5147 },
5148 )
5149 .expect("valid title page config");
5150 assert_eq!(result.pages.len(), 3);
5151 assert!(result.pages[0].systems.is_empty());
5152 assert!(result.pages[0].publication.is_title_page);
5153 assert_eq!(result.pages[0].break_reason, BreakReason::TitlePage);
5154 assert_eq!(result.pages[0].page_number, Some(1));
5155 assert_eq!(result.pages[1].page_number, Some(2));
5156 assert_eq!(result.pages[1].systems[0].page_index, 1);
5157 assert!(!result.pages[1].publication.is_title_page);
5158 assert_eq!(result.pages[0].publication.score_texts, score.texts);
5159 assert!(result.validate().is_ok());
5160 assert_eq!(
5161 result.pages[0]
5162 .publication
5163 .text_blocks
5164 .iter()
5165 .map(|block| block.role)
5166 .collect::<Vec<_>>(),
5167 vec![
5168 PublicationTextRole::Title,
5169 PublicationTextRole::Subtitle,
5170 PublicationTextRole::Credit,
5171 PublicationTextRole::Copyright
5172 ]
5173 );
5174 }
5175
5176 #[test]
5177 fn print_presets_are_versioned_and_select_the_expected_scope() {
5178 assert_eq!(PrintPreset::A4Score.schema_version(), 2);
5179 assert_eq!(
5180 PrintPreset::A4Score.config().part_layout,
5181 PartLayoutPolicy::FullScore
5182 );
5183 assert_eq!(
5184 PrintPreset::LetterPart { part_index: 2 }
5185 .config()
5186 .part_layout,
5187 PartLayoutPolicy::ExtractedPart { part_index: 2 }
5188 );
5189 assert_eq!(
5190 PrintPreset::LetterScore.config().paper_size,
5191 PaperSize::Letter
5192 );
5193 assert!(
5194 PrintPreset::A4Score
5195 .config_with_title_page(true)
5196 .publication
5197 .title_page
5198 );
5199 assert!(!PrintPreset::A4Score.config().publication.title_page);
5200 assert_eq!(PRINT_PRESET_SCHEMA_VERSION, 2);
5201 }
5202
5203 #[test]
5204 fn glyph_collision_resolution_is_deterministic_and_priority_aware() {
5205 let metrics = GlyphMetrics {
5206 advance_mm: 4.0,
5207 left_mm: -1.0,
5208 top_mm: -2.0,
5209 width_mm: 2.0,
5210 height_mm: 4.0,
5211 };
5212 let mut placements = vec![
5213 GlyphPlacement {
5214 resource_key: "high".into(),
5215 metrics,
5216 x_mm: 10.0,
5217 y_mm: 20.0,
5218 priority: 10,
5219 },
5220 GlyphPlacement {
5221 resource_key: "low".into(),
5222 metrics,
5223 x_mm: 10.0,
5224 y_mm: 20.0,
5225 priority: 1,
5226 },
5227 ];
5228 let moved = resolve_glyph_collisions(&mut placements, 1.0);
5229 assert_eq!(moved, 1);
5230 assert_eq!(placements[0].y_mm, 20.0);
5231 assert_eq!(placements[1].y_mm, 25.0);
5232 }
5233
5234 #[test]
5235 fn class_aware_collision_resolution_uses_stable_semantic_tie_breakers() {
5236 let metrics = GlyphMetrics {
5237 advance_mm: 4.0,
5238 left_mm: -1.0,
5239 top_mm: -2.0,
5240 width_mm: 2.0,
5241 height_mm: 4.0,
5242 };
5243 let mut placements = vec![
5244 GlyphPlacement {
5245 resource_key: "annotation".into(),
5246 metrics,
5247 x_mm: 10.0,
5248 y_mm: 20.0,
5249 priority: 1,
5250 },
5251 GlyphPlacement {
5252 resource_key: "critical".into(),
5253 metrics,
5254 x_mm: 10.0,
5255 y_mm: 20.0,
5256 priority: 1,
5257 },
5258 ];
5259 let classes = [
5260 GlyphCollisionClass::Annotation,
5261 GlyphCollisionClass::Critical,
5262 ];
5263 assert_eq!(
5264 resolve_glyph_collisions_with_classes(&mut placements, &classes, 1.0),
5265 Ok(1)
5266 );
5267 assert_eq!(placements[0].y_mm, 25.0);
5268 assert_eq!(placements[1].y_mm, 20.0);
5269 assert_eq!(
5270 resolve_glyph_horizontal_collisions_with_classes(
5271 &mut placements,
5272 &[GlyphCollisionClass::Annotation],
5273 1.0,
5274 ),
5275 Err(GlyphPlacementError::CollisionClassCount {
5276 placements: 2,
5277 classes: 1,
5278 })
5279 );
5280 }
5281
5282 #[test]
5283 fn constrained_collision_pass_honors_annotation_escape_lanes() {
5284 let metrics = GlyphMetrics {
5285 advance_mm: 4.0,
5286 left_mm: -1.0,
5287 top_mm: -2.0,
5288 width_mm: 2.0,
5289 height_mm: 4.0,
5290 };
5291 let mut placements = vec![
5292 GlyphPlacement {
5293 resource_key: "notation".into(),
5294 metrics,
5295 x_mm: 10.0,
5296 y_mm: 20.0,
5297 priority: 1,
5298 },
5299 GlyphPlacement {
5300 resource_key: "lyric".into(),
5301 metrics,
5302 x_mm: 10.0,
5303 y_mm: 20.0,
5304 priority: 1,
5305 },
5306 GlyphPlacement {
5307 resource_key: "rehearsal".into(),
5308 metrics,
5309 x_mm: 10.0,
5310 y_mm: 20.0,
5311 priority: 1,
5312 },
5313 GlyphPlacement {
5314 resource_key: "tab".into(),
5315 metrics,
5316 x_mm: 10.0,
5317 y_mm: 20.0,
5318 priority: 1,
5319 },
5320 ];
5321 let classes = [
5322 GlyphCollisionClass::Critical,
5323 GlyphCollisionClass::Annotation,
5324 GlyphCollisionClass::Annotation,
5325 GlyphCollisionClass::Annotation,
5326 ];
5327 let directions = [
5328 GlyphCollisionDirection::Down,
5329 GlyphCollisionDirection::Down,
5330 GlyphCollisionDirection::Up,
5331 GlyphCollisionDirection::Right,
5332 ];
5333
5334 assert_eq!(
5335 resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
5336 Ok(3)
5337 );
5338 assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
5339 assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
5340 assert_eq!((placements[2].x_mm, placements[2].y_mm), (10.0, 15.0));
5341 assert_eq!((placements[3].x_mm, placements[3].y_mm), (13.0, 20.0));
5342
5343 let before = placements.clone();
5344 assert_eq!(
5345 resolve_glyph_collisions_constrained(
5346 &mut placements,
5347 &classes,
5348 &[GlyphCollisionDirection::Down],
5349 1.0
5350 ),
5351 Err(GlyphPlacementError::CollisionDirectionCount {
5352 placements: 4,
5353 directions: 1,
5354 })
5355 );
5356 assert_eq!(placements, before);
5357 }
5358
5359 #[test]
5360 fn constrained_collision_pass_keeps_fixed_obstacles_in_place() {
5361 let metrics = GlyphMetrics {
5362 advance_mm: 4.0,
5363 left_mm: -1.0,
5364 top_mm: -2.0,
5365 width_mm: 2.0,
5366 height_mm: 4.0,
5367 };
5368 let mut placements = vec![
5369 GlyphPlacement {
5370 resource_key: "resolved-annotation".into(),
5371 metrics,
5372 x_mm: 10.0,
5373 y_mm: 20.0,
5374 priority: 2,
5375 },
5376 GlyphPlacement {
5377 resource_key: "measure-text".into(),
5378 metrics,
5379 x_mm: 10.0,
5380 y_mm: 20.0,
5381 priority: 1,
5382 },
5383 ];
5384 let classes = [
5385 GlyphCollisionClass::Critical,
5386 GlyphCollisionClass::Annotation,
5387 ];
5388 let directions = [
5389 GlyphCollisionDirection::Fixed,
5390 GlyphCollisionDirection::Down,
5391 ];
5392
5393 assert_eq!(
5394 resolve_glyph_collisions_constrained(&mut placements, &classes, &directions, 1.0),
5395 Ok(1)
5396 );
5397 assert_eq!((placements[0].x_mm, placements[0].y_mm), (10.0, 20.0));
5398 assert_eq!((placements[1].x_mm, placements[1].y_mm), (10.0, 25.0));
5399 }
5400
5401 #[test]
5402 fn vertical_collision_resolution_does_not_move_non_overlapping_glyphs() {
5403 let metrics = GlyphMetrics {
5404 advance_mm: 4.0,
5405 left_mm: -1.0,
5406 top_mm: -1.0,
5407 width_mm: 2.0,
5408 height_mm: 2.0,
5409 };
5410 let mut placements = vec![
5411 GlyphPlacement {
5412 resource_key: "high".into(),
5413 metrics,
5414 x_mm: 10.0,
5415 y_mm: 20.0,
5416 priority: 10,
5417 },
5418 GlyphPlacement {
5419 resource_key: "low".into(),
5420 metrics,
5421 x_mm: 10.0,
5422 y_mm: 0.0,
5423 priority: 1,
5424 },
5425 ];
5426 assert_eq!(resolve_glyph_collisions(&mut placements, 1.0), 0);
5427 assert_eq!(placements[1].y_mm, 0.0);
5428 }
5429
5430 #[test]
5431 fn glyph_placement_validation_rejects_non_finite_and_negative_geometry() {
5432 let mut placements = vec![GlyphPlacement {
5433 resource_key: "test".into(),
5434 metrics: GlyphMetrics {
5435 advance_mm: 1.0,
5436 left_mm: 0.0,
5437 top_mm: 0.0,
5438 width_mm: 1.0,
5439 height_mm: 1.0,
5440 },
5441 x_mm: 0.0,
5442 y_mm: 0.0,
5443 priority: 0,
5444 }];
5445 assert_eq!(validate_glyph_placements(&placements), Ok(()));
5446 placements[0].x_mm = f32::NAN;
5447 assert_eq!(
5448 validate_glyph_placements(&placements),
5449 Err(GlyphPlacementError::NonFinite { index: 0 })
5450 );
5451 placements[0].x_mm = 0.0;
5452 placements[0].metrics.width_mm = -1.0;
5453 assert_eq!(
5454 validate_glyph_placements(&placements),
5455 Err(GlyphPlacementError::NegativeExtent { index: 0 })
5456 );
5457 }
5458
5459 #[test]
5460 fn horizontal_glyph_collision_resolution_is_priority_aware_and_skips_vertical_gaps() {
5461 let metrics = GlyphMetrics {
5462 advance_mm: 4.0,
5463 left_mm: -1.0,
5464 top_mm: -1.0,
5465 width_mm: 2.0,
5466 height_mm: 2.0,
5467 };
5468 let mut placements = vec![
5469 GlyphPlacement {
5470 resource_key: "high".into(),
5471 metrics,
5472 x_mm: 10.0,
5473 y_mm: 20.0,
5474 priority: 10,
5475 },
5476 GlyphPlacement {
5477 resource_key: "low".into(),
5478 metrics,
5479 x_mm: 10.0,
5480 y_mm: 20.0,
5481 priority: 1,
5482 },
5483 GlyphPlacement {
5484 resource_key: "far".into(),
5485 metrics,
5486 x_mm: 10.0,
5487 y_mm: 30.0,
5488 priority: 1,
5489 },
5490 ];
5491 assert_eq!(resolve_glyph_horizontal_collisions(&mut placements, 1.0), 1);
5492 assert_eq!(placements[0].x_mm, 10.0);
5493 assert_eq!(placements[1].x_mm, 13.0);
5494 assert_eq!(placements[2].x_mm, 10.0);
5495 }
5496
5497 #[test]
5498 fn glyph_spacing_distribution_is_stable_and_rejects_non_finite_spacing() {
5499 let metrics = GlyphMetrics {
5500 advance_mm: 1.0,
5501 left_mm: 0.0,
5502 top_mm: 0.0,
5503 width_mm: 1.0,
5504 height_mm: 1.0,
5505 };
5506 let mut placements = vec![
5507 GlyphPlacement {
5508 resource_key: "second".into(),
5509 metrics,
5510 x_mm: 20.0,
5511 y_mm: 0.0,
5512 priority: 0,
5513 },
5514 GlyphPlacement {
5515 resource_key: "first".into(),
5516 metrics,
5517 x_mm: 10.0,
5518 y_mm: 0.0,
5519 priority: 0,
5520 },
5521 GlyphPlacement {
5522 resource_key: "third".into(),
5523 metrics,
5524 x_mm: 30.0,
5525 y_mm: 0.0,
5526 priority: 0,
5527 },
5528 ];
5529 assert_eq!(distribute_glyph_spacing(&mut placements, 6.0), Ok(2));
5530 assert_eq!(placements[0].x_mm, 23.0);
5531 assert_eq!(placements[1].x_mm, 10.0);
5532 assert_eq!(placements[2].x_mm, 36.0);
5533 assert_eq!(
5534 distribute_glyph_spacing(&mut placements, f32::NAN),
5535 Err(GlyphPlacementError::NonFiniteSpacing)
5536 );
5537 let before = placements.clone();
5538 assert_eq!(
5539 distribute_glyph_spacing(&mut placements, f32::MAX),
5540 Err(GlyphPlacementError::NonFiniteSpacing)
5541 );
5542 assert_eq!(placements, before);
5543 }
5544
5545 #[test]
5546 fn glyph_placement_validation_rejects_missing_resource_and_negative_advance() {
5547 let mut placement = GlyphPlacement {
5548 resource_key: " ".into(),
5549 metrics: GlyphMetrics {
5550 advance_mm: 1.0,
5551 left_mm: 0.0,
5552 top_mm: 0.0,
5553 width_mm: 1.0,
5554 height_mm: 1.0,
5555 },
5556 x_mm: 0.0,
5557 y_mm: 0.0,
5558 priority: 0,
5559 };
5560 assert_eq!(
5561 validate_glyph_placements(&[placement.clone()]),
5562 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5563 );
5564 placement.resource_key = "glyph".into();
5565 placement.metrics.advance_mm = -1.0;
5566 assert_eq!(
5567 validate_glyph_placements(&[placement]),
5568 Err(GlyphPlacementError::NegativeAdvance { index: 0 })
5569 );
5570 }
5571
5572 #[test]
5573 fn checked_collision_resolvers_reject_invalid_geometry_before_mutation() {
5574 let mut placements = vec![GlyphPlacement {
5575 resource_key: String::new(),
5576 metrics: GlyphMetrics {
5577 advance_mm: 1.0,
5578 left_mm: 0.0,
5579 top_mm: 0.0,
5580 width_mm: 1.0,
5581 height_mm: 1.0,
5582 },
5583 x_mm: 0.0,
5584 y_mm: 0.0,
5585 priority: 0,
5586 }];
5587 assert_eq!(
5588 resolve_glyph_collisions_checked(&mut placements, 1.0),
5589 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5590 );
5591 assert_eq!(
5592 resolve_glyph_horizontal_collisions_checked(&mut placements, 1.0),
5593 Err(GlyphPlacementError::EmptyResourceKey { index: 0 })
5594 );
5595 assert_eq!(placements[0].x_mm, 0.0);
5596 assert_eq!(placements[0].y_mm, 0.0);
5597 }
5598
5599 #[test]
5600 fn checked_collision_resolvers_reject_non_finite_gap() {
5601 let metrics = GlyphMetrics {
5602 advance_mm: 1.0,
5603 left_mm: 0.0,
5604 top_mm: 0.0,
5605 width_mm: 1.0,
5606 height_mm: 1.0,
5607 };
5608 let original = vec![GlyphPlacement {
5609 resource_key: "glyph".into(),
5610 metrics,
5611 x_mm: 0.0,
5612 y_mm: 0.0,
5613 priority: 0,
5614 }];
5615 let mut vertical = original.clone();
5616 assert_eq!(
5617 resolve_glyph_collisions_checked(&mut vertical, f32::NAN),
5618 Err(GlyphPlacementError::NonFiniteSpacing)
5619 );
5620 assert_eq!(vertical, original);
5621
5622 let mut horizontal = original.clone();
5623 assert_eq!(
5624 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::INFINITY),
5625 Err(GlyphPlacementError::NonFiniteSpacing)
5626 );
5627 assert_eq!(horizontal, original);
5628 }
5629
5630 #[test]
5631 fn checked_collision_resolvers_reject_arithmetic_overflow_without_mutation() {
5632 let metrics = GlyphMetrics {
5633 advance_mm: 1.0,
5634 left_mm: 0.0,
5635 top_mm: 0.0,
5636 width_mm: f32::MAX / 2.0,
5637 height_mm: f32::MAX / 2.0,
5638 };
5639 let original = vec![
5640 GlyphPlacement {
5641 resource_key: "high".into(),
5642 metrics,
5643 x_mm: 0.0,
5644 y_mm: 0.0,
5645 priority: 1,
5646 },
5647 GlyphPlacement {
5648 resource_key: "low".into(),
5649 metrics,
5650 x_mm: 0.0,
5651 y_mm: 0.0,
5652 priority: 0,
5653 },
5654 ];
5655 let mut vertical = original.clone();
5656 assert_eq!(
5657 resolve_glyph_collisions_checked(&mut vertical, f32::MAX),
5658 Err(GlyphPlacementError::NonFinite { index: 1 })
5659 );
5660 assert_eq!(vertical, original);
5661
5662 let mut horizontal = original.clone();
5663 assert_eq!(
5664 resolve_glyph_horizontal_collisions_checked(&mut horizontal, f32::MAX),
5665 Err(GlyphPlacementError::NonFinite { index: 1 })
5666 );
5667 assert_eq!(horizontal, original);
5668 }
5669
5670 #[test]
5671 fn glyph_extents_are_content_aware_and_empty_collections_are_explicit() {
5672 let metrics = GlyphMetrics {
5673 advance_mm: 1.0,
5674 left_mm: -1.0,
5675 top_mm: -2.0,
5676 width_mm: 3.0,
5677 height_mm: 4.0,
5678 };
5679 let placements = vec![
5680 GlyphPlacement {
5681 resource_key: "a".into(),
5682 metrics,
5683 x_mm: 10.0,
5684 y_mm: 20.0,
5685 priority: 0,
5686 },
5687 GlyphPlacement {
5688 resource_key: "b".into(),
5689 metrics,
5690 x_mm: 30.0,
5691 y_mm: 5.0,
5692 priority: 0,
5693 },
5694 ];
5695 assert_eq!(
5696 glyph_extents(&placements),
5697 Ok(Some(GlyphExtents {
5698 left_mm: 9.0,
5699 top_mm: 3.0,
5700 right_mm: 32.0,
5701 bottom_mm: 22.0,
5702 }))
5703 );
5704 let extents = glyph_extents(&placements).unwrap().unwrap();
5705 assert_eq!(extents.width_mm(), 23.0);
5706 assert_eq!(extents.height_mm(), 19.0);
5707 assert_eq!(glyph_extents(&[]), Ok(None));
5708 }
5709
5710 #[test]
5711 fn glyph_extents_reject_derived_bound_overflow() {
5712 let placements = [GlyphPlacement {
5713 resource_key: "edge".into(),
5714 metrics: GlyphMetrics {
5715 advance_mm: 1.0,
5716 left_mm: 0.0,
5717 top_mm: 0.0,
5718 width_mm: f32::MAX,
5719 height_mm: 1.0,
5720 },
5721 x_mm: f32::MAX,
5722 y_mm: 0.0,
5723 priority: 0,
5724 }];
5725 assert_eq!(
5726 glyph_extents(&placements),
5727 Err(GlyphPlacementError::NonFinite { index: 0 })
5728 );
5729 }
5730
5731 #[test]
5732 fn page_render_tree_preserves_canonical_note_and_rest_addresses() {
5733 let mut score = score_with_measures(1);
5734 score.parts[0].staves[0].measures[0].voices[0] = vec![
5735 Note::new(Pitch::new(Step::C, 4), Duration::Quarter),
5736 Note::rest(Duration::Quarter),
5737 ];
5738 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5739 let trees = layout
5740 .export_page_render_trees(&score)
5741 .expect("render trees");
5742 assert_eq!(trees.len(), 1);
5743 assert_eq!(trees[0].contract_version, PAGE_RENDER_TREE_CONTRACT_VERSION);
5744 assert_eq!(trees[0].validate(&score), Ok(()));
5745 assert!(trees[0].nodes.iter().any(|node| matches!(
5746 (&node.address, &node.kind),
5747 (PageRenderAddress::Note(address), PageRenderNodeKind::Note)
5748 if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 0
5749 )));
5750 assert!(trees[0].nodes.iter().any(|node| matches!(
5751 (&node.address, &node.kind),
5752 (PageRenderAddress::Note(address), PageRenderNodeKind::Rest)
5753 if address.part == 0 && address.staff == 0 && address.measure == 0 && address.note == 1
5754 )));
5755 let restored: Vec<PageRenderTree> =
5756 serde_json::from_str(&serde_json::to_string(&trees).expect("trees serialize"))
5757 .expect("trees deserialize");
5758 assert_eq!(restored, trees);
5759 let mut invalid = restored[0].clone();
5760 invalid.nodes[0]
5761 .system
5762 .as_mut()
5763 .expect("system node")
5764 .page_index = 99;
5765 assert!(matches!(
5766 invalid.validate(&score),
5767 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5768 ));
5769
5770 let mut wrong_note_kind = restored[0].clone();
5771 wrong_note_kind.nodes[0].kind = PageRenderNodeKind::Rest;
5772 assert!(matches!(
5773 wrong_note_kind.validate(&score),
5774 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5775 ));
5776
5777 let mut missing_system = restored[0].clone();
5778 missing_system.nodes[0].system = None;
5779 assert!(matches!(
5780 missing_system.validate(&score),
5781 Err(PrintLayoutError::InvalidRenderTreeNode { node_index: 0 })
5782 ));
5783 }
5784
5785 #[test]
5786 fn page_render_tree_for_linked_view_keeps_source_part_addresses() {
5787 let mut score = score_with_measures(1);
5788 score.parts[0].staves[0].measures[0].voices[0] =
5789 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5790 score
5791 .views
5792 .push(ScoreView::linked_part("piano", "Piano", 0));
5793 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5794 let trees = layout
5795 .export_page_render_trees_for_view(&score, "piano")
5796 .expect("view trees");
5797 assert_eq!(trees[0].view_id.as_deref(), Some("piano"));
5798 assert!(trees[0].nodes.iter().all(|node| match &node.address {
5799 PageRenderAddress::Note(address) => address.part == 0,
5800 _ => true,
5801 }));
5802 assert!(
5803 layout
5804 .export_page_render_trees_for_view(&score, "missing")
5805 .is_err()
5806 );
5807 }
5808
5809 #[test]
5810 fn page_render_tree_for_linked_view_omits_hidden_staff_nodes() {
5811 let mut score = Score::template(ScoreTemplate::Piano);
5812 score.parts[0].staves[0].measures[0].voices[0] =
5813 vec![Note::new(Pitch::new(Step::C, 4), Duration::Quarter)];
5814 score.parts[0].staves[1].measures[0].voices[0] =
5815 vec![Note::new(Pitch::new(Step::C, 3), Duration::Quarter)];
5816 let mut view = ScoreView::linked_part("piano", "Piano", 0);
5817 view.layout
5818 .hidden_staves
5819 .push(ViewStaffRef { part: 0, staff: 1 });
5820 score.views.push(view);
5821
5822 let layout = compute_print_layout(&score, &PrintConfig::default()).expect("layout");
5823 let trees = layout
5824 .export_page_render_trees_for_view(&score, "piano")
5825 .expect("view trees");
5826
5827 let note_staves = trees
5828 .iter()
5829 .flat_map(|tree| tree.nodes.iter())
5830 .filter_map(|node| match &node.address {
5831 PageRenderAddress::Note(address) => Some(address.staff),
5832 _ => None,
5833 })
5834 .collect::<Vec<_>>();
5835 assert!(!note_staves.is_empty());
5836 assert!(note_staves.iter().all(|&staff| staff == 0));
5837 assert!(trees.iter().all(|tree| tree.validate(&score).is_ok()));
5838
5839 let mut invalid = trees[0].clone();
5840 invalid.nodes.push(PageRenderNode {
5841 address: PageRenderAddress::Note(NoteAddr {
5842 part: 0,
5843 staff: 1,
5844 measure: 0,
5845 voice: 0,
5846 note: 0,
5847 }),
5848 kind: PageRenderNodeKind::Note,
5849 system: None,
5850 });
5851 assert!(matches!(
5852 invalid.validate(&score),
5853 Err(PrintLayoutError::InvalidRenderTreeNode { .. })
5854 ));
5855 }
5856
5857 #[test]
5858 fn print_layout_for_linked_view_applies_local_breaks_without_mutating_score() {
5859 let mut score = score_with_measures(4);
5860 let mut view = ScoreView::linked_part("part", "Part", 0);
5861 view.layout.measures_per_row = Some(3);
5862 view.layout.system_breaks.push(1);
5863 score.views.push(view);
5864
5865 let layout = compute_print_layout_for_view(&score, &PrintConfig::default(), "part")
5866 .expect("view layout");
5867 let systems = layout
5868 .pages
5869 .iter()
5870 .flat_map(|page| page.systems.iter())
5871 .collect::<Vec<_>>();
5872 assert_eq!(
5873 systems
5874 .iter()
5875 .map(|system| system.measure_indices.clone())
5876 .collect::<Vec<_>>(),
5877 vec![vec![0, 1], vec![2, 3]]
5878 );
5879 assert!(!score.parts[0].staves[0].measures[1].system_break);
5880 }
5881
5882 #[test]
5883 fn page_render_tree_keeps_page_scoped_resource_addresses() {
5884 let score = score_with_measures(1);
5885 let config = PrintConfig {
5886 publication: PublicationConfig {
5887 title_page: true,
5888 image_resources: vec![
5889 PublicationImageResource {
5890 resource_key: "cover-art-v1".into(),
5891 alt_text: "Cover".into(),
5892 placement: PublicationImagePlacement::TitlePage,
5893 x_mm: 10.0,
5894 y_mm: 10.0,
5895 width_mm: 30.0,
5896 height_mm: 20.0,
5897 },
5898 PublicationImageResource {
5899 resource_key: "publisher-mark".into(),
5900 alt_text: "Mark".into(),
5901 placement: PublicationImagePlacement::MusicPages,
5902 x_mm: 160.0,
5903 y_mm: 10.0,
5904 width_mm: 20.0,
5905 height_mm: 10.0,
5906 },
5907 ],
5908 frames: vec![PublicationFrame {
5909 placement: PublicationFramePlacement::EveryPage,
5910 x_mm: 5.0,
5911 y_mm: 5.0,
5912 width_mm: 200.0,
5913 height_mm: 287.0,
5914 stroke_width_mm: 0.5,
5915 }],
5916 ..PublicationConfig::default()
5917 },
5918 ..PrintConfig::default()
5919 };
5920 let layout = compute_print_layout(&score, &config).expect("layout");
5921 let trees = layout
5922 .export_page_render_trees(&score)
5923 .expect("render trees");
5924 assert!(trees[0].nodes.iter().any(|node| matches!(
5925 (&node.address, &node.kind),
5926 (
5927 PageRenderAddress::Resource { page_index: 0, resource_key },
5928 PageRenderNodeKind::Resource,
5929 ) if resource_key == "cover-art-v1"
5930 )));
5931 assert!(trees[1].nodes.iter().any(|node| matches!(
5932 (&node.address, &node.kind),
5933 (
5934 PageRenderAddress::Resource { page_index: 1, resource_key },
5935 PageRenderNodeKind::Resource,
5936 ) if resource_key == "publisher-mark"
5937 )));
5938 assert!(
5939 trees
5940 .iter()
5941 .enumerate()
5942 .all(|(page_index, tree)| tree.nodes.iter().any(|node| matches!(
5943 (&node.address, &node.kind),
5944 (
5945 PageRenderAddress::Frame { page_index: address_page, frame_index: 0 },
5946 PageRenderNodeKind::Frame,
5947 ) if *address_page == page_index
5948 )))
5949 );
5950 }
5951}