Skip to main content

acorde_layout/
print.rs

1use crate::{LayoutConfig, compute_layout};
2use acorde_core::Score;
3use serde::{Deserialize, Serialize};
4
5/// A paper size expressed in physical millimetres.
6#[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/// Page orientation for a logical print layout.
29#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
30pub enum PageOrientation {
31    Portrait,
32    Landscape,
33}
34
35/// Policy for the page number exposed in logical page metadata.
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37pub enum PageNumbering {
38    None,
39    OneBased,
40}
41
42/// Host-neutral inputs for deterministic page and system layout.
43///
44/// This contract describes physical page geometry only. It intentionally does not select
45/// fonts, emit PDF, access printers, or perform filesystem I/O.
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47#[serde(default)]
48pub struct PrintConfig {
49    pub paper_size: PaperSize,
50    pub orientation: PageOrientation,
51    pub margin_top_mm: f32,
52    pub margin_right_mm: f32,
53    pub margin_bottom_mm: f32,
54    pub margin_left_mm: f32,
55    pub bleed_top_mm: f32,
56    pub bleed_right_mm: f32,
57    pub bleed_bottom_mm: f32,
58    pub bleed_left_mm: f32,
59    pub safe_top_mm: f32,
60    pub safe_right_mm: f32,
61    pub safe_bottom_mm: f32,
62    pub safe_left_mm: f32,
63    pub system_height_mm: f32,
64    /// Content scale factor. `1.0` preserves the configured system height.
65    pub scale: f32,
66    pub measures_per_system: usize,
67    /// Override the number of systems per page. When omitted it is derived from the usable
68    /// page height and `system_height_mm`.
69    pub systems_per_page: Option<usize>,
70    pub page_numbering: PageNumbering,
71}
72
73impl Default for PrintConfig {
74    fn default() -> Self {
75        Self {
76            paper_size: PaperSize::A4,
77            orientation: PageOrientation::Portrait,
78            margin_top_mm: 16.0,
79            margin_right_mm: 14.0,
80            margin_bottom_mm: 16.0,
81            margin_left_mm: 14.0,
82            bleed_top_mm: 0.0,
83            bleed_right_mm: 0.0,
84            bleed_bottom_mm: 0.0,
85            bleed_left_mm: 0.0,
86            safe_top_mm: 0.0,
87            safe_right_mm: 0.0,
88            safe_bottom_mm: 0.0,
89            safe_left_mm: 0.0,
90            system_height_mm: 24.0,
91            scale: 1.0,
92            measures_per_system: 4,
93            systems_per_page: None,
94            page_numbering: PageNumbering::OneBased,
95        }
96    }
97}
98
99/// A logical system placed on a page.
100#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
101pub struct SystemLayout {
102    pub address: SystemAddress,
103    pub system_index: usize,
104    pub page_index: usize,
105    pub measure_indices: Vec<usize>,
106    pub top_mm: f32,
107    pub height_mm: f32,
108    pub break_reason: BreakReason,
109}
110
111/// Stable address of a page within one print-layout result.
112#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
113pub struct PageAddress {
114    pub page_index: usize,
115}
116
117/// Stable address of a system, including global and page-local positions.
118#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
119pub struct SystemAddress {
120    pub system_index: usize,
121    pub page_index: usize,
122    pub index_on_page: usize,
123}
124
125/// Explains why a system or page ended at its final measure.
126#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
127pub enum BreakReason {
128    MeasureCapacity,
129    ExplicitSystemBreak,
130    ExplicitPageBreak,
131    PageCapacity,
132    EndOfScore,
133}
134
135/// One page in a [`PrintLayoutResult`].
136#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
137pub struct PageLayout {
138    pub address: PageAddress,
139    pub page_index: usize,
140    pub page_number: Option<usize>,
141    pub width_mm: f32,
142    pub height_mm: f32,
143    pub content_width_mm: f32,
144    pub content_height_mm: f32,
145    pub bleed_top_mm: f32,
146    pub bleed_right_mm: f32,
147    pub bleed_bottom_mm: f32,
148    pub bleed_left_mm: f32,
149    pub systems: Vec<SystemLayout>,
150    pub break_reason: BreakReason,
151}
152
153/// Deterministic page/system geometry for a score.
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
155pub struct PrintLayoutResult {
156    pub contract_version: u16,
157    pub pages: Vec<PageLayout>,
158}
159
160#[derive(Debug, thiserror::Error, PartialEq)]
161pub enum PrintLayoutError {
162    #[error("paper dimensions must be finite and greater than zero")]
163    InvalidPaperDimensions,
164    #[error("margins must be finite and non-negative")]
165    InvalidMargins,
166    #[error("system height must be finite and greater than zero")]
167    InvalidSystemHeight,
168    #[error("print scale must be finite and greater than zero")]
169    InvalidScale,
170    #[error("margins leave no usable page area")]
171    NoUsablePageArea,
172}
173
174/// Compute physical page and system placement without rendering or host integration.
175pub fn compute_print_layout(
176    score: &Score,
177    config: &PrintConfig,
178) -> Result<PrintLayoutResult, PrintLayoutError> {
179    let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
180    if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
181        return Err(PrintLayoutError::InvalidPaperDimensions);
182    }
183    if matches!(config.orientation, PageOrientation::Landscape) {
184        std::mem::swap(&mut width_mm, &mut height_mm);
185    }
186
187    let margins = [
188        config.margin_top_mm,
189        config.margin_right_mm,
190        config.margin_bottom_mm,
191        config.margin_left_mm,
192        config.bleed_top_mm,
193        config.bleed_right_mm,
194        config.bleed_bottom_mm,
195        config.bleed_left_mm,
196        config.safe_top_mm,
197        config.safe_right_mm,
198        config.safe_bottom_mm,
199        config.safe_left_mm,
200    ];
201    if margins
202        .iter()
203        .any(|value| !value.is_finite() || *value < 0.0)
204    {
205        return Err(PrintLayoutError::InvalidMargins);
206    }
207    if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
208        return Err(PrintLayoutError::InvalidSystemHeight);
209    }
210    if !config.scale.is_finite() || config.scale <= 0.0 {
211        return Err(PrintLayoutError::InvalidScale);
212    }
213    let scaled_system_height_mm = config.system_height_mm * config.scale;
214    if !scaled_system_height_mm.is_finite() || scaled_system_height_mm <= 0.0 {
215        return Err(PrintLayoutError::InvalidScale);
216    }
217
218    let content_width_mm = width_mm
219        - config.margin_left_mm
220        - config.margin_right_mm
221        - config.safe_left_mm
222        - config.safe_right_mm;
223    let content_height_mm = height_mm
224        - config.margin_top_mm
225        - config.margin_bottom_mm
226        - config.safe_top_mm
227        - config.safe_bottom_mm;
228    if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
229        return Err(PrintLayoutError::NoUsablePageArea);
230    }
231
232    let systems_per_page = config
233        .systems_per_page
234        .unwrap_or_else(|| {
235            (content_height_mm / scaled_system_height_mm)
236                .floor()
237                .max(1.0) as usize
238        })
239        .max(1);
240    let layout = compute_layout(
241        score,
242        &LayoutConfig {
243            measures_per_row: config.measures_per_system.max(1),
244            ..LayoutConfig::default()
245        },
246    );
247
248    let mut pages = Vec::new();
249    let mut page_systems = Vec::new();
250    let mut page_index = 0;
251    for (system_index, row) in layout.rows.iter().enumerate() {
252        let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
253            score
254                .parts
255                .iter()
256                .flat_map(|part| part.staves.iter())
257                .filter_map(|staff| staff.measures.get(measure_index))
258                .any(|measure| measure.page_break)
259        });
260        let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
261            score
262                .parts
263                .iter()
264                .flat_map(|part| part.staves.iter())
265                .filter_map(|staff| staff.measures.get(measure_index))
266                .any(|measure| measure.system_break)
267        });
268        let is_last_system = system_index + 1 == layout.rows.len();
269        let break_reason = if explicit_page_break {
270            BreakReason::ExplicitPageBreak
271        } else if explicit_system_break {
272            BreakReason::ExplicitSystemBreak
273        } else if is_last_system {
274            BreakReason::EndOfScore
275        } else {
276            BreakReason::MeasureCapacity
277        };
278        let system = SystemLayout {
279            address: SystemAddress {
280                system_index,
281                page_index,
282                index_on_page: page_systems.len(),
283            },
284            system_index,
285            page_index,
286            measure_indices: row.measure_indices.clone(),
287            top_mm: config.margin_top_mm
288                + config.safe_top_mm
289                + page_systems.len() as f32 * scaled_system_height_mm,
290            height_mm: scaled_system_height_mm,
291            break_reason,
292        };
293        page_systems.push(system);
294
295        let page_is_full = page_systems.len() >= systems_per_page;
296        if page_is_full || explicit_page_break {
297            let page_break_reason = if explicit_page_break {
298                BreakReason::ExplicitPageBreak
299            } else if is_last_system {
300                BreakReason::EndOfScore
301            } else {
302                BreakReason::PageCapacity
303            };
304            pages.push(PageLayout {
305                address: PageAddress { page_index },
306                page_index,
307                page_number: match config.page_numbering {
308                    PageNumbering::None => None,
309                    PageNumbering::OneBased => Some(page_index + 1),
310                },
311                width_mm,
312                height_mm,
313                content_width_mm,
314                content_height_mm,
315                bleed_top_mm: config.bleed_top_mm,
316                bleed_right_mm: config.bleed_right_mm,
317                bleed_bottom_mm: config.bleed_bottom_mm,
318                bleed_left_mm: config.bleed_left_mm,
319                systems: std::mem::take(&mut page_systems),
320                break_reason: page_break_reason,
321            });
322            page_index += 1;
323        }
324    }
325    if !page_systems.is_empty() || pages.is_empty() {
326        pages.push(PageLayout {
327            address: PageAddress { page_index },
328            page_index,
329            page_number: match config.page_numbering {
330                PageNumbering::None => None,
331                PageNumbering::OneBased => Some(page_index + 1),
332            },
333            width_mm,
334            height_mm,
335            content_width_mm,
336            content_height_mm,
337            bleed_top_mm: config.bleed_top_mm,
338            bleed_right_mm: config.bleed_right_mm,
339            bleed_bottom_mm: config.bleed_bottom_mm,
340            bleed_left_mm: config.bleed_left_mm,
341            systems: page_systems,
342            break_reason: BreakReason::EndOfScore,
343        });
344    }
345
346    Ok(PrintLayoutResult {
347        contract_version: 5,
348        pages,
349    })
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355    use acorde_core::{Clef, Measure, Part, Score, Staff};
356
357    fn score_with_measures(count: usize) -> Score {
358        let mut score = Score::default();
359        let mut part = Part::new("Piano", "Pno.");
360        let mut staff = Staff::new(Clef::Treble);
361        staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
362        part.staves = vec![staff];
363        score.parts = vec![part];
364        score
365    }
366
367    #[test]
368    fn paginates_rows_and_preserves_measure_indices() {
369        let score = score_with_measures(5);
370        let result = compute_print_layout(
371            &score,
372            &PrintConfig {
373                measures_per_system: 2,
374                systems_per_page: Some(2),
375                ..PrintConfig::default()
376            },
377        )
378        .expect("valid print config");
379        assert_eq!(result.pages.len(), 2);
380        assert_eq!(
381            result.pages[0]
382                .systems
383                .iter()
384                .map(|s| s.measure_indices.clone())
385                .collect::<Vec<_>>(),
386            vec![vec![0, 1], vec![2, 3]]
387        );
388        assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
389        assert_eq!(result.pages[1].systems[0].page_index, 1);
390        assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
391        assert_eq!(
392            result.pages[1].systems[0].break_reason,
393            BreakReason::EndOfScore
394        );
395        assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
396    }
397
398    #[test]
399    fn forced_page_break_starts_next_system_on_next_page() {
400        let mut score = score_with_measures(3);
401        score.parts[0].staves[0].measures[0].page_break = true;
402        let result = compute_print_layout(
403            &score,
404            &PrintConfig {
405                measures_per_system: 3,
406                systems_per_page: Some(8),
407                ..PrintConfig::default()
408            },
409        )
410        .expect("valid print config");
411        assert_eq!(result.pages.len(), 2);
412        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
413        assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
414        assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
415        assert_eq!(
416            result.pages[0].systems[0].break_reason,
417            BreakReason::ExplicitPageBreak
418        );
419    }
420
421    #[test]
422    fn rejects_margins_that_leave_no_page_area() {
423        let score = score_with_measures(1);
424        let error = compute_print_layout(
425            &score,
426            &PrintConfig {
427                margin_left_mm: 200.0,
428                ..PrintConfig::default()
429            },
430        )
431        .expect_err("invalid page area");
432        assert_eq!(error, PrintLayoutError::NoUsablePageArea);
433    }
434
435    #[test]
436    fn safe_area_reduces_content_and_bleed_is_exposed() {
437        let score = score_with_measures(1);
438        let result = compute_print_layout(
439            &score,
440            &PrintConfig {
441                bleed_top_mm: 3.0,
442                bleed_right_mm: 3.0,
443                bleed_bottom_mm: 3.0,
444                bleed_left_mm: 3.0,
445                safe_top_mm: 5.0,
446                safe_right_mm: 6.0,
447                safe_bottom_mm: 7.0,
448                safe_left_mm: 8.0,
449                ..PrintConfig::default()
450            },
451        )
452        .expect("valid print config");
453        let page = &result.pages[0];
454        assert_eq!(result.contract_version, 5);
455        assert_eq!(page.bleed_left_mm, 3.0);
456        assert_eq!(page.content_width_mm, 210.0 - 14.0 - 14.0 - 8.0 - 6.0);
457        assert_eq!(page.content_height_mm, 297.0 - 16.0 - 16.0 - 5.0 - 7.0);
458        assert_eq!(page.systems[0].top_mm, 21.0);
459    }
460
461    #[test]
462    fn scale_changes_system_height_and_page_capacity() {
463        let score = score_with_measures(10);
464        let result = compute_print_layout(
465            &score,
466            &PrintConfig {
467                scale: 2.0,
468                measures_per_system: 1,
469                systems_per_page: None,
470                ..PrintConfig::default()
471            },
472        )
473        .expect("valid print config");
474        assert_eq!(result.pages[0].systems[0].height_mm, 48.0);
475        assert_eq!(result.pages[0].systems[1].top_mm, 64.0);
476        assert_eq!(result.pages.len(), 2);
477    }
478
479    #[test]
480    fn rejects_non_positive_scale() {
481        let score = score_with_measures(1);
482        let error = compute_print_layout(
483            &score,
484            &PrintConfig {
485                scale: 0.0,
486                ..PrintConfig::default()
487            },
488        )
489        .expect_err("invalid scale");
490        assert_eq!(error, PrintLayoutError::InvalidScale);
491    }
492
493    #[test]
494    fn page_numbering_is_configurable() {
495        let score = score_with_measures(5);
496        let numbered = compute_print_layout(
497            &score,
498            &PrintConfig {
499                measures_per_system: 1,
500                systems_per_page: Some(2),
501                ..PrintConfig::default()
502            },
503        )
504        .expect("valid print config");
505        assert_eq!(numbered.pages[0].page_number, Some(1));
506        assert_eq!(numbered.pages[1].page_number, Some(2));
507
508        let unnumbered = compute_print_layout(
509            &score,
510            &PrintConfig {
511                page_numbering: PageNumbering::None,
512                measures_per_system: 1,
513                systems_per_page: Some(2),
514                ..PrintConfig::default()
515            },
516        )
517        .expect("valid print config");
518        assert!(
519            unnumbered
520                .pages
521                .iter()
522                .all(|page| page.page_number.is_none())
523        );
524    }
525}