Skip to main content

appcore_filemaker/
preset.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: preset.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 preset contracts and behavior for this crate.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::{ErrorCode, FileMakerError, Result, Size, Unit};
18
19/// Page/canvas orientation applied independently from its preset.
20#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum Orientation {
23    /// Preset width then height.
24    #[default]
25    Portrait,
26    /// Preset dimensions swapped.
27    Landscape,
28}
29
30/// Versioned named size.
31#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
32#[serde(deny_unknown_fields)]
33pub struct Preset {
34    /// Stable preset name.
35    pub name: String,
36    /// Registry version that defines its meaning.
37    pub version: u16,
38    /// Portrait/reference size.
39    pub size: Size,
40}
41
42impl Preset {
43    /// Applies orientation without mutating the registry value.
44    #[must_use]
45    pub fn oriented_size(&self, orientation: Orientation) -> Size {
46        match orientation {
47            Orientation::Portrait => self.size,
48            Orientation::Landscape => Size {
49                width: self.size.height,
50                height: self.size.width,
51            },
52        }
53    }
54}
55
56/// Deterministically ordered built-in and custom preset registry.
57#[derive(Clone, Debug)]
58pub struct PresetRegistry {
59    presets: BTreeMap<String, Preset>,
60}
61
62impl Default for PresetRegistry {
63    fn default() -> Self {
64        // appcore-norm: allow(clippy::expect_used) reason: the private built-in table is covered exhaustively by registry tests
65        Self::v1().expect("built-in preset constants are valid")
66    }
67}
68
69impl PresetRegistry {
70    /// Creates the version-one registry.
71    pub fn v1() -> Result<Self> {
72        let mut registry = Self {
73            presets: BTreeMap::new(),
74        };
75        for (name, width, height, unit) in BUILT_INS {
76            let size = match *unit {
77                "mm" => Size::new(mm(*width)?, mm(*height)?)?,
78                "in" => Size::new(inches(*width)?, inches(*height)?)?,
79                "mil" => Size::new(milli_inches(*width)?, milli_inches(*height)?)?,
80                "px" => Size::new(px(*width)?, px(*height)?)?,
81                _ => return Err(invalid_preset("invalid built-in preset unit")),
82            };
83            registry.register(Preset {
84                name: (*name).to_owned(),
85                version: 1,
86                size,
87            })?;
88        }
89        Ok(registry)
90    }
91
92    /// Registers a custom preset without replacing an existing name.
93    pub fn register(&mut self, preset: Preset) -> Result<()> {
94        if !valid_name(&preset.name) || preset.version == 0 {
95            return Err(invalid_preset("invalid preset name or version"));
96        }
97        if self.presets.contains_key(&preset.name) {
98            return Err(invalid_preset(format!(
99                "duplicate preset `{}`",
100                preset.name
101            )));
102        }
103        self.presets.insert(preset.name.clone(), preset);
104        Ok(())
105    }
106
107    /// Resolves a preset by exact stable name.
108    pub fn get(&self, name: &str) -> Result<&Preset> {
109        self.presets
110            .get(name)
111            .ok_or_else(|| invalid_preset(format!("unknown preset `{name}`")))
112    }
113
114    /// Iterates in stable lexical order.
115    pub fn iter(&self) -> impl Iterator<Item = (&str, &Preset)> {
116        self.presets
117            .iter()
118            .map(|(name, preset)| (name.as_str(), preset))
119    }
120}
121
122fn valid_name(name: &str) -> bool {
123    !name.is_empty()
124        && name.len() <= 64
125        && name
126            .bytes()
127            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
128}
129
130fn mm(value: i64) -> Result<Unit> {
131    Unit::from_ratio(i128::from(value) * 360, 127)
132}
133
134fn inches(value_hundredths: i64) -> Result<Unit> {
135    Unit::from_ratio(i128::from(value_hundredths) * 72, 100)
136}
137
138fn milli_inches(value_thousandths: i64) -> Result<Unit> {
139    Unit::from_ratio(i128::from(value_thousandths) * 72, 1_000)
140}
141
142fn px(value: i64) -> Result<Unit> {
143    Unit::from_ratio(i128::from(value) * 3, 4)
144}
145
146fn invalid_preset(message: impl Into<String>) -> FileMakerError {
147    FileMakerError::new(ErrorCode::GeometryInvalid, message)
148}
149
150const BUILT_INS: &[(&str, i64, i64, &str)] = &[
151    ("A0", 841, 1189, "mm"),
152    ("A1", 594, 841, "mm"),
153    ("A2", 420, 594, "mm"),
154    ("A3", 297, 420, "mm"),
155    ("A4", 210, 297, "mm"),
156    ("A5", 148, 210, "mm"),
157    ("A6", 105, 148, "mm"),
158    ("A7", 74, 105, "mm"),
159    ("A8", 52, 74, "mm"),
160    ("A9", 37, 52, "mm"),
161    ("A10", 26, 37, "mm"),
162    ("B0", 1000, 1414, "mm"),
163    ("B1", 707, 1000, "mm"),
164    ("B2", 500, 707, "mm"),
165    ("B3", 353, 500, "mm"),
166    ("B4", 250, 353, "mm"),
167    ("B5", 176, 250, "mm"),
168    ("B6", 125, 176, "mm"),
169    ("B7", 88, 125, "mm"),
170    ("B8", 62, 88, "mm"),
171    ("B9", 44, 62, "mm"),
172    ("B10", 31, 44, "mm"),
173    ("C0", 917, 1297, "mm"),
174    ("C1", 648, 917, "mm"),
175    ("C2", 458, 648, "mm"),
176    ("C3", 324, 458, "mm"),
177    ("C4", 229, 324, "mm"),
178    ("C5", 162, 229, "mm"),
179    ("C6", 114, 162, "mm"),
180    ("C7", 81, 114, "mm"),
181    ("C8", 57, 81, "mm"),
182    ("C9", 40, 57, "mm"),
183    ("C10", 28, 40, "mm"),
184    ("Letter", 850, 1100, "in"),
185    ("Legal", 850, 1400, "in"),
186    ("Tabloid", 1100, 1700, "in"),
187    ("Ledger", 1700, 1100, "in"),
188    ("Executive", 725, 1050, "in"),
189    ("HD", 1280, 720, "px"),
190    ("FHD", 1920, 1080, "px"),
191    ("QHD", 2560, 1440, "px"),
192    ("UHD", 3840, 2160, "px"),
193    ("5K", 5120, 2880, "px"),
194    ("8K", 7680, 4320, "px"),
195    ("Ratio1x1", 1000, 1000, "px"),
196    ("Ratio4x3", 1200, 900, "px"),
197    ("Ratio3x2", 1200, 800, "px"),
198    ("Ratio16x9", 1600, 900, "px"),
199    ("Ratio21x9", 2100, 900, "px"),
200    ("Photo4x6", 400, 600, "in"),
201    ("Photo5x7", 500, 700, "in"),
202    ("Photo8x10", 800, 1000, "in"),
203    ("Photo10x15cm", 100, 150, "mm"),
204    ("EnvelopeDL", 110, 220, "mm"),
205    ("EnvelopeC5", 162, 229, "mm"),
206    ("EnvelopeC6", 114, 162, "mm"),
207    ("EnvelopeMonarch", 3875, 7500, "mil"),
208    ("Label4x6", 400, 600, "in"),
209    ("LabelAvery5160", 2625, 1000, "mil"),
210    ("Thermal58", 58, 200, "mm"),
211    ("Thermal80", 80, 200, "mm"),
212    ("InstagramPost-v1", 1080, 1080, "px"),
213    ("InstagramStory-v1", 1080, 1920, "px"),
214    ("LinkedInPost-v1", 1200, 627, "px"),
215];
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn orientation_is_separate_from_preset() {
223        let registry = PresetRegistry::v1().unwrap();
224        let a4 = registry.get("A4").unwrap();
225        let landscape = a4.oriented_size(Orientation::Landscape);
226        assert_eq!(landscape.width, a4.size.height);
227        assert_eq!(landscape.height, a4.size.width);
228    }
229
230    #[test]
231    fn v1_contains_all_declared_preset_families() {
232        let registry = PresetRegistry::v1().unwrap();
233        for name in [
234            "A10",
235            "B0",
236            "B10",
237            "C0",
238            "C10",
239            "Ratio16x9",
240            "Photo4x6",
241            "EnvelopeDL",
242            "LabelAvery5160",
243            "Thermal80",
244            "InstagramStory-v1",
245        ] {
246            assert_eq!(registry.get(name).unwrap().version, 1);
247        }
248    }
249}