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