use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Default)]
pub struct ShapeStyle {
pub fill: Option<String>,
pub stroke: Option<String>,
pub stroke_width: Option<f64>,
pub text: Option<String>,
pub dash: Option<String>,
}
impl ShapeStyle {
pub fn is_empty(&self) -> bool {
self.fill.is_none()
&& self.stroke.is_none()
&& self.stroke_width.is_none()
&& self.text.is_none()
&& self.dash.is_none()
}
pub fn apply(&mut self, decl: &str) {
let Some((key, value)) = decl.split_once(':') else {
return;
};
let key = key.trim();
let value = value.trim();
match key {
"fill" => {
if let Some(v) = paint(value) {
self.fill = Some(v);
}
}
"stroke" => {
if let Some(v) = paint(value) {
self.stroke = Some(v);
}
}
"color" => {
if let Some(v) = paint(value) {
self.text = Some(v);
}
}
"stroke-width" => {
if let Some(v) = px(value) {
self.stroke_width = Some(v);
}
}
"stroke-dasharray" => {
if let Some(v) = dasharray(value) {
self.dash = Some(v);
}
}
_ => {}
}
}
}
fn paint(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
if value.eq_ignore_ascii_case("none") {
return Some("none".to_string());
}
svgtypes::Color::from_str(value).ok()?;
Some(value.to_string())
}
pub fn lighten(color: &str) -> Option<String> {
if color.eq_ignore_ascii_case("none") {
return None;
}
let c = svgtypes::Color::from_str(color).ok()?;
let (h, s, l) = rgb_to_hsl(c.red, c.green, c.blue);
let l = (l * LIGHTEN_FACTOR).min(LIGHTEN_MAX_LIGHTNESS);
let (r, g, b) = hsl_to_rgb(h, s, l);
Some(format!("#{r:02x}{g:02x}{b:02x}"))
}
pub fn tint(color: &str) -> Option<String> {
if color.eq_ignore_ascii_case("none") {
return None;
}
let c = svgtypes::Color::from_str(color).ok()?;
let (h, s, l) = rgb_to_hsl(c.red, c.green, c.blue);
let (r, g, b) = hsl_to_rgb(h, s * TINT_SATURATION, l * TINT_LIGHTNESS);
Some(format!("#{r:02x}{g:02x}{b:02x}"))
}
const TINT_LIGHTNESS: f64 = 0.213;
const TINT_SATURATION: f64 = 0.623;
const LIGHTEN_FACTOR: f64 = 1.2;
const LIGHTEN_MAX_LIGHTNESS: f64 = 92.0;
fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
let (r, g, b) = (r as f64 / 255.0, g as f64 / 255.0, b as f64 / 255.0);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let l = (max + min) / 2.0;
let d = max - min;
if d < 1e-9 {
return (0.0, 0.0, l * 100.0);
}
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if (max - r).abs() < 1e-9 {
((g - b) / d).rem_euclid(6.0)
} else if (max - g).abs() < 1e-9 {
(b - r) / d + 2.0
} else {
(r - g) / d + 4.0
};
((h * 60.0).rem_euclid(360.0), s * 100.0, l * 100.0)
}
fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
let (h, s, l) = (
h / 360.0,
(s / 100.0).clamp(0.0, 1.0),
(l / 100.0).clamp(0.0, 1.0),
);
if s < 1e-9 {
let v = (l * 255.0).round() as u8;
return (v, v, v);
}
let q = if l < 0.5 {
l * (1.0 + s)
} else {
l + s - l * s
};
let p = 2.0 * l - q;
let hue_to_rgb = |t: f64| -> f64 {
let t = t.rem_euclid(1.0);
if t < 1.0 / 6.0 {
p + (q - p) * 6.0 * t
} else if t < 1.0 / 2.0 {
q
} else if t < 2.0 / 3.0 {
p + (q - p) * (2.0 / 3.0 - t) * 6.0
} else {
p
}
};
let to_u8 = |v: f64| (v * 255.0).round().clamp(0.0, 255.0) as u8;
(
to_u8(hue_to_rgb(h + 1.0 / 3.0)),
to_u8(hue_to_rgb(h)),
to_u8(hue_to_rgb(h - 1.0 / 3.0)),
)
}
fn px(value: &str) -> Option<f64> {
let value = value.trim();
let value = value.strip_suffix("px").unwrap_or(value).trim();
let w: f64 = value.parse().ok()?;
if w.is_finite() && w >= 0.0 {
Some(w)
} else {
None
}
}
fn dasharray(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty()
|| !value
.bytes()
.all(|b| b.is_ascii_digit() || matches!(b, b' ' | b',' | b'.'))
{
return None;
}
Some(value.to_string())
}
pub fn cascade<'a>(
class_of: impl Fn(&str) -> Option<&'a [String]>,
classes: &[String],
own_styles: &[String],
) -> Option<ShapeStyle> {
let mut style = ShapeStyle::default();
if let Some(decls) = class_of("default") {
for decl in decls {
style.apply(decl);
}
}
for name in classes {
if let Some(decls) = class_of(name) {
for decl in decls {
style.apply(decl);
}
}
}
for decl in own_styles {
style.apply(decl);
}
if style.is_empty() {
None
} else {
Some(style)
}
}
pub fn cascade_edge<'a>(
class_of: impl Fn(&str) -> Option<&'a [String]>,
classes: &[String],
link_default: impl Iterator<Item = &'a [String]>,
link_indexed: impl Iterator<Item = &'a [String]>,
) -> Option<ShapeStyle> {
let mut style = ShapeStyle::default();
for name in classes {
if let Some(decls) = class_of(name) {
for decl in decls {
style.apply(decl);
}
}
}
for decls in link_default {
for decl in decls {
style.apply(decl);
}
}
for decls in link_indexed {
for decl in decls {
style.apply(decl);
}
}
if style.is_empty() {
None
} else {
Some(style)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hsl_round_trips_every_corner_and_octant() {
let cases: &[(u8, u8, u8)] = &[
(0, 0, 0),
(255, 255, 255),
(128, 128, 128),
(255, 0, 0),
(0, 255, 0),
(0, 0, 255),
(255, 255, 0),
(0, 255, 255),
(255, 0, 255),
(31, 111, 235), (212, 160, 23), ];
for &(r, g, b) in cases {
let (h, s, l) = rgb_to_hsl(r, g, b);
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
assert_eq!(
(r, g, b),
(r2, g2, b2),
"hsl({h},{s},{l}) must round-trip back to rgb({r},{g},{b})"
);
}
}
#[test]
fn lighten_leaves_none_and_an_unparsable_colour_alone() {
assert_eq!(lighten("none"), None, "\"none\" has no hue to lighten");
assert_eq!(
lighten("NONE"),
None,
"the keyword check is case-insensitive"
);
assert_eq!(lighten("notacolor"), None);
}
#[test]
fn lighten_never_reaches_pure_white() {
let lightened = lighten("#f5f5f5").expect("a light grey still has a hue-adjacent value");
let (_, _, l) = rgb_to_hsl_hex(&lightened);
assert!(
l <= LIGHTEN_MAX_LIGHTNESS + 0.5,
"L={l} must stay close to the {LIGHTEN_MAX_LIGHTNESS} clamp"
);
}
#[test]
fn lighten_approximates_the_three_reference_conversions_within_its_documented_error() {
let reference = [
("#1f6feb", "#58a6ff"),
("#2da44e", "#3fb950"),
("#d4a017", "#d29922"),
];
for (source, target) in reference {
let got = lighten(source).unwrap_or_else(|| panic!("{source} must parse"));
let (_, _, l_got) = rgb_to_hsl_hex(&got);
let (_, _, l_target) = rgb_to_hsl_hex(target);
let error = (l_got - l_target).abs();
assert!(
error <= 8.0,
"{source}->{got} (target {target}): lightness error {error} exceeds the \
documented ~7.5pt worst case"
);
}
assert_eq!(lighten("#1f6feb").as_deref(), Some("#508eef"));
assert_eq!(lighten("#2da44e").as_deref(), Some("#36c55e"));
assert_eq!(lighten("#d4a017").as_deref(), Some("#e9b631"));
}
fn rgb_to_hsl_hex(hex: &str) -> (f64, f64, f64) {
let c = svgtypes::Color::from_str(hex).unwrap_or_else(|_| panic!("{hex} must parse"));
rgb_to_hsl(c.red, c.green, c.blue)
}
#[test]
fn tint_approximates_the_three_reference_fills_within_its_documented_error() {
let reference = [
("#58a6ff", "#0f2038"),
("#3fb950", "#0f2617"),
("#d29922", "#271d0b"),
];
for (stroke, fill) in reference {
let got = tint(stroke).unwrap_or_else(|| panic!("{stroke} must parse"));
let a = svgtypes::Color::from_str(&got).expect("tint emits #rrggbb");
let b = svgtypes::Color::from_str(fill).expect("the reference is #rrggbb");
for (channel, (x, y)) in [
("r", (a.red, b.red)),
("g", (a.green, b.green)),
("b", (a.blue, b.blue)),
] {
let error = (x as i32 - y as i32).abs();
assert!(
error <= 3,
"{stroke}->{got} (reference {fill}): {channel} is off by {error}, past the \
documented 3-of-255 worst case"
);
}
}
assert_eq!(tint("#58a6ff").as_deref(), Some("#0e233b"));
assert_eq!(tint("#3fb950").as_deref(), Some("#122315"));
assert_eq!(tint("#d29922").as_deref(), Some("#261e0e"));
}
#[test]
fn a_tint_is_always_darker_than_its_source() {
let cases = [
"#ffffff", "#000000", "#808080", "#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff",
"#ff00ff", "#58a6ff", "#f85149",
];
for source in cases {
let got = tint(source).unwrap_or_else(|| panic!("{source} must parse"));
let (_, _, l_source) = rgb_to_hsl_hex(source);
let (_, _, l_got) = rgb_to_hsl_hex(&got);
assert!(
l_got <= l_source + 1e-9,
"{source} -> {got}: lightness went up ({l_source} -> {l_got})"
);
}
}
#[test]
fn tint_leaves_none_and_an_unparsable_colour_alone() {
assert_eq!(tint("none"), None, "\"none\" has no hue to tint");
assert_eq!(tint("NONE"), None, "the keyword check is case-insensitive");
assert_eq!(tint("notacolor"), None);
}
#[test]
fn an_unknown_color_is_dropped_not_substituted_with_black() {
let mut s = ShapeStyle::default();
s.apply("fill:notacolor");
assert_eq!(s.fill, None);
}
#[test]
fn none_is_a_real_fill_distinct_from_no_override() {
let mut s = ShapeStyle::default();
s.apply("fill:none");
assert_eq!(s.fill.as_deref(), Some("none"));
}
#[test]
fn a_hex_color_passes_through_unchanged() {
let mut s = ShapeStyle::default();
s.apply("fill:#f9f");
assert_eq!(s.fill.as_deref(), Some("#f9f"));
}
#[test]
fn stroke_width_sheds_its_px_suffix() {
let mut s = ShapeStyle::default();
s.apply("stroke-width:4px");
assert_eq!(s.stroke_width, Some(4.0));
}
#[test]
fn a_negative_stroke_width_is_dropped() {
let mut s = ShapeStyle::default();
s.apply("stroke-width:-2px");
assert_eq!(s.stroke_width, None);
}
#[test]
fn dasharray_rejects_anything_that_could_break_out_of_the_attribute() {
let mut s = ShapeStyle::default();
s.apply("stroke-dasharray:5,5\" onload=\"evil()");
assert_eq!(s.dash, None);
}
#[test]
fn later_declarations_overwrite_earlier_ones() {
let mut s = ShapeStyle::default();
s.apply("fill:#111111");
s.apply("fill:#222222");
assert_eq!(s.fill.as_deref(), Some("#222222"));
}
#[test]
fn cascade_order_is_default_then_classes_then_own_style() {
let defs: Vec<(&str, Vec<String>)> = vec![
("default", vec!["fill:#111111".to_string()]),
("hot", vec!["fill:#222222".to_string()]),
];
let class_of = |name: &str| {
defs.iter()
.find(|(n, _)| *n == name)
.map(|(_, s)| s.as_slice())
};
let classes = vec!["hot".to_string()];
let own = vec!["fill:#333333".to_string()];
let style = cascade(class_of, &classes, &own).unwrap();
assert_eq!(style.fill.as_deref(), Some("#333333"));
let style = cascade(class_of, &classes, &[]).unwrap();
assert_eq!(style.fill.as_deref(), Some("#222222"));
let style = cascade(class_of, &[], &[]).unwrap();
assert_eq!(style.fill.as_deref(), Some("#111111"));
}
#[test]
fn no_classdef_at_all_resolves_to_none() {
let class_of = |_: &str| None;
assert_eq!(cascade(class_of, &[], &[]), None);
}
#[test]
fn classdef_default_does_not_leak_onto_an_edge_with_no_class_or_linkstyle() {
let defs: Vec<(&str, Vec<String>)> = vec![("default", vec!["stroke:#556".to_string()])];
let class_of = |name: &str| {
defs.iter()
.find(|(n, _)| *n == name)
.map(|(_, s)| s.as_slice())
};
let style = cascade_edge(class_of, &[], std::iter::empty(), std::iter::empty());
assert_eq!(
style, None,
"an edge named by neither `class` nor `linkStyle` must draw in the theme's own colour"
);
}
#[test]
fn a_class_statement_naming_an_edge_still_reaches_it() {
let defs: Vec<(&str, Vec<String>)> = vec![("hot", vec!["stroke:#a00".to_string()])];
let class_of = |name: &str| {
defs.iter()
.find(|(n, _)| *n == name)
.map(|(_, s)| s.as_slice())
};
let classes = vec!["hot".to_string()];
let style = cascade_edge(class_of, &classes, std::iter::empty(), std::iter::empty())
.expect("the edge's own class must still resolve");
assert_eq!(style.stroke.as_deref(), Some("#a00"));
}
}