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
51/// A percentage, held as the number before the sign.
52///
53/// A font size, a line height and a column width can each be written as one,
54/// relative to something the renderer knows and this crate does not.
55#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
56pub struct Percent(pub f32);
57
58impl Percent {
59    /// The fraction this percentage is of its reference: 150% is 1.5.
60    pub const fn fraction(self) -> f32 {
61        self.0 / 100.0
62    }
63
64    /// Parse a percentage.
65    pub fn parse(text: &str) -> Option<Self> {
66        let value: f32 = text.trim().strip_suffix('%')?.trim().parse().ok()?;
67        Some(Self(value))
68    }
69}
70
71/// A length or a percentage, which is what ODF permits wherever it permits
72/// either.
73#[derive(Debug, Clone, Copy, PartialEq)]
74pub enum Measure {
75    /// An absolute length.
76    Absolute(Length),
77    /// A proportion of whatever the property is relative to.
78    Relative(Percent),
79}
80
81impl Measure {
82    /// Parse either form.
83    pub fn parse(text: &str) -> Option<Self> {
84        if let Some(percent) = Percent::parse(text) {
85            return Some(Self::Relative(percent));
86        }
87        Length::parse(text).map(Self::Absolute)
88    }
89
90    /// Resolve against the value the property is relative to, in points.
91    pub fn resolve(self, reference: f32) -> f32 {
92        match self {
93            Self::Absolute(length) => length.points(),
94            Self::Relative(percent) => reference * percent.fraction(),
95        }
96    }
97}
98
99/// An opaque colour.
100///
101/// ODF writes a colour as `#rrggbb` and has no notation for an alpha channel;
102/// where something is meant to be see-through it says `transparent` in the
103/// property instead, which is [`None`] here.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub struct Color {
106    /// Red.
107    pub r: u8,
108    /// Green.
109    pub g: u8,
110    /// Blue.
111    pub b: u8,
112}
113
114impl Color {
115    /// Parse `#rrggbb`.
116    ///
117    /// `None` for `transparent`, for the three-digit CSS form ODF does not
118    /// define, and for anything else unreadable.
119    pub fn parse(text: &str) -> Option<Self> {
120        let hex = text.trim().strip_prefix('#')?;
121        if hex.len() != 6 {
122            return None;
123        }
124        Some(Self {
125            r: u8::from_str_radix(&hex[0..2], 16).ok()?,
126            g: u8::from_str_radix(&hex[2..4], 16).ok()?,
127            b: u8::from_str_radix(&hex[4..6], 16).ok()?,
128        })
129    }
130}
131
132/// Read an ODF boolean attribute, which is spelled `true` or `false`.
133pub(crate) fn boolean(text: &str) -> Option<bool> {
134    match text.trim() {
135        "true" => Some(true),
136        "false" => Some(false),
137        _ => None,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn lengths_convert_to_points() {
147        assert_eq!(Length::parse("12pt"), Some(Length(12.0)));
148        assert_eq!(Length::parse("1in"), Some(Length(72.0)));
149        assert_eq!(Length::parse("1pc"), Some(Length(12.0)));
150        let cm = Length::parse("2.54cm").unwrap();
151        assert!((cm.points() - 72.0).abs() < 0.01);
152        let mm = Length::parse("25.4mm").unwrap();
153        assert!((mm.points() - 72.0).abs() < 0.01);
154    }
155
156    #[test]
157    fn a_length_needs_a_unit() {
158        assert_eq!(Length::parse("12"), None);
159        assert_eq!(Length::parse(""), None);
160        assert_eq!(Length::parse("pt"), None);
161    }
162
163    #[test]
164    fn a_multibyte_tail_is_not_split_through() {
165        // Two bytes from the end of "12µm" is inside the µ, and splitting a
166        // string there panics rather than returning.
167        assert_eq!(Length::parse("12µm"), None);
168    }
169
170    #[test]
171    fn colours_and_percentages() {
172        assert_eq!(
173            Color::parse("#ff8000"),
174            Some(Color {
175                r: 255,
176                g: 128,
177                b: 0
178            })
179        );
180        assert_eq!(Color::parse("transparent"), None);
181        assert_eq!(Color::parse("#abc"), None);
182        assert_eq!(Percent::parse("150%").map(Percent::fraction), Some(1.5));
183    }
184}