hwpforge_blueprint/
serde_helpers.rs

1//! Custom serde helpers for human-friendly YAML values.
2//!
3//! Provides parsing and serialization for:
4//! - Dimensions: `"16pt"`, `"20mm"`, `"1in"` → [`HwpUnit`]
5//! - Percentages: `"160%"` → `f64`
6//! - Colors: `"#RRGGBB"` → [`Color`]
7
8use hwpforge_foundation::{Color, HwpUnit};
9use serde::Deserialize;
10
11use crate::error::BlueprintError;
12
13// ---------------------------------------------------------------------------
14// Serde bridge functions (used via #[serde(serialize_with/deserialize_with)])
15//
16// These are pub(crate) so that style.rs and template.rs can reference them
17// in serde attributes as `crate::serde_helpers::ser_dim`, etc.
18// ---------------------------------------------------------------------------
19
20/// Serializes an `HwpUnit` as a dimension string (e.g. `"16pt"`).
21pub(crate) fn ser_dim<S: serde::Serializer>(unit: &HwpUnit, s: S) -> Result<S::Ok, S::Error> {
22    s.serialize_str(&format_dimension_pt(*unit))
23}
24
25/// Deserializes a dimension string into `HwpUnit`.
26pub(crate) fn de_dim<'de, D: serde::Deserializer<'de>>(d: D) -> Result<HwpUnit, D::Error> {
27    let v = String::deserialize(d)?;
28    parse_dimension(&v).map_err(serde::de::Error::custom)
29}
30
31/// Serializes an `Option<HwpUnit>` as a dimension string or null.
32pub(crate) fn ser_dim_opt<S: serde::Serializer>(
33    u: &Option<HwpUnit>,
34    s: S,
35) -> Result<S::Ok, S::Error> {
36    match u {
37        Some(v) => s.serialize_str(&format_dimension_pt(*v)),
38        None => s.serialize_none(),
39    }
40}
41
42/// Deserializes an optional dimension string into `Option<HwpUnit>`.
43pub(crate) fn de_dim_opt<'de, D: serde::Deserializer<'de>>(
44    d: D,
45) -> Result<Option<HwpUnit>, D::Error> {
46    let opt: Option<String> = Option::deserialize(d)?;
47    match opt {
48        Some(v) => parse_dimension(&v).map(Some).map_err(serde::de::Error::custom),
49        None => Ok(None),
50    }
51}
52
53/// Serializes an `Option<f64>` as a percentage string or null.
54pub(crate) fn ser_pct_opt<S: serde::Serializer>(v: &Option<f64>, s: S) -> Result<S::Ok, S::Error> {
55    match v {
56        Some(val) => s.serialize_str(&format_percentage(*val)),
57        None => s.serialize_none(),
58    }
59}
60
61/// Deserializes an optional percentage string into `Option<f64>`.
62pub(crate) fn de_pct_opt<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<f64>, D::Error> {
63    let opt: Option<String> = Option::deserialize(d)?;
64    match opt {
65        Some(v) => parse_percentage(&v).map(Some).map_err(serde::de::Error::custom),
66        None => Ok(None),
67    }
68}
69
70/// Serializes a `Color` as a `#RRGGBB` string.
71pub(crate) fn ser_color<S: serde::Serializer>(c: &Color, s: S) -> Result<S::Ok, S::Error> {
72    s.serialize_str(&format_color(*c))
73}
74
75/// Deserializes a `#RRGGBB` string into `Color`.
76pub(crate) fn de_color<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Color, D::Error> {
77    let v = String::deserialize(d)?;
78    parse_color(&v).map_err(serde::de::Error::custom)
79}
80
81/// Serializes an `Option<Color>` as a `#RRGGBB` string or null.
82pub(crate) fn ser_color_opt<S: serde::Serializer>(
83    c: &Option<Color>,
84    s: S,
85) -> Result<S::Ok, S::Error> {
86    match c {
87        Some(v) => s.serialize_str(&format_color(*v)),
88        None => s.serialize_none(),
89    }
90}
91
92/// Deserializes an optional `#RRGGBB` string into `Option<Color>`.
93pub(crate) fn de_color_opt<'de, D: serde::Deserializer<'de>>(
94    d: D,
95) -> Result<Option<Color>, D::Error> {
96    let opt: Option<String> = Option::deserialize(d)?;
97    match opt {
98        Some(v) => parse_color(&v).map(Some).map_err(serde::de::Error::custom),
99        None => Ok(None),
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Dimension parsing: "16pt", "20mm", "1in" → HwpUnit
105// ---------------------------------------------------------------------------
106
107/// Parses a dimension string into [`HwpUnit`].
108///
109/// Supported suffixes (case-insensitive):
110/// - `pt` — points (1pt = 100 HwpUnit)
111/// - `mm` — millimeters (1mm ≈ 2.835pt)
112/// - `in` — inches (1in = 72pt)
113///
114/// Also accepts a plain number as raw HwpUnit value.
115pub fn parse_dimension(s: &str) -> Result<HwpUnit, BlueprintError> {
116    let s = s.trim();
117    if s.is_empty() {
118        return Err(BlueprintError::InvalidDimension { value: s.to_string() });
119    }
120
121    let lower = s.to_ascii_lowercase();
122
123    if let Some(num_str) = lower.strip_suffix("pt") {
124        let pt: f64 = num_str
125            .trim()
126            .parse()
127            .map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })?;
128        HwpUnit::from_pt(pt).map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })
129    } else if let Some(num_str) = lower.strip_suffix("mm") {
130        let mm: f64 = num_str
131            .trim()
132            .parse()
133            .map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })?;
134        HwpUnit::from_mm(mm).map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })
135    } else if let Some(num_str) = lower.strip_suffix("in") {
136        let inches: f64 = num_str
137            .trim()
138            .parse()
139            .map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })?;
140        HwpUnit::from_inch(inches)
141            .map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })
142    } else {
143        // Try raw integer (HwpUnit value)
144        let raw: i32 =
145            s.parse().map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })?;
146        HwpUnit::new(raw).map_err(|_| BlueprintError::InvalidDimension { value: s.to_string() })
147    }
148}
149
150/// Formats an [`HwpUnit`] back to a pt string for YAML serialization.
151///
152/// Uses 2 decimal places for non-integer values to preserve full precision
153/// (1 HwpUnit = 0.01pt).
154pub fn format_dimension_pt(unit: HwpUnit) -> String {
155    let pt = unit.to_pt();
156    if (pt - pt.round()).abs() < f64::EPSILON {
157        format!("{}pt", pt as i64)
158    } else {
159        format!("{pt:.2}pt")
160    }
161}
162
163// ---------------------------------------------------------------------------
164// Percentage parsing: "160%" → f64
165// ---------------------------------------------------------------------------
166
167/// Parses a percentage string into `f64`.
168///
169/// Rejects negative values. Examples: `"160%"` → `160.0`, `"100%"` → `100.0`
170pub fn parse_percentage(s: &str) -> Result<f64, BlueprintError> {
171    let s = s.trim();
172    let num_str = s
173        .strip_suffix('%')
174        .ok_or_else(|| BlueprintError::InvalidPercentage { value: s.to_string() })?;
175    let value: f64 = num_str
176        .trim()
177        .parse()
178        .map_err(|_| BlueprintError::InvalidPercentage { value: s.to_string() })?;
179    if value < 0.0 {
180        return Err(BlueprintError::InvalidPercentage { value: s.to_string() });
181    }
182    Ok(value)
183}
184
185/// Formats a percentage value back to string.
186pub fn format_percentage(value: f64) -> String {
187    if (value - value.round()).abs() < f64::EPSILON {
188        format!("{}%", value as i64)
189    } else {
190        format!("{value:.1}%")
191    }
192}
193
194// ---------------------------------------------------------------------------
195// Color parsing: "#RRGGBB" → Color
196// ---------------------------------------------------------------------------
197
198/// Parses a color string in `#RRGGBB` format into [`Color`].
199pub fn parse_color(s: &str) -> Result<Color, BlueprintError> {
200    let s = s.trim();
201    let hex =
202        s.strip_prefix('#').ok_or_else(|| BlueprintError::InvalidColor { value: s.to_string() })?;
203
204    if hex.len() != 6 {
205        return Err(BlueprintError::InvalidColor { value: s.to_string() });
206    }
207
208    let r = u8::from_str_radix(&hex[0..2], 16)
209        .map_err(|_| BlueprintError::InvalidColor { value: s.to_string() })?;
210    let g = u8::from_str_radix(&hex[2..4], 16)
211        .map_err(|_| BlueprintError::InvalidColor { value: s.to_string() })?;
212    let b = u8::from_str_radix(&hex[4..6], 16)
213        .map_err(|_| BlueprintError::InvalidColor { value: s.to_string() })?;
214
215    Ok(Color::from_rgb(r, g, b))
216}
217
218/// Formats a [`Color`] as `#RRGGBB`.
219pub fn format_color(color: Color) -> String {
220    let (r, g, b) = color.to_rgb();
221    format!("#{r:02X}{g:02X}{b:02X}")
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use pretty_assertions::assert_eq;
228
229    // -----------------------------------------------------------------------
230    // Dimension parsing
231    // -----------------------------------------------------------------------
232
233    #[test]
234    fn parse_dimension_pt() {
235        let unit = parse_dimension("16pt").unwrap();
236        assert_eq!(unit, HwpUnit::from_pt(16.0).unwrap());
237    }
238
239    #[test]
240    fn parse_dimension_pt_fractional() {
241        let unit = parse_dimension("10.5pt").unwrap();
242        assert_eq!(unit, HwpUnit::from_pt(10.5).unwrap());
243    }
244
245    #[test]
246    fn parse_dimension_mm() {
247        let unit = parse_dimension("20mm").unwrap();
248        assert_eq!(unit, HwpUnit::from_mm(20.0).unwrap());
249    }
250
251    #[test]
252    fn parse_dimension_inch() {
253        let unit = parse_dimension("1in").unwrap();
254        assert_eq!(unit, HwpUnit::from_inch(1.0).unwrap());
255    }
256
257    #[test]
258    fn parse_dimension_case_insensitive() {
259        assert_eq!(parse_dimension("16PT").unwrap(), parse_dimension("16pt").unwrap());
260        assert_eq!(parse_dimension("20MM").unwrap(), parse_dimension("20mm").unwrap());
261        assert_eq!(parse_dimension("1IN").unwrap(), parse_dimension("1in").unwrap());
262    }
263
264    #[test]
265    fn parse_dimension_raw_integer() {
266        let unit = parse_dimension("1600").unwrap();
267        assert_eq!(unit, HwpUnit::new(1600).unwrap());
268    }
269
270    #[test]
271    fn parse_dimension_zero() {
272        let unit = parse_dimension("0pt").unwrap();
273        assert_eq!(unit, HwpUnit::ZERO);
274    }
275
276    #[test]
277    fn parse_dimension_whitespace_trimmed() {
278        let unit = parse_dimension("  16pt  ").unwrap();
279        assert_eq!(unit, HwpUnit::from_pt(16.0).unwrap());
280    }
281
282    #[test]
283    fn parse_dimension_empty_error() {
284        assert!(parse_dimension("").is_err());
285        assert!(parse_dimension("   ").is_err());
286    }
287
288    #[test]
289    fn parse_dimension_no_unit_no_number() {
290        assert!(parse_dimension("pt").is_err());
291        assert!(parse_dimension("mm").is_err());
292        assert!(parse_dimension("abc").is_err());
293    }
294
295    #[test]
296    fn parse_dimension_invalid_unit() {
297        assert!(parse_dimension("16px").is_err());
298        assert!(parse_dimension("16em").is_err());
299    }
300
301    #[test]
302    fn parse_dimension_negative() {
303        let unit = parse_dimension("-5pt").unwrap();
304        assert_eq!(unit, HwpUnit::from_pt(-5.0).unwrap());
305    }
306
307    // -----------------------------------------------------------------------
308    // Dimension formatting (roundtrip)
309    // -----------------------------------------------------------------------
310
311    #[test]
312    fn format_dimension_whole_number() {
313        assert_eq!(format_dimension_pt(HwpUnit::from_pt(16.0).unwrap()), "16pt");
314    }
315
316    #[test]
317    fn format_dimension_zero() {
318        assert_eq!(format_dimension_pt(HwpUnit::ZERO), "0pt");
319    }
320
321    // -----------------------------------------------------------------------
322    // Percentage parsing
323    // -----------------------------------------------------------------------
324
325    #[test]
326    fn parse_percentage_normal() {
327        assert_eq!(parse_percentage("160%").unwrap(), 160.0);
328    }
329
330    #[test]
331    fn parse_percentage_hundred() {
332        assert_eq!(parse_percentage("100%").unwrap(), 100.0);
333    }
334
335    #[test]
336    fn parse_percentage_fractional() {
337        assert_eq!(parse_percentage("150.5%").unwrap(), 150.5);
338    }
339
340    #[test]
341    fn parse_percentage_zero() {
342        assert_eq!(parse_percentage("0%").unwrap(), 0.0);
343    }
344
345    #[test]
346    fn parse_percentage_no_percent_sign() {
347        assert!(parse_percentage("160").is_err());
348    }
349
350    #[test]
351    fn parse_percentage_empty() {
352        assert!(parse_percentage("").is_err());
353        assert!(parse_percentage("%").is_err());
354    }
355
356    #[test]
357    fn parse_percentage_invalid() {
358        assert!(parse_percentage("abc%").is_err());
359    }
360
361    #[test]
362    fn parse_percentage_negative_rejected() {
363        assert!(parse_percentage("-10%").is_err());
364        assert!(parse_percentage("-0.1%").is_err());
365    }
366
367    #[test]
368    fn format_percentage_whole() {
369        assert_eq!(format_percentage(160.0), "160%");
370    }
371
372    #[test]
373    fn format_percentage_fractional() {
374        assert_eq!(format_percentage(150.5), "150.5%");
375    }
376
377    // -----------------------------------------------------------------------
378    // Color parsing
379    // -----------------------------------------------------------------------
380
381    #[test]
382    fn parse_color_black() {
383        let c = parse_color("#000000").unwrap();
384        assert_eq!(c, Color::BLACK);
385    }
386
387    #[test]
388    fn parse_color_white() {
389        let c = parse_color("#FFFFFF").unwrap();
390        assert_eq!(c, Color::WHITE);
391    }
392
393    #[test]
394    fn parse_color_red() {
395        let c = parse_color("#FF0000").unwrap();
396        assert_eq!(c, Color::RED);
397    }
398
399    #[test]
400    fn parse_color_lowercase() {
401        let c = parse_color("#ff0000").unwrap();
402        assert_eq!(c, Color::RED);
403    }
404
405    #[test]
406    fn parse_color_mixed_case() {
407        let c = parse_color("#Ff0000").unwrap();
408        assert_eq!(c, Color::RED);
409    }
410
411    #[test]
412    fn parse_color_custom() {
413        let c = parse_color("#003366").unwrap();
414        let (r, g, b) = c.to_rgb();
415        assert_eq!((r, g, b), (0x00, 0x33, 0x66));
416    }
417
418    #[test]
419    fn parse_color_no_hash() {
420        assert!(parse_color("FF0000").is_err());
421    }
422
423    #[test]
424    fn parse_color_short_form() {
425        assert!(parse_color("#FFF").is_err());
426    }
427
428    #[test]
429    fn parse_color_too_long() {
430        assert!(parse_color("#FF00FF00").is_err());
431    }
432
433    #[test]
434    fn parse_color_invalid_hex() {
435        assert!(parse_color("#GGHHII").is_err());
436    }
437
438    #[test]
439    fn parse_color_empty() {
440        assert!(parse_color("").is_err());
441        assert!(parse_color("#").is_err());
442    }
443
444    #[test]
445    fn format_color_roundtrip() {
446        let original = "#003366";
447        let color = parse_color(original).unwrap();
448        assert_eq!(format_color(color), original);
449    }
450
451    #[test]
452    fn format_color_red() {
453        assert_eq!(format_color(Color::RED), "#FF0000");
454    }
455}