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())
}
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 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"));
}
}