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/// Color intent for a print-capable host.
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
44pub enum PrintColorPolicy {
45    #[default]
46    Monochrome,
47    Preserve,
48}
49
50/// Whether a host should expose crop marks at the configured bleed boundary.
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
52pub enum CropMarkPolicy {
53    #[default]
54    None,
55    BleedEdges,
56}
57
58/// How a host resolves fonts and notation glyph resources for print output.
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
60pub enum GlyphResourcePolicy {
61    /// Use the renderer's deterministic built-in vector glyphs where available.
62    #[default]
63    BuiltInVector,
64    /// Resolve a host-owned resource identified by this stable application key.
65    HostProvided(String),
66}
67
68/// Host-neutral inputs for deterministic page and system layout.
69///
70/// This contract describes physical page geometry only. It intentionally does not select
71/// fonts, emit PDF, access printers, or perform filesystem I/O.
72#[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    /// Content scale factor. `1.0` preserves the configured system height.
91    pub scale: f32,
92    pub measures_per_system: usize,
93    /// Override the number of systems per page. When omitted it is derived from the usable
94    /// page height and `system_height_mm`.
95    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/// A logical system placed on a page.
135#[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/// Stable address of a page within one print-layout result.
147#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
148pub struct PageAddress {
149    pub page_index: usize,
150}
151
152/// Stable address of a system, including global and page-local positions.
153#[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/// Explains why a system or page ended at its final measure.
161#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
162pub enum BreakReason {
163    MeasureCapacity,
164    ExplicitSystemBreak,
165    ExplicitPageBreak,
166    PageCapacity,
167    EndOfScore,
168}
169
170/// One page in a [`PrintLayoutResult`].
171#[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/// Deterministic page/system geometry for a score.
195#[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
215/// Compute physical page and system placement without rendering or host integration.
216pub 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}