Skip to main content

odox_core/
value.rs

1//! The value types ODF writes its measurements and colours in.
2//
3// Author: David M. Anderson
4// Built with AI assistance (Claude, Anthropic)
5
6/// A length, held in points.
7///
8/// ODF writes a length as a number and a unit, and the unit varies by writer and
9/// by locale: the same document carries centimetres in its page layout and points
10/// in its font sizes. Everything is converted on the way in so that nothing
11/// downstream has to ask which unit it is looking at. A point is the unit a
12/// renderer wants, being what a font size is already in.
13#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
14pub struct Length(pub f32);
15
16impl Length {
17    /// Points.
18    pub const fn points(self) -> f32 {
19        self.0
20    }
21
22    /// Parse a length with its unit, as ODF writes one.
23    ///
24    /// `None` for anything unreadable, including a bare number: ODF requires a
25    /// unit on every length, and a number with none is a writer's mistake whose
26    /// intended unit cannot be guessed.
27    pub fn parse(text: &str) -> Option<Self> {
28        let text = text.trim();
29        let split = text.len().checked_sub(2)?;
30        if !text.is_char_boundary(split) {
31            return None;
32        }
33        let (number, unit) = text.split_at(split);
34        let value: f32 = number.trim().parse().ok()?;
35        let points = match unit {
36            "pt" => value,
37            "in" => value * 72.0,
38            "cm" => value * 72.0 / 2.54,
39            "mm" => value * 72.0 / 25.4,
40            // A pica is twelve points, and ODF permits it because XSL does.
41            "pc" => value * 12.0,
42            // Not an ODF unit and written by some producers anyway, read at the
43            // CSS reference resolution of 96 pixels to the inch.
44            "px" => value * 0.75,
45            _ => return None,
46        };
47        Some(Self(points))
48    }
49
50    /// Write the length in a unit, as ODF spells one: `2.54cm`.
51    ///
52    /// The unit is whichever the attribute carried when it was read, so a
53    /// document written in centimetres stays in centimetres; anything not an
54    /// ODF unit is written in centimetres. Four decimals, trailing zeros
55    /// dropped, which is finer than a hundredth of a point in any unit.
56    pub fn write(self, unit: &str) -> String {
57        let (value, unit) = match unit {
58            "pt" => (self.0, "pt"),
59            "in" => (self.0 / 72.0, "in"),
60            "mm" => (self.0 * 25.4 / 72.0, "mm"),
61            "pc" => (self.0 / 12.0, "pc"),
62            "px" => (self.0 / 0.75, "px"),
63            _ => (self.0 * 2.54 / 72.0, "cm"),
64        };
65        let mut text = format!("{value:.4}");
66        while text.ends_with('0') {
67            text.pop();
68        }
69        if text.ends_with('.') {
70            text.pop();
71        }
72        if text == "-0" {
73            "0".clone_into(&mut text);
74        }
75        format!("{text}{unit}")
76    }
77
78    /// The unit a written length ends in, for writing it back the same way.
79    pub fn unit_of(text: &str) -> &str {
80        let text = text.trim();
81        text.len()
82            .checked_sub(2)
83            .filter(|&split| text.is_char_boundary(split))
84            .map_or("cm", |split| &text[split..])
85    }
86}
87
88/// A percentage, held as the number before the sign.
89///
90/// A font size, a line height and a column width can each be written as one,
91/// relative to something the renderer knows and this crate does not.
92#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
93pub struct Percent(pub f32);
94
95impl Percent {
96    /// The fraction this percentage is of its reference: 150% is 1.5.
97    pub const fn fraction(self) -> f32 {
98        self.0 / 100.0
99    }
100
101    /// Parse a percentage.
102    pub fn parse(text: &str) -> Option<Self> {
103        let value: f32 = text.trim().strip_suffix('%')?.trim().parse().ok()?;
104        Some(Self(value))
105    }
106}
107
108/// A length or a percentage, which is what ODF permits wherever it permits
109/// either.
110#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum Measure {
112    /// An absolute length.
113    Absolute(Length),
114    /// A proportion of whatever the property is relative to.
115    Relative(Percent),
116}
117
118impl Measure {
119    /// Parse either form.
120    pub fn parse(text: &str) -> Option<Self> {
121        if let Some(percent) = Percent::parse(text) {
122            return Some(Self::Relative(percent));
123        }
124        Length::parse(text).map(Self::Absolute)
125    }
126
127    /// Resolve against the value the property is relative to, in points.
128    pub fn resolve(self, reference: f32) -> f32 {
129        match self {
130            Self::Absolute(length) => length.points(),
131            Self::Relative(percent) => reference * percent.fraction(),
132        }
133    }
134}
135
136/// An opaque colour.
137///
138/// ODF writes a colour as `#rrggbb` and has no notation for an alpha channel;
139/// where something is meant to be see-through it says `transparent` in the
140/// property instead, which is [`None`] here.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct Color {
143    /// Red.
144    pub r: u8,
145    /// Green.
146    pub g: u8,
147    /// Blue.
148    pub b: u8,
149}
150
151impl Color {
152    /// Parse `#rrggbb`.
153    ///
154    /// `None` for `transparent`, for the three-digit CSS form ODF does not
155    /// define, and for anything else unreadable.
156    pub fn parse(text: &str) -> Option<Self> {
157        let hex = text.trim().strip_prefix('#')?;
158        if hex.len() != 6 {
159            return None;
160        }
161        Some(Self {
162            r: u8::from_str_radix(&hex[0..2], 16).ok()?,
163            g: u8::from_str_radix(&hex[2..4], 16).ok()?,
164            b: u8::from_str_radix(&hex[4..6], 16).ok()?,
165        })
166    }
167}
168
169/// Read an ODF boolean attribute, which is spelled `true` or `false`.
170pub(crate) fn boolean(text: &str) -> Option<bool> {
171    match text.trim() {
172        "true" => Some(true),
173        "false" => Some(false),
174        _ => None,
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn lengths_convert_to_points() {
184        assert_eq!(Length::parse("12pt"), Some(Length(12.0)));
185        assert_eq!(Length::parse("1in"), Some(Length(72.0)));
186        assert_eq!(Length::parse("1pc"), Some(Length(12.0)));
187        let cm = Length::parse("2.54cm").unwrap();
188        assert!((cm.points() - 72.0).abs() < 0.01);
189        let mm = Length::parse("25.4mm").unwrap();
190        assert!((mm.points() - 72.0).abs() < 0.01);
191    }
192
193    #[test]
194    fn a_length_is_written_back_in_its_unit() {
195        let length = Length::parse("2.54cm").expect("a length");
196        assert_eq!(length.write("cm"), "2.54cm");
197        assert_eq!(length.write("in"), "1in");
198        assert_eq!(length.write("pt"), "72pt");
199        assert_eq!(Length(0.0).write("cm"), "0cm");
200        assert_eq!(Length::unit_of("12.5mm"), "mm");
201        assert_eq!(Length::unit_of("x"), "cm");
202        let back = Length::parse(&Length(100.0).write("mm")).expect("readable");
203        assert!((back.points() - 100.0).abs() < 0.001, "{}", back.points());
204    }
205
206    #[test]
207    fn a_length_needs_a_unit() {
208        assert_eq!(Length::parse("12"), None);
209        assert_eq!(Length::parse(""), None);
210        assert_eq!(Length::parse("pt"), None);
211    }
212
213    #[test]
214    fn a_multibyte_tail_is_not_split_through() {
215        // Two bytes from the end of "12µm" is inside the µ, and splitting a
216        // string there panics rather than returning.
217        assert_eq!(Length::parse("12µm"), None);
218    }
219
220    #[test]
221    fn colours_and_percentages() {
222        assert_eq!(
223            Color::parse("#ff8000"),
224            Some(Color {
225                r: 255,
226                g: 128,
227                b: 0
228            })
229        );
230        assert_eq!(Color::parse("transparent"), None);
231        assert_eq!(Color::parse("#abc"), None);
232        assert_eq!(Percent::parse("150%").map(Percent::fraction), Some(1.5));
233    }
234}