Skip to main content

appcore_filemaker/
units.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: units.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 units contracts and behavior for this crate.
12
13use std::fmt;
14use std::str::FromStr;
15
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17
18use crate::{ErrorCode, FileMakerError, Result};
19
20/// One millionth of a typographic point, used as geometry truth.
21#[derive(Clone, Copy, Default, Eq, Ord, PartialEq, PartialOrd, Hash)]
22pub struct Unit(i64);
23
24impl Unit {
25    /// Fixed-point scale per point.
26    pub const PER_POINT: i64 = 1_000_000;
27    /// Zero length.
28    pub const ZERO: Self = Self(0);
29
30    /// Creates units from the raw fixed-point representation.
31    #[must_use]
32    pub const fn from_raw(raw: i64) -> Self {
33        Self(raw)
34    }
35
36    /// Returns the raw fixed-point representation.
37    #[must_use]
38    pub const fn raw(self) -> i64 {
39        self.0
40    }
41
42    /// Creates an exact integer point measurement.
43    pub fn points(points: i64) -> Result<Self> {
44        points
45            .checked_mul(Self::PER_POINT)
46            .map(Self)
47            .ok_or_else(|| invalid_unit("point conversion overflow"))
48    }
49
50    /// Converts a rational point measurement using half-away-from-zero rounding.
51    pub fn from_ratio(numerator: i128, denominator: i128) -> Result<Self> {
52        if denominator <= 0 {
53            return Err(invalid_unit("unit denominator must be positive"));
54        }
55        let scaled = numerator
56            .checked_mul(i128::from(Self::PER_POINT))
57            .ok_or_else(|| invalid_unit("unit conversion overflow"))?;
58        let adjustment = denominator / 2;
59        let rounded = if scaled >= 0 {
60            scaled.checked_add(adjustment)
61        } else {
62            scaled.checked_sub(adjustment)
63        }
64        .ok_or_else(|| invalid_unit("unit rounding overflow"))?
65            / denominator;
66        i64::try_from(rounded)
67            .map(Self)
68            .map_err(|_| invalid_unit("unit is outside the supported range"))
69    }
70
71    /// Checked addition.
72    pub fn checked_add(self, other: Self) -> Result<Self> {
73        self.0
74            .checked_add(other.0)
75            .map(Self)
76            .ok_or_else(|| invalid_unit("geometry addition overflow"))
77    }
78
79    /// Checked subtraction.
80    pub fn checked_sub(self, other: Self) -> Result<Self> {
81        self.0
82            .checked_sub(other.0)
83            .map(Self)
84            .ok_or_else(|| invalid_unit("geometry subtraction overflow"))
85    }
86
87    /// Checked multiplication by a fixed-point millionth ratio.
88    pub fn checked_scale(self, millionths: i64) -> Result<Self> {
89        let product = i128::from(self.0)
90            .checked_mul(i128::from(millionths))
91            .ok_or_else(|| invalid_unit("geometry scale overflow"))?;
92        let rounded = if product >= 0 {
93            product + 500_000
94        } else {
95            product - 500_000
96        } / 1_000_000;
97        i64::try_from(rounded)
98            .map(Self)
99            .map_err(|_| invalid_unit("scaled geometry is outside the supported range"))
100    }
101
102    /// Returns a floating-point value for output APIs only.
103    #[must_use]
104    pub fn as_points_f64(self) -> f64 {
105        self.0 as f64 / Self::PER_POINT as f64
106    }
107}
108
109impl fmt::Debug for Unit {
110    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
111        write!(formatter, "{}pt", self.as_points_f64())
112    }
113}
114
115impl Serialize for Unit {
116    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
117    where
118        S: Serializer,
119    {
120        serializer.serialize_i64(self.0)
121    }
122}
123
124impl<'de> Deserialize<'de> for Unit {
125    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
126    where
127        D: Deserializer<'de>,
128    {
129        i64::deserialize(deserializer).map(Self)
130    }
131}
132
133/// A source length resolved against explicit layout context.
134#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub enum Length {
136    /// Absolute fixed-point length.
137    Absolute(Unit),
138    /// Parts per million of the containing dimension, parsed from `%` or a
139    /// bounded `0..=1` `norm`/`normalized` source spelling.
140    Percent(i64),
141    /// Logical units interpreted by caller-selected context.
142    Logical(i64),
143    /// Automatic measurement.
144    Auto,
145}
146
147impl Length {
148    /// Resolves the value using explicit percentage and logical bases.
149    pub fn resolve(self, percent_base: Unit, logical_unit: Unit) -> Result<Option<Unit>> {
150        match self {
151            Self::Absolute(value) => Ok(Some(value)),
152            Self::Percent(value) => percent_base.checked_scale(value).map(Some),
153            Self::Logical(value) => logical_unit.checked_scale(value).map(Some),
154            Self::Auto => Ok(None),
155        }
156    }
157}
158
159impl FromStr for Length {
160    type Err = FileMakerError;
161
162    fn from_str(value: &str) -> Result<Self> {
163        let value = value.trim();
164        if value == "auto" {
165            return Ok(Self::Auto);
166        }
167        if let Some(raw) = value.strip_suffix("logical-ppm") {
168            return raw
169                .parse::<i64>()
170                .map(Self::Logical)
171                .map_err(|_| invalid_unit("invalid logical fixed-point length"));
172        }
173        if let Some(raw) = value.strip_suffix("ppm") {
174            return raw
175                .parse::<i64>()
176                .map(Self::Percent)
177                .map_err(|_| invalid_unit("invalid percentage fixed-point length"));
178        }
179        if let Some(raw) = value.strip_suffix("raw") {
180            return raw
181                .parse::<i64>()
182                .map(Unit::from_raw)
183                .map(Self::Absolute)
184                .map_err(|_| invalid_unit("invalid raw fixed-point length"));
185        }
186        let (number, suffix) = split_number_suffix(value)?;
187        let (numerator, decimal_scale) = parse_decimal(number)?;
188        let absolute = match suffix {
189            "pt" => Unit::from_ratio(numerator, decimal_scale)?,
190            "px" => Unit::from_ratio(numerator * 3, decimal_scale * 4)?,
191            "in" => Unit::from_ratio(numerator * 72, decimal_scale)?,
192            "mm" => Unit::from_ratio(numerator * 360, decimal_scale * 127)?,
193            "cm" => Unit::from_ratio(numerator * 3_600, decimal_scale * 127)?,
194            "%" => {
195                let ppm = rounded_i64(numerator * 10_000, decimal_scale)?;
196                return Ok(Self::Percent(ppm));
197            }
198            "norm" | "normalized" => {
199                let ppm = rounded_i64(numerator * 1_000_000, decimal_scale)?;
200                if !(0..=1_000_000).contains(&ppm) {
201                    return Err(invalid_unit("normalized length must be between 0 and 1"));
202                }
203                return Ok(Self::Percent(ppm));
204            }
205            "lu" | "logical" => {
206                let logical = rounded_i64(numerator * 1_000_000, decimal_scale)?;
207                return Ok(Self::Logical(logical));
208            }
209            _ => return Err(invalid_unit(format!("unsupported unit suffix `{suffix}`"))),
210        };
211        Ok(Self::Absolute(absolute))
212    }
213}
214
215impl Serialize for Length {
216    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
217    where
218        S: Serializer,
219    {
220        match self {
221            Self::Absolute(value) => serializer.serialize_str(&format!("{}raw", value.raw())),
222            Self::Percent(value) => serializer.serialize_str(&format!("{value}ppm")),
223            Self::Logical(value) => serializer.serialize_str(&format!("{value}logical-ppm")),
224            Self::Auto => serializer.serialize_str("auto"),
225        }
226    }
227}
228
229impl<'de> Deserialize<'de> for Length {
230    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
231    where
232        D: Deserializer<'de>,
233    {
234        let value = String::deserialize(deserializer)?;
235        value.parse().map_err(serde::de::Error::custom)
236    }
237}
238
239fn split_number_suffix(value: &str) -> Result<(&str, &str)> {
240    let split = value
241        .find(|character: char| character.is_ascii_alphabetic() || character == '%')
242        .ok_or_else(|| invalid_unit("length requires an explicit unit"))?;
243    let (number, suffix) = value.split_at(split);
244    if number.is_empty() || suffix.is_empty() {
245        return Err(invalid_unit("length requires a number and unit"));
246    }
247    Ok((number, suffix))
248}
249
250fn parse_decimal(value: &str) -> Result<(i128, i128)> {
251    let negative = value.starts_with('-');
252    let unsigned = value.strip_prefix(['-', '+']).unwrap_or(value);
253    let mut parts = unsigned.split('.');
254    let whole = parts.next().unwrap_or_default();
255    let fractional = parts.next();
256    if parts.next().is_some()
257        || whole.is_empty()
258        || !whole.bytes().all(|byte| byte.is_ascii_digit())
259        || fractional.is_some_and(|part| !part.bytes().all(|byte| byte.is_ascii_digit()))
260    {
261        return Err(invalid_unit("invalid decimal length"));
262    }
263    let fractional = fractional.unwrap_or_default();
264    let scale = 10_i128
265        .checked_pow(u32::try_from(fractional.len()).map_err(|_| invalid_unit("decimal too long"))?)
266        .ok_or_else(|| invalid_unit("decimal precision overflow"))?;
267    let digits = format!("{whole}{fractional}")
268        .parse::<i128>()
269        .map_err(|_| invalid_unit("decimal length overflow"))?;
270    Ok((if negative { -digits } else { digits }, scale))
271}
272
273fn rounded_i64(numerator: i128, denominator: i128) -> Result<i64> {
274    let adjusted = if numerator >= 0 {
275        numerator + denominator / 2
276    } else {
277        numerator - denominator / 2
278    };
279    i64::try_from(adjusted / denominator).map_err(|_| invalid_unit("ratio overflow"))
280}
281
282fn invalid_unit(message: impl Into<String>) -> FileMakerError {
283    FileMakerError::new(ErrorCode::GeometryInvalid, message)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn converts_physical_units_deterministically() {
292        assert_eq!(
293            "1in".parse::<Length>().unwrap(),
294            Length::Absolute(Unit::points(72).unwrap())
295        );
296        assert_eq!(
297            "25.4mm".parse::<Length>().unwrap(),
298            Length::Absolute(Unit::points(72).unwrap())
299        );
300        assert_eq!(
301            "96px".parse::<Length>().unwrap(),
302            Length::Absolute(Unit::points(72).unwrap())
303        );
304    }
305
306    #[test]
307    fn rejects_implicit_units() {
308        assert_eq!(
309            "12".parse::<Length>().unwrap_err().code(),
310            ErrorCode::GeometryInvalid
311        );
312    }
313
314    #[test]
315    fn normalized_coordinates_resolve_against_the_percentage_context() {
316        let quarter: Length = "0.25normalized".parse().unwrap();
317        assert_eq!(quarter, Length::Percent(250_000));
318        assert_eq!(
319            quarter
320                .resolve(Unit::points(200).unwrap(), Unit::points(1).unwrap())
321                .unwrap(),
322            Some(Unit::points(50).unwrap())
323        );
324        assert_eq!(
325            "1.1norm".parse::<Length>().unwrap_err().code(),
326            ErrorCode::GeometryInvalid
327        );
328    }
329}