appcore_filemaker/
units.rs1use std::fmt;
14use std::str::FromStr;
15
16use serde::{Deserialize, Deserializer, Serialize, Serializer};
17
18use crate::{ErrorCode, FileMakerError, Result};
19
20#[derive(Clone, Copy, Default, Eq, Ord, PartialEq, PartialOrd, Hash)]
22pub struct Unit(i64);
23
24impl Unit {
25 pub const PER_POINT: i64 = 1_000_000;
27 pub const ZERO: Self = Self(0);
29
30 #[must_use]
32 pub const fn from_raw(raw: i64) -> Self {
33 Self(raw)
34 }
35
36 #[must_use]
38 pub const fn raw(self) -> i64 {
39 self.0
40 }
41
42 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 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 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 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 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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
135pub enum Length {
136 Absolute(Unit),
138 Percent(i64),
141 Logical(i64),
143 Auto,
145}
146
147impl Length {
148 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}