1use crate::{LayoutConfig, compute_layout};
2use acorde_core::Score;
3use serde::{Deserialize, Serialize};
4
5#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
7pub enum PaperSize {
8 A4,
9 Letter,
10 Legal,
11 Custom { width_mm: f32, height_mm: f32 },
12}
13
14impl PaperSize {
15 fn dimensions_mm(self) -> (f32, f32) {
16 match self {
17 Self::A4 => (210.0, 297.0),
18 Self::Letter => (215.9, 279.4),
19 Self::Legal => (215.9, 355.6),
20 Self::Custom {
21 width_mm,
22 height_mm,
23 } => (width_mm, height_mm),
24 }
25 }
26}
27
28#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
30pub enum PageOrientation {
31 Portrait,
32 Landscape,
33}
34
35#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37pub enum PageNumbering {
38 None,
39 OneBased,
40}
41
42#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
44pub enum PrintColorPolicy {
45 #[default]
46 Monochrome,
47 Preserve,
48}
49
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
52pub enum CropMarkPolicy {
53 #[default]
54 None,
55 BleedEdges,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
60pub enum GlyphResourcePolicy {
61 #[default]
63 BuiltInVector,
64 HostProvided(String),
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73#[serde(default)]
74pub struct PrintConfig {
75 pub paper_size: PaperSize,
76 pub orientation: PageOrientation,
77 pub margin_top_mm: f32,
78 pub margin_right_mm: f32,
79 pub margin_bottom_mm: f32,
80 pub margin_left_mm: f32,
81 pub bleed_top_mm: f32,
82 pub bleed_right_mm: f32,
83 pub bleed_bottom_mm: f32,
84 pub bleed_left_mm: f32,
85 pub safe_top_mm: f32,
86 pub safe_right_mm: f32,
87 pub safe_bottom_mm: f32,
88 pub safe_left_mm: f32,
89 pub system_height_mm: f32,
90 pub scale: f32,
92 pub measures_per_system: usize,
93 pub systems_per_page: Option<usize>,
96 pub page_numbering: PageNumbering,
97 #[serde(default)]
98 pub color_policy: PrintColorPolicy,
99 #[serde(default)]
100 pub crop_mark_policy: CropMarkPolicy,
101 #[serde(default)]
102 pub glyph_resources: GlyphResourcePolicy,
103}
104
105impl Default for PrintConfig {
106 fn default() -> Self {
107 Self {
108 paper_size: PaperSize::A4,
109 orientation: PageOrientation::Portrait,
110 margin_top_mm: 16.0,
111 margin_right_mm: 14.0,
112 margin_bottom_mm: 16.0,
113 margin_left_mm: 14.0,
114 bleed_top_mm: 0.0,
115 bleed_right_mm: 0.0,
116 bleed_bottom_mm: 0.0,
117 bleed_left_mm: 0.0,
118 safe_top_mm: 0.0,
119 safe_right_mm: 0.0,
120 safe_bottom_mm: 0.0,
121 safe_left_mm: 0.0,
122 system_height_mm: 24.0,
123 scale: 1.0,
124 measures_per_system: 4,
125 systems_per_page: None,
126 page_numbering: PageNumbering::OneBased,
127 color_policy: PrintColorPolicy::Monochrome,
128 crop_mark_policy: CropMarkPolicy::None,
129 glyph_resources: GlyphResourcePolicy::BuiltInVector,
130 }
131 }
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct SystemLayout {
137 pub address: SystemAddress,
138 pub system_index: usize,
139 pub page_index: usize,
140 pub measure_indices: Vec<usize>,
141 pub top_mm: f32,
142 pub height_mm: f32,
143 pub break_reason: BreakReason,
144}
145
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
148pub struct PageAddress {
149 pub page_index: usize,
150}
151
152#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
154pub struct SystemAddress {
155 pub system_index: usize,
156 pub page_index: usize,
157 pub index_on_page: usize,
158}
159
160#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
162pub enum BreakReason {
163 MeasureCapacity,
164 ExplicitSystemBreak,
165 ExplicitPageBreak,
166 PageCapacity,
167 EndOfScore,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
172pub struct PageLayout {
173 pub address: PageAddress,
174 pub page_index: usize,
175 pub page_number: Option<usize>,
176 #[serde(default)]
177 pub color_policy: PrintColorPolicy,
178 #[serde(default)]
179 pub crop_mark_policy: CropMarkPolicy,
180 #[serde(default)]
181 pub glyph_resources: GlyphResourcePolicy,
182 pub width_mm: f32,
183 pub height_mm: f32,
184 pub content_width_mm: f32,
185 pub content_height_mm: f32,
186 pub bleed_top_mm: f32,
187 pub bleed_right_mm: f32,
188 pub bleed_bottom_mm: f32,
189 pub bleed_left_mm: f32,
190 pub systems: Vec<SystemLayout>,
191 pub break_reason: BreakReason,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
196pub struct PrintLayoutResult {
197 pub contract_version: u16,
198 pub pages: Vec<PageLayout>,
199}
200
201#[derive(Debug, thiserror::Error, PartialEq)]
202pub enum PrintLayoutError {
203 #[error("paper dimensions must be finite and greater than zero")]
204 InvalidPaperDimensions,
205 #[error("margins must be finite and non-negative")]
206 InvalidMargins,
207 #[error("system height must be finite and greater than zero")]
208 InvalidSystemHeight,
209 #[error("print scale must be finite and greater than zero")]
210 InvalidScale,
211 #[error("margins leave no usable page area")]
212 NoUsablePageArea,
213}
214
215pub fn compute_print_layout(
217 score: &Score,
218 config: &PrintConfig,
219) -> Result<PrintLayoutResult, PrintLayoutError> {
220 let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
221 if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
222 return Err(PrintLayoutError::InvalidPaperDimensions);
223 }
224 if matches!(config.orientation, PageOrientation::Landscape) {
225 std::mem::swap(&mut width_mm, &mut height_mm);
226 }
227
228 let margins = [
229 config.margin_top_mm,
230 config.margin_right_mm,
231 config.margin_bottom_mm,
232 config.margin_left_mm,
233 config.bleed_top_mm,
234 config.bleed_right_mm,
235 config.bleed_bottom_mm,
236 config.bleed_left_mm,
237 config.safe_top_mm,
238 config.safe_right_mm,
239 config.safe_bottom_mm,
240 config.safe_left_mm,
241 ];
242 if margins
243 .iter()
244 .any(|value| !value.is_finite() || *value < 0.0)
245 {
246 return Err(PrintLayoutError::InvalidMargins);
247 }
248 if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
249 return Err(PrintLayoutError::InvalidSystemHeight);
250 }
251 if !config.scale.is_finite() || config.scale <= 0.0 {
252 return Err(PrintLayoutError::InvalidScale);
253 }
254 let scaled_system_height_mm = config.system_height_mm * config.scale;
255 if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
256 return Err(PrintLayoutError::InvalidScale);
257 }
258
259 let content_width_mm = width_mm
260 - config.margin_left_mm
261 - config.margin_right_mm
262 - config.safe_left_mm
263 - config.safe_right_mm;
264 let content_height_mm = height_mm
265 - config.margin_top_mm
266 - config.margin_bottom_mm
267 - config.safe_top_mm
268 - config.safe_bottom_mm;
269 if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
270 return Err(PrintLayoutError::NoUsablePageArea);
271 }
272
273 let systems_per_page = config
274 .systems_per_page
275 .unwrap_or_else(|| {
276 (content_height_mm / scaled_system_height_mm)
277 .floor()
278 .max(1.0) as usize
279 })
280 .max(1);
281 let layout = compute_layout(
282 score,
283 &LayoutConfig {
284 measures_per_row: config.measures_per_system.max(1),
285 ..LayoutConfig::default()
286 },
287 );
288
289 let mut pages = Vec::new();
290 let mut page_systems = Vec::new();
291 let mut page_index = 0;
292 for (system_index, row) in layout.rows.iter().enumerate() {
293 let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
294 score
295 .parts
296 .iter()
297 .flat_map(|part| part.staves.iter())
298 .filter_map(|staff| staff.measures.get(measure_index))
299 .any(|measure| measure.page_break)
300 });
301 let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
302 score
303 .parts
304 .iter()
305 .flat_map(|part| part.staves.iter())
306 .filter_map(|staff| staff.measures.get(measure_index))
307 .any(|measure| measure.system_break)
308 });
309 let is_last_system = system_index + 1 == layout.rows.len();
310 let break_reason = if explicit_page_break {
311 BreakReason::ExplicitPageBreak
312 } else if explicit_system_break {
313 BreakReason::ExplicitSystemBreak
314 } else if is_last_system {
315 BreakReason::EndOfScore
316 } else {
317 BreakReason::MeasureCapacity
318 };
319 let system = SystemLayout {
320 address: SystemAddress {
321 system_index,
322 page_index,
323 index_on_page: page_systems.len(),
324 },
325 system_index,
326 page_index,
327 measure_indices: row.measure_indices.clone(),
328 top_mm: config.margin_top_mm
329 + config.safe_top_mm
330 + page_systems.len() as f32 * scaled_system_height_mm,
331 height_mm: scaled_system_height_mm,
332 break_reason,
333 };
334 page_systems.push(system);
335
336 let page_is_full = page_systems.len() >= systems_per_page;
337 if page_is_full || explicit_page_break {
338 let page_break_reason = if explicit_page_break {
339 BreakReason::ExplicitPageBreak
340 } else if is_last_system {
341 BreakReason::EndOfScore
342 } else {
343 BreakReason::PageCapacity
344 };
345 pages.push(PageLayout {
346 address: PageAddress { page_index },
347 page_index,
348 page_number: match config.page_numbering {
349 PageNumbering::None => None,
350 PageNumbering::OneBased => Some(page_index + 1),
351 },
352 color_policy: config.color_policy,
353 crop_mark_policy: config.crop_mark_policy,
354 glyph_resources: config.glyph_resources.clone(),
355 width_mm,
356 height_mm,
357 content_width_mm,
358 content_height_mm,
359 bleed_top_mm: config.bleed_top_mm,
360 bleed_right_mm: config.bleed_right_mm,
361 bleed_bottom_mm: config.bleed_bottom_mm,
362 bleed_left_mm: config.bleed_left_mm,
363 systems: std::mem::take(&mut page_systems),
364 break_reason: page_break_reason,
365 });
366 page_index += 1;
367 }
368 }
369 if !page_systems.is_empty() || pages.is_empty() {
370 pages.push(PageLayout {
371 address: PageAddress { page_index },
372 page_index,
373 page_number: match config.page_numbering {
374 PageNumbering::None => None,
375 PageNumbering::OneBased => Some(page_index + 1),
376 },
377 color_policy: config.color_policy,
378 crop_mark_policy: config.crop_mark_policy,
379 glyph_resources: config.glyph_resources.clone(),
380 width_mm,
381 height_mm,
382 content_width_mm,
383 content_height_mm,
384 bleed_top_mm: config.bleed_top_mm,
385 bleed_right_mm: config.bleed_right_mm,
386 bleed_bottom_mm: config.bleed_bottom_mm,
387 bleed_left_mm: config.bleed_left_mm,
388 systems: page_systems,
389 break_reason: BreakReason::EndOfScore,
390 });
391 }
392
393 Ok(PrintLayoutResult {
394 contract_version: 7,
395 pages,
396 })
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use acorde_core::{Clef, Measure, Part, Score, Staff};
403
404 fn score_with_measures(count: usize) -> Score {
405 let mut score = Score::default();
406 let mut part = Part::new("Piano", "Pno.");
407 let mut staff = Staff::new(Clef::Treble);
408 staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
409 part.staves = vec![staff];
410 score.parts = vec![part];
411 score
412 }
413
414 #[test]
415 fn paginates_rows_and_preserves_measure_indices() {
416 let score = score_with_measures(5);
417 let result = compute_print_layout(
418 &score,
419 &PrintConfig {
420 measures_per_system: 2,
421 systems_per_page: Some(2),
422 ..PrintConfig::default()
423 },
424 )
425 .expect("valid print config");
426 assert_eq!(result.pages.len(), 2);
427 assert_eq!(
428 result.pages[0]
429 .systems
430 .iter()
431 .map(|s| s.measure_indices.clone())
432 .collect::<Vec<_>>(),
433 vec![vec![0, 1], vec![2, 3]]
434 );
435 assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
436 assert_eq!(result.pages[1].systems[0].page_index, 1);
437 assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
438 assert_eq!(
439 result.pages[1].systems[0].break_reason,
440 BreakReason::EndOfScore
441 );
442 assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
443 }
444
445 #[test]
446 fn forced_page_break_starts_next_system_on_next_page() {
447 let mut score = score_with_measures(3);
448 score.parts[0].staves[0].measures[0].page_break = true;
449 let result = compute_print_layout(
450 &score,
451 &PrintConfig {
452 measures_per_system: 3,
453 systems_per_page: Some(8),
454 ..PrintConfig::default()
455 },
456 )
457 .expect("valid print config");
458 assert_eq!(result.pages.len(), 2);
459 assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
460 assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
461 assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
462 assert_eq!(
463 result.pages[0].systems[0].break_reason,
464 BreakReason::ExplicitPageBreak
465 );
466 }
467
468 #[test]
469 fn rejects_margins_that_leave_no_page_area() {
470 let score = score_with_measures(1);
471 let error = compute_print_layout(
472 &score,
473 &PrintConfig {
474 margin_left_mm: 200.0,
475 ..PrintConfig::default()
476 },
477 )
478 .expect_err("invalid page area");
479 assert_eq!(error, PrintLayoutError::NoUsablePageArea);
480 }
481
482 #[test]
483 fn safe_area_reduces_content_and_bleed_is_exposed() {
484 let score = score_with_measures(1);
485 let result = compute_print_layout(
486 &score,
487 &PrintConfig {
488 bleed_top_mm: 3.0,
489 bleed_right_mm: 3.0,
490 bleed_bottom_mm: 3.0,
491 bleed_left_mm: 3.0,
492 safe_top_mm: 5.0,
493 safe_right_mm: 6.0,
494 safe_bottom_mm: 7.0,
495 safe_left_mm: 8.0,
496 ..PrintConfig::default()
497 },
498 )
499 .expect("valid print config");
500 let page = &result.pages[0];
501 assert_eq!(result.contract_version, 7);
502 assert_eq!(page.bleed_left_mm, 3.0);
503 assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
504 assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
505 assert_eq!(page.systems[0].top_mm, 21.0);
506 }
507
508 #[test]
509 fn scale_changes_system_height_and_page_capacity() {
510 let score = score_with_measures(10);
511 let result = compute_print_layout(
512 &score,
513 &PrintConfig {
514 scale: 2.0,
515 measures_per_system: 1,
516 systems_per_page: None,
517 ..PrintConfig::default()
518 },
519 )
520 .expect("valid print config");
521 assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
522 assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
523 assert_eq!(result.pages.len(), 2);
524 }
525
526 #[test]
527 fn rejects_non_positive_scale() {
528 let score = score_with_measures(1);
529 let error = compute_print_layout(
530 &score,
531 &PrintConfig {
532 scale: 0.0,
533 ..PrintConfig::default()
534 },
535 )
536 .expect_err("invalid scale");
537 assert_eq!(error, PrintLayoutError::InvalidScale);
538 }
539
540 #[test]
541 fn page_numbering_is_configurable() {
542 let score = score_with_measures(5);
543 let numbered = compute_print_layout(
544 &score,
545 &PrintConfig {
546 measures_per_system: 1,
547 systems_per_page: Some(2),
548 ..PrintConfig::default()
549 },
550 )
551 .expect("valid print config");
552 assert_eq!(numbered.pages[0].page_number, Some(1));
553 assert_eq!(numbered.pages[1].page_number, Some(2));
554
555 let unnumbered = compute_print_layout(
556 &score,
557 &PrintConfig {
558 page_numbering: PageNumbering::None,
559 measures_per_system: 1,
560 systems_per_page: Some(2),
561 ..PrintConfig::default()
562 },
563 )
564 .expect("valid print config");
565 assert!(
566 unnumbered
567 .pages
568 .iter()
569 .all(|page| page.page_number.is_none())
570 );
571 }
572
573 #[test]
574 fn print_color_and_crop_policies_are_exposed_per_page() {
575 let score = score_with_measures(1);
576 let result = compute_print_layout(
577 &score,
578 &PrintConfig {
579 color_policy: PrintColorPolicy::Preserve,
580 crop_mark_policy: CropMarkPolicy::BleedEdges,
581 ..PrintConfig::default()
582 },
583 )
584 .expect("valid print config");
585 let page = &result.pages[0];
586 assert_eq!(result.contract_version, 7);
587 assert_eq!(page.color_policy, PrintColorPolicy::Preserve);
588 assert_eq!(page.crop_mark_policy, CropMarkPolicy::BleedEdges);
589 }
590
591 #[test]
592 fn glyph_resource_policy_is_exposed_per_page() {
593 let score = score_with_measures(1);
594 let result = compute_print_layout(
595 &score,
596 &PrintConfig {
597 glyph_resources: GlyphResourcePolicy::HostProvided("music-font-v1".into()),
598 ..PrintConfig::default()
599 },
600 )
601 .expect("valid print config");
602 assert_eq!(
603 result.pages[0].glyph_resources,
604 GlyphResourcePolicy::HostProvided("music-font-v1".into())
605 );
606 }
607}