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/// Host-neutral inputs for deterministic page and system layout.
36///
37/// This contract describes physical page geometry only. It intentionally does not select
38/// fonts, emit PDF, access printers, or perform filesystem I/O.
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
40#[serde(default)]
41pub struct PrintConfig {
42    pub paper_size: PaperSize,
43    pub orientation: PageOrientation,
44    pub margin_top_mm: f32,
45    pub margin_right_mm: f32,
46    pub margin_bottom_mm: f32,
47    pub margin_left_mm: f32,
48    pub system_height_mm: f32,
49    pub measures_per_system: usize,
50    /// Override the number of systems per page. When omitted it is derived from the usable
51    /// page height and `system_height_mm`.
52    pub systems_per_page: Option<usize>,
53}
54
55impl Default for PrintConfig {
56    fn default() -> Self {
57        Self {
58            paper_size: PaperSize::A4,
59            orientation: PageOrientation::Portrait,
60            margin_top_mm: 16.0,
61            margin_right_mm: 14.0,
62            margin_bottom_mm: 16.0,
63            margin_left_mm: 14.0,
64            system_height_mm: 24.0,
65            measures_per_system: 4,
66            systems_per_page: None,
67        }
68    }
69}
70
71/// A logical system placed on a page.
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
73pub struct SystemLayout {
74    pub address: SystemAddress,
75    pub system_index: usize,
76    pub page_index: usize,
77    pub measure_indices: Vec<usize>,
78    pub top_mm: f32,
79    pub height_mm: f32,
80    pub break_reason: BreakReason,
81}
82
83/// Stable address of a page within one print-layout result.
84#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
85pub struct PageAddress {
86    pub page_index: usize,
87}
88
89/// Stable address of a system, including global and page-local positions.
90#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
91pub struct SystemAddress {
92    pub system_index: usize,
93    pub page_index: usize,
94    pub index_on_page: usize,
95}
96
97/// Explains why a system or page ended at its final measure.
98#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
99pub enum BreakReason {
100    MeasureCapacity,
101    ExplicitSystemBreak,
102    ExplicitPageBreak,
103    PageCapacity,
104    EndOfScore,
105}
106
107/// One page in a [`PrintLayoutResult`].
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct PageLayout {
110    pub address: PageAddress,
111    pub page_index: usize,
112    pub width_mm: f32,
113    pub height_mm: f32,
114    pub content_width_mm: f32,
115    pub content_height_mm: f32,
116    pub systems: Vec<SystemLayout>,
117    pub break_reason: BreakReason,
118}
119
120/// Deterministic page/system geometry for a score.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122pub struct PrintLayoutResult {
123    pub contract_version: u16,
124    pub pages: Vec<PageLayout>,
125}
126
127#[derive(Debug, thiserror::Error, PartialEq)]
128pub enum PrintLayoutError {
129    #[error("paper dimensions must be finite and greater than zero")]
130    InvalidPaperDimensions,
131    #[error("margins must be finite and non-negative")]
132    InvalidMargins,
133    #[error("system height must be finite and greater than zero")]
134    InvalidSystemHeight,
135    #[error("margins leave no usable page area")]
136    NoUsablePageArea,
137}
138
139/// Compute physical page and system placement without rendering or host integration.
140pub fn compute_print_layout(
141    score: &Score,
142    config: &PrintConfig,
143) -> Result<PrintLayoutResult, PrintLayoutError> {
144    let (mut width_mm, mut height_mm) = config.paper_size.dimensions_mm();
145    if !width_mm.is_finite() || !height_mm.is_finite() || width_mm <= 0.0 || height_mm <= 0.0 {
146        return Err(PrintLayoutError::InvalidPaperDimensions);
147    }
148    if matches!(config.orientation, PageOrientation::Landscape) {
149        std::mem::swap(&mut width_mm, &mut height_mm);
150    }
151
152    let margins = [
153        config.margin_top_mm,
154        config.margin_right_mm,
155        config.margin_bottom_mm,
156        config.margin_left_mm,
157    ];
158    if margins
159        .iter()
160        .any(|value| !value.is_finite() || *value < 0.0)
161    {
162        return Err(PrintLayoutError::InvalidMargins);
163    }
164    if !config.system_height_mm.is_finite() || config.system_height_mm <= 0.0 {
165        return Err(PrintLayoutError::InvalidSystemHeight);
166    }
167
168    let content_width_mm = width_mm - config.margin_left_mm - config.margin_right_mm;
169    let content_height_mm = height_mm - config.margin_top_mm - config.margin_bottom_mm;
170    if content_width_mm <= 0.0 || content_height_mm <= 0.0 {
171        return Err(PrintLayoutError::NoUsablePageArea);
172    }
173
174    let systems_per_page = config
175        .systems_per_page
176        .unwrap_or_else(|| {
177            (content_height_mm / config.system_height_mm)
178                .floor()
179                .max(1.0) as usize
180        })
181        .max(1);
182    let layout = compute_layout(
183        score,
184        &LayoutConfig {
185            measures_per_row: config.measures_per_system.max(1),
186            ..LayoutConfig::default()
187        },
188    );
189
190    let mut pages = Vec::new();
191    let mut page_systems = Vec::new();
192    let mut page_index = 0;
193    for (system_index, row) in layout.rows.iter().enumerate() {
194        let explicit_page_break = row.measure_indices.last().is_some_and(|&measure_index| {
195            score
196                .parts
197                .iter()
198                .flat_map(|part| part.staves.iter())
199                .filter_map(|staff| staff.measures.get(measure_index))
200                .any(|measure| measure.page_break)
201        });
202        let explicit_system_break = row.measure_indices.last().is_some_and(|&measure_index| {
203            score
204                .parts
205                .iter()
206                .flat_map(|part| part.staves.iter())
207                .filter_map(|staff| staff.measures.get(measure_index))
208                .any(|measure| measure.system_break)
209        });
210        let is_last_system = system_index + 1 == layout.rows.len();
211        let break_reason = if explicit_page_break {
212            BreakReason::ExplicitPageBreak
213        } else if explicit_system_break {
214            BreakReason::ExplicitSystemBreak
215        } else if is_last_system {
216            BreakReason::EndOfScore
217        } else {
218            BreakReason::MeasureCapacity
219        };
220        let system = SystemLayout {
221            address: SystemAddress {
222                system_index,
223                page_index,
224                index_on_page: page_systems.len(),
225            },
226            system_index,
227            page_index,
228            measure_indices: row.measure_indices.clone(),
229            top_mm: config.margin_top_mm + page_systems.len() as f32 * config.system_height_mm,
230            height_mm: config.system_height_mm,
231            break_reason,
232        };
233        page_systems.push(system);
234
235        let page_is_full = page_systems.len() >= systems_per_page;
236        if page_is_full || explicit_page_break {
237            let page_break_reason = if explicit_page_break {
238                BreakReason::ExplicitPageBreak
239            } else if is_last_system {
240                BreakReason::EndOfScore
241            } else {
242                BreakReason::PageCapacity
243            };
244            pages.push(PageLayout {
245                address: PageAddress { page_index },
246                page_index,
247                width_mm,
248                height_mm,
249                content_width_mm,
250                content_height_mm,
251                systems: std::mem::take(&mut page_systems),
252                break_reason: page_break_reason,
253            });
254            page_index += 1;
255        }
256    }
257    if !page_systems.is_empty() || pages.is_empty() {
258        pages.push(PageLayout {
259            address: PageAddress { page_index },
260            page_index,
261            width_mm,
262            height_mm,
263            content_width_mm,
264            content_height_mm,
265            systems: page_systems,
266            break_reason: BreakReason::EndOfScore,
267        });
268    }
269
270    Ok(PrintLayoutResult {
271        contract_version: 2,
272        pages,
273    })
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use acorde_core::{Clef, Measure, Part, Score, Staff};
280
281    fn score_with_measures(count: usize) -> Score {
282        let mut score = Score::default();
283        let mut part = Part::new("Piano", "Pno.");
284        let mut staff = Staff::new(Clef::Treble);
285        staff.measures = (0..count).map(|_| Measure::empty(4, 4)).collect();
286        part.staves = vec![staff];
287        score.parts = vec![part];
288        score
289    }
290
291    #[test]
292    fn paginates_rows_and_preserves_measure_indices() {
293        let score = score_with_measures(5);
294        let result = compute_print_layout(
295            &score,
296            &PrintConfig {
297                measures_per_system: 2,
298                systems_per_page: Some(2),
299                ..PrintConfig::default()
300            },
301        )
302        .expect("valid print config");
303        assert_eq!(result.pages.len(), 2);
304        assert_eq!(
305            result.pages[0]
306                .systems
307                .iter()
308                .map(|s| s.measure_indices.clone())
309                .collect::<Vec<_>>(),
310            vec![vec![0, 1], vec![2, 3]]
311        );
312        assert_eq!(result.pages[1].systems[0].measure_indices, vec![4]);
313        assert_eq!(result.pages[1].systems[0].page_index, 1);
314        assert_eq!(result.pages[1].systems[0].address.index_on_page, 0);
315        assert_eq!(
316            result.pages[1].systems[0].break_reason,
317            BreakReason::EndOfScore
318        );
319        assert_eq!(result.pages[0].break_reason, BreakReason::PageCapacity);
320    }
321
322    #[test]
323    fn forced_page_break_starts_next_system_on_next_page() {
324        let mut score = score_with_measures(3);
325        score.parts[0].staves[0].measures[0].page_break = true;
326        let result = compute_print_layout(
327            &score,
328            &PrintConfig {
329                measures_per_system: 3,
330                systems_per_page: Some(8),
331                ..PrintConfig::default()
332            },
333        )
334        .expect("valid print config");
335        assert_eq!(result.pages.len(), 2);
336        assert_eq!(result.pages[0].systems[0].measure_indices, vec![0]);
337        assert_eq!(result.pages[1].systems[0].measure_indices, vec![1, 2]);
338        assert_eq!(result.pages[0].break_reason, BreakReason::ExplicitPageBreak);
339        assert_eq!(
340            result.pages[0].systems[0].break_reason,
341            BreakReason::ExplicitPageBreak
342        );
343    }
344
345    #[test]
346    fn rejects_margins_that_leave_no_page_area() {
347        let score = score_with_measures(1);
348        let error = compute_print_layout(
349            &score,
350            &PrintConfig {
351                margin_left_mm: 200.0,
352                ..PrintConfig::default()
353            },
354        )
355        .expect_err("invalid page area");
356        assert_eq!(error, PrintLayoutError::NoUsablePageArea);
357    }
358}