Skip to main content

appcore_filemaker/
page.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: page.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/30 05:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/30 05:00:00 by dnettoRaw
8//      ###########      S: 1.0.2-rc
9// =============================================================================
10
11//! Defines bounded page contracts and behavior for this crate.
12
13use serde::{Deserialize, Serialize};
14
15use crate::{Insets, Size};
16
17/// Semantic page role used by paginated templates.
18#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum PageRole {
21    /// First page.
22    First,
23    /// Middle/continuation page.
24    Continuation,
25    /// Last page.
26    Last,
27    /// Master background/header/footer.
28    Master,
29}
30
31/// Semantic band within a page layer.
32#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum PageBand {
35    /// Paint behind body content.
36    Background,
37    /// Repeating or role-specific header content.
38    Header,
39    /// Repeating or role-specific footer content.
40    Footer,
41}
42
43/// Placement assigned to a root element owned by a page layer.
44#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
45pub struct PagePlacement {
46    /// Master, first, continuation, or last layer.
47    pub role: PageRole,
48    /// Background, header, or footer band.
49    pub band: PageBand,
50}
51
52/// Resolved page template metadata.
53#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
54pub struct PageTemplate {
55    /// Stable template name.
56    pub name: String,
57    /// Semantic role.
58    pub role: PageRole,
59    /// Trim size.
60    pub size: Size,
61    /// Content margins.
62    pub margin: Insets,
63    /// Bleed extents.
64    pub bleed: Insets,
65    /// Safe-area inset.
66    pub safe: Insets,
67    /// Whether crop marks are requested at compatible export.
68    pub crop_marks: bool,
69}
70
71impl PageTemplate {
72    /// Returns the content rectangle after applying margins to the trim box.
73    pub fn content_bounds(&self) -> crate::Result<crate::Rect> {
74        let width = self
75            .size
76            .width
77            .checked_sub(self.margin.left)?
78            .checked_sub(self.margin.right)?;
79        let height = self
80            .size
81            .height
82            .checked_sub(self.margin.top)?
83            .checked_sub(self.margin.bottom)?;
84        crate::Rect::new(self.margin.left, self.margin.top, width, height)
85    }
86
87    /// Returns the safe-area rectangle inside the trim box.
88    pub fn safe_bounds(&self) -> crate::Result<crate::Rect> {
89        let width = self
90            .size
91            .width
92            .checked_sub(self.safe.left)?
93            .checked_sub(self.safe.right)?;
94        let height = self
95            .size
96            .height
97            .checked_sub(self.safe.top)?
98            .checked_sub(self.safe.bottom)?;
99        crate::Rect::new(self.safe.left, self.safe.top, width, height)
100    }
101}
102
103/// Optional first/continuation/last/master page roles.
104#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
105pub struct PageTemplateSet {
106    /// First-page override.
107    pub first: Option<PageTemplate>,
108    /// Continuation page.
109    pub continuation: Option<PageTemplate>,
110    /// Last-page override.
111    pub last: Option<PageTemplate>,
112    /// Master composited beneath each page.
113    pub master: Option<PageTemplate>,
114}
115
116impl PageTemplateSet {
117    /// Builds all semantic page templates from one validated geometry contract.
118    #[must_use]
119    pub fn from_base(base: &PageTemplate) -> Self {
120        let with_role = |role| {
121            let mut template = base.clone();
122            template.role = role;
123            template
124        };
125        Self {
126            first: Some(with_role(PageRole::First)),
127            continuation: Some(with_role(PageRole::Continuation)),
128            last: Some(with_role(PageRole::Last)),
129            master: Some(with_role(PageRole::Master)),
130        }
131    }
132
133    /// Selects the deterministic template for a zero-based page.
134    #[must_use]
135    pub fn select(&self, index: usize, total: usize) -> Option<&PageTemplate> {
136        if index == 0 {
137            self.first.as_ref().or(self.continuation.as_ref())
138        } else if index + 1 == total {
139            self.last.as_ref().or(self.continuation.as_ref())
140        } else {
141            self.continuation.as_ref()
142        }
143    }
144
145    /// Replaces page-number placeholders without locale-dependent formatting.
146    #[must_use]
147    pub fn number_text(source: &str, index: usize, total: usize) -> String {
148        source
149            .replace("{page}", &(index + 1).to_string())
150            .replace("{pages}", &total.to_string())
151    }
152
153    /// Returns whether a layer role is active on this physical page.
154    #[must_use]
155    pub fn role_is_active(role: PageRole, index: usize, total: usize) -> bool {
156        match role {
157            PageRole::Master => true,
158            PageRole::First => index == 0,
159            PageRole::Continuation => index > 0 && index + 1 < total,
160            PageRole::Last => total > 1 && index + 1 == total,
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn page_numbers_and_roles_are_deterministic() {
171        assert_eq!(
172            PageTemplateSet::number_text("Page {page}/{pages}", 1, 3),
173            "Page 2/3"
174        );
175        assert!(PageTemplateSet::role_is_active(PageRole::Master, 2, 3));
176        assert!(PageTemplateSet::role_is_active(
177            PageRole::Continuation,
178            1,
179            3
180        ));
181        assert!(!PageTemplateSet::role_is_active(PageRole::Last, 0, 1));
182    }
183}