#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Length(pub f32);
impl Length {
pub const fn points(self) -> f32 {
self.0
}
pub fn parse(text: &str) -> Option<Self> {
let text = text.trim();
let split = text.len().checked_sub(2)?;
if !text.is_char_boundary(split) {
return None;
}
let (number, unit) = text.split_at(split);
let value: f32 = number.trim().parse().ok()?;
let points = match unit {
"pt" => value,
"in" => value * 72.0,
"cm" => value * 72.0 / 2.54,
"mm" => value * 72.0 / 25.4,
"pc" => value * 12.0,
"px" => value * 0.75,
_ => return None,
};
Some(Self(points))
}
pub fn write(self, unit: &str) -> String {
let (value, unit) = match unit {
"pt" => (self.0, "pt"),
"in" => (self.0 / 72.0, "in"),
"mm" => (self.0 * 25.4 / 72.0, "mm"),
"pc" => (self.0 / 12.0, "pc"),
"px" => (self.0 / 0.75, "px"),
_ => (self.0 * 2.54 / 72.0, "cm"),
};
let mut text = format!("{value:.4}");
while text.ends_with('0') {
text.pop();
}
if text.ends_with('.') {
text.pop();
}
if text == "-0" {
"0".clone_into(&mut text);
}
format!("{text}{unit}")
}
pub fn unit_of(text: &str) -> &str {
let text = text.trim();
text.len()
.checked_sub(2)
.filter(|&split| text.is_char_boundary(split))
.map_or("cm", |split| &text[split..])
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct Percent(pub f32);
impl Percent {
pub const fn fraction(self) -> f32 {
self.0 / 100.0
}
pub fn parse(text: &str) -> Option<Self> {
let value: f32 = text.trim().strip_suffix('%')?.trim().parse().ok()?;
Some(Self(value))
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Measure {
Absolute(Length),
Relative(Percent),
}
impl Measure {
pub fn parse(text: &str) -> Option<Self> {
if let Some(percent) = Percent::parse(text) {
return Some(Self::Relative(percent));
}
Length::parse(text).map(Self::Absolute)
}
pub fn resolve(self, reference: f32) -> f32 {
match self {
Self::Absolute(length) => length.points(),
Self::Relative(percent) => reference * percent.fraction(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl Color {
pub fn parse(text: &str) -> Option<Self> {
let hex = text.trim().strip_prefix('#')?;
if hex.len() != 6 {
return None;
}
Some(Self {
r: u8::from_str_radix(&hex[0..2], 16).ok()?,
g: u8::from_str_radix(&hex[2..4], 16).ok()?,
b: u8::from_str_radix(&hex[4..6], 16).ok()?,
})
}
}
pub(crate) fn boolean(text: &str) -> Option<bool> {
match text.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lengths_convert_to_points() {
assert_eq!(Length::parse("12pt"), Some(Length(12.0)));
assert_eq!(Length::parse("1in"), Some(Length(72.0)));
assert_eq!(Length::parse("1pc"), Some(Length(12.0)));
let cm = Length::parse("2.54cm").unwrap();
assert!((cm.points() - 72.0).abs() < 0.01);
let mm = Length::parse("25.4mm").unwrap();
assert!((mm.points() - 72.0).abs() < 0.01);
}
#[test]
fn a_length_is_written_back_in_its_unit() {
let length = Length::parse("2.54cm").expect("a length");
assert_eq!(length.write("cm"), "2.54cm");
assert_eq!(length.write("in"), "1in");
assert_eq!(length.write("pt"), "72pt");
assert_eq!(Length(0.0).write("cm"), "0cm");
assert_eq!(Length::unit_of("12.5mm"), "mm");
assert_eq!(Length::unit_of("x"), "cm");
let back = Length::parse(&Length(100.0).write("mm")).expect("readable");
assert!((back.points() - 100.0).abs() < 0.001, "{}", back.points());
}
#[test]
fn a_length_needs_a_unit() {
assert_eq!(Length::parse("12"), None);
assert_eq!(Length::parse(""), None);
assert_eq!(Length::parse("pt"), None);
}
#[test]
fn a_multibyte_tail_is_not_split_through() {
assert_eq!(Length::parse("12µm"), None);
}
#[test]
fn colours_and_percentages() {
assert_eq!(
Color::parse("#ff8000"),
Some(Color {
r: 255,
g: 128,
b: 0
})
);
assert_eq!(Color::parse("transparent"), None);
assert_eq!(Color::parse("#abc"), None);
assert_eq!(Percent::parse("150%").map(Percent::fraction), Some(1.5));
}
}