1#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
14pub struct Length(pub f32);
15
16impl Length {
17 pub const fn points(self) -> f32 {
19 self.0
20 }
21
22 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 "pc" => value * 12.0,
42 "px" => value * 0.75,
45 _ => return None,
46 };
47 Some(Self(points))
48 }
49
50 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 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#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
93pub struct Percent(pub f32);
94
95impl Percent {
96 pub const fn fraction(self) -> f32 {
98 self.0 / 100.0
99 }
100
101 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#[derive(Debug, Clone, Copy, PartialEq)]
111pub enum Measure {
112 Absolute(Length),
114 Relative(Percent),
116}
117
118impl Measure {
119 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct Color {
143 pub r: u8,
145 pub g: u8,
147 pub b: u8,
149}
150
151impl Color {
152 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
169pub(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 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}