mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
svg-paths: CSS colour parser and a small SVG writer (#857)
color: parses the CSS colours SVG paint uses (hex, rgb()/hsl(), the 148 named colours) into an opaque 8-bit sRGB Color, and writes the canonical #rrggbb form. Shape::stroke is still returned as written; parse_color reads it. write: SvgWriter (XML declaration, root element with the SVG namespace, elements and attributes escaped through craft-xml, caller-controlled line breaks), path data in shortest round-trip form or with fixed decimals, and fixed-decimal numbers that are never NaN or infinite.
This commit is contained in:
1 parent
e7814b1727
commit
12e24c1029
6 files changed
+712
-5
No files matched your search
Generated
+1
@@ -1206,6 +1206,7 @@ dependencies = [
|
||||
name = "craft-svg-paths"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-xml",
|
||||
"kurbo 0.13.1",
|
||||
"roxmltree 0.21.2",
|
||||
"thiserror 2.0.21",
|
||||
|
||||
@@ -136,7 +136,8 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused.
|
||||
- [`craft-svg-paths`](crates/svg-paths/src/lib.rs): SVG geometry reader: shapes,
|
||||
transforms, `<use>`/`<symbol>` instances and the CSS cascade as Bézier paths in millimetres, each
|
||||
tagged by the caller. Import only; not the shared SVG model.
|
||||
tagged by the caller; a CSS colour parser (`color`); and a small SVG writer (`write`: elements with
|
||||
escaping, path data, numbers). Not the shared SVG model.
|
||||
- [`craft-text`](crates/text/README.md): text processing below the composer. Its `hyphen` module finds
|
||||
hyphenation points, with a bundled US English dictionary and patterns and optional hypher patterns
|
||||
for 47 more languages (one cargo feature each).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "craft-svg-paths"
|
||||
description = "SVG geometry reader: shapes, transforms, use/symbol instances and the CSS cascade, as Bézier paths in millimetres"
|
||||
description = "SVG geometry reader (shapes, transforms, use/symbol instances and the CSS cascade, as Bézier paths in millimetres), CSS colour parser and a small SVG writer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,6 +11,7 @@ repository.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-xml = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
roxmltree = "0.21.2"
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,464 @@
|
||||
//! CSS colours, the paint syntax of SVG: parsing ([`parse_color`]) and the canonical
|
||||
//! `#rrggbb` form ([`Color::to_css`]), with luminance and blending for palette work.
|
||||
//!
|
||||
//! The grammar is a subset of <https://www.w3.org/TR/css-color-4/>: hex, `rgb()`/`hsl()` and
|
||||
//! the named colours. Alpha is checked and dropped: a [`Color`] is opaque.
|
||||
|
||||
/// An 8-bit sRGB color, no alpha.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
|
||||
pub struct Color {
|
||||
pub r: u8,
|
||||
pub g: u8,
|
||||
pub b: u8,
|
||||
}
|
||||
|
||||
impl Color {
|
||||
pub const fn new(r: u8, g: u8, b: u8) -> Self {
|
||||
Color { r, g, b }
|
||||
}
|
||||
|
||||
/// Canonical CSS form: lowercase `#rrggbb`.
|
||||
pub fn to_css(&self) -> String {
|
||||
format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
|
||||
}
|
||||
|
||||
/// Perceived (relative) luminance in `[0, 1]`, the WCAG weighting of
|
||||
/// sRGB channels. 0 is black, 1 is white.
|
||||
pub fn luminance(&self) -> f32 {
|
||||
let r = channel_value(self.r);
|
||||
let g = channel_value(self.g);
|
||||
let b = channel_value(self.b);
|
||||
0.2126 * r + 0.7152 * g + 0.0722 * b
|
||||
}
|
||||
|
||||
/// Blend from `self` to `other` by `t` in `[0, 1]`: `t = 0.5` is a
|
||||
/// plain midpoint, not perceptually flat. `t` outside the range is
|
||||
/// clamped, so UI dials can pass anything.
|
||||
pub fn blend(self, other: Color, t: f32) -> Color {
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
Color { r: lerp_channel(self.r, other.r, t), g: lerp_channel(self.g, other.g, t), b: lerp_channel(self.b, other.b, t) }
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_value(v: u8) -> f32 {
|
||||
let c = f32::from(v) / 255.0;
|
||||
// WCAG 2.x "relative luminance" is plain channel weighting; css-color-4
|
||||
// applies the sRGB transfer function first. We use the WCAG form, which
|
||||
// is what palette contrast helpers want.
|
||||
c
|
||||
}
|
||||
|
||||
fn lerp_channel(a: u8, b: u8, t: f32) -> u8 {
|
||||
let l = f32::from(a) + (f32::from(b) - f32::from(a)) * t;
|
||||
(l + 0.5).clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// Why a color string could not be parsed. `Display` names the offending
|
||||
/// input so callers can echo it without re-wrapping.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("invalid color {input:?}")]
|
||||
pub struct ColorParseError {
|
||||
input: String,
|
||||
}
|
||||
|
||||
/// Parse a CSS color string (<https://www.w3.org/TR/css-color-4/>, which SVG 1.1 §4.2
|
||||
/// and SVG 2 paint both defer to): the 148 named colours, `#rgb`, `#rgba`, `#rrggbb`,
|
||||
/// `#rrggbbaa`, `rgb()`/`rgba()` with integer 0–255 or percentage channels, and
|
||||
/// `hsl()`/`hsla()`, in comma or space syntax with an optional alpha (`/ a` or a fourth
|
||||
/// comma value). Alpha is checked and then dropped.
|
||||
///
|
||||
/// Anything else — `transparent`, `currentColor`, `color()`, out-of-range channels —
|
||||
/// is an error, not a guess: silent color loss is worse than a reported failure.
|
||||
/// Callers that understand `none`/`currentColor` (SVG paint) resolve them first.
|
||||
pub fn parse_color(input: &str) -> Result<Color, ColorParseError> {
|
||||
let err = || ColorParseError { input: input.to_string() };
|
||||
// Cap input length before work: a caller feeding untrusted text cannot
|
||||
// make this quadratic.
|
||||
if input.len() > 64 {
|
||||
return Err(err());
|
||||
}
|
||||
let lower = input.trim().to_ascii_lowercase();
|
||||
if let Some(digits) = lower.strip_prefix('#') {
|
||||
return parse_hex(digits.trim()).ok_or_else(err);
|
||||
}
|
||||
if let Some((name, rest)) = lower.split_once('(') {
|
||||
return parse_function(name.trim(), rest).ok_or_else(err);
|
||||
}
|
||||
NAMED.iter().find(|(name, _)| *name == lower).map(|(_, rgb)| Color::new((rgb >> 16) as u8, (rgb >> 8) as u8, *rgb as u8)).ok_or_else(err)
|
||||
}
|
||||
|
||||
/// Hex digits after `#`: 3 or 4 (each doubled), 6 or 8; the alpha digits are dropped.
|
||||
fn parse_hex(digits: &str) -> Option<Color> {
|
||||
if !digits.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let value = |s: &str| u8::from_str_radix(s, 16).ok();
|
||||
match digits.len() {
|
||||
3 | 4 => {
|
||||
let d = |i: usize| value(digits.get(i..=i)?).map(|v| v * 17);
|
||||
Some(Color::new(d(0)?, d(1)?, d(2)?))
|
||||
}
|
||||
6 | 8 => {
|
||||
let d = |i: usize| value(digits.get(i..i + 2)?);
|
||||
Some(Color::new(d(0)?, d(2)?, d(4)?))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `rgb(…)`, `rgba(…)`, `hsl(…)`, `hsla(…)` after the `(`, which must close with `)`.
|
||||
fn parse_function(name: &str, rest: &str) -> Option<Color> {
|
||||
let body = rest.trim().strip_suffix(')')?;
|
||||
// Legacy comma syntax, or modern space syntax with `/ alpha`.
|
||||
let parts: Vec<&str> = if body.contains(',') {
|
||||
if body.contains('/') {
|
||||
return None;
|
||||
}
|
||||
body.split(',').map(str::trim).collect()
|
||||
} else {
|
||||
let (channels, alpha) = match body.split_once('/') {
|
||||
Some((channels, alpha)) => (channels, Some(alpha.trim())),
|
||||
None => (body, None),
|
||||
};
|
||||
let mut parts: Vec<&str> = channels.split_whitespace().collect();
|
||||
parts.extend(alpha);
|
||||
parts
|
||||
};
|
||||
if !(parts.len() == 3 || parts.len() == 4) {
|
||||
return None;
|
||||
}
|
||||
if let Some(alpha) = parts.get(3) {
|
||||
let a = match alpha.strip_suffix('%') {
|
||||
Some(p) => number(p)? / 100.0,
|
||||
None => number(alpha)?,
|
||||
};
|
||||
if !(0.0..=1.0).contains(&a) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let (Some(a), Some(b), Some(c)) = (parts.first(), parts.get(1), parts.get(2)) else {
|
||||
return None;
|
||||
};
|
||||
match name {
|
||||
"rgb" | "rgba" => Some(Color::new(rgb_channel(a)?, rgb_channel(b)?, rgb_channel(c)?)),
|
||||
"hsl" | "hsla" => {
|
||||
let hue = number(a.strip_suffix("deg").unwrap_or(a))?;
|
||||
Some(hsl_to_rgb(hue.rem_euclid(360.0), percent(b)?, percent(c)?))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A finite plain decimal number.
|
||||
fn number(text: &str) -> Option<f64> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit() || b"+-.".contains(&b)) {
|
||||
return None;
|
||||
}
|
||||
text.parse::<f64>().ok().filter(|v| v.is_finite())
|
||||
}
|
||||
|
||||
/// `0%`–`100%` as a fraction.
|
||||
fn percent(text: &str) -> Option<f64> {
|
||||
let p = number(text.strip_suffix('%')?)?;
|
||||
(0.0..=100.0).contains(&p).then_some(p / 100.0)
|
||||
}
|
||||
|
||||
/// An `rgb()` channel: an integer 0–255 or a percentage. Out of range is an error.
|
||||
fn rgb_channel(text: &str) -> Option<u8> {
|
||||
if text.ends_with('%') {
|
||||
return Some((percent(text)? * 255.0).round() as u8);
|
||||
}
|
||||
if text.is_empty() || !text.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
text.parse::<u8>().ok()
|
||||
}
|
||||
|
||||
/// css-color-4 §7.1 HSL to RGB: `hue` in [0, 360), saturation/lightness in [0, 1].
|
||||
fn hsl_to_rgb(hue: f64, s: f64, l: f64) -> Color {
|
||||
let channel = |n: f64| {
|
||||
let k = (n + hue / 30.0) % 12.0;
|
||||
let a = s * l.min(1.0 - l);
|
||||
let v = l - a * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0);
|
||||
(v * 255.0).round().clamp(0.0, 255.0) as u8
|
||||
};
|
||||
Color::new(channel(0.0), channel(8.0), channel(4.0))
|
||||
}
|
||||
|
||||
/// css-color-4 §6.1 named colours as `0xrrggbb`, lowercase.
|
||||
const NAMED: &[(&str, u32)] = &[
|
||||
("aliceblue", 0xf0f8ff),
|
||||
("antiquewhite", 0xfaebd7),
|
||||
("aqua", 0x00ffff),
|
||||
("aquamarine", 0x7fffd4),
|
||||
("azure", 0xf0ffff),
|
||||
("beige", 0xf5f5dc),
|
||||
("bisque", 0xffe4c4),
|
||||
("black", 0x000000),
|
||||
("blanchedalmond", 0xffebcd),
|
||||
("blue", 0x0000ff),
|
||||
("blueviolet", 0x8a2be2),
|
||||
("brown", 0xa52a2a),
|
||||
("burlywood", 0xdeb887),
|
||||
("cadetblue", 0x5f9ea0),
|
||||
("chartreuse", 0x7fff00),
|
||||
("chocolate", 0xd2691e),
|
||||
("coral", 0xff7f50),
|
||||
("cornflowerblue", 0x6495ed),
|
||||
("cornsilk", 0xfff8dc),
|
||||
("crimson", 0xdc143c),
|
||||
("cyan", 0x00ffff),
|
||||
("darkblue", 0x00008b),
|
||||
("darkcyan", 0x008b8b),
|
||||
("darkgoldenrod", 0xb8860b),
|
||||
("darkgray", 0xa9a9a9),
|
||||
("darkgreen", 0x006400),
|
||||
("darkgrey", 0xa9a9a9),
|
||||
("darkkhaki", 0xbdb76b),
|
||||
("darkmagenta", 0x8b008b),
|
||||
("darkolivegreen", 0x556b2f),
|
||||
("darkorange", 0xff8c00),
|
||||
("darkorchid", 0x9932cc),
|
||||
("darkred", 0x8b0000),
|
||||
("darksalmon", 0xe9967a),
|
||||
("darkseagreen", 0x8fbc8f),
|
||||
("darkslateblue", 0x483d8b),
|
||||
("darkslategray", 0x2f4f4f),
|
||||
("darkslategrey", 0x2f4f4f),
|
||||
("darkturquoise", 0x00ced1),
|
||||
("darkviolet", 0x9400d3),
|
||||
("deeppink", 0xff1493),
|
||||
("deepskyblue", 0x00bfff),
|
||||
("dimgray", 0x696969),
|
||||
("dimgrey", 0x696969),
|
||||
("dodgerblue", 0x1e90ff),
|
||||
("firebrick", 0xb22222),
|
||||
("floralwhite", 0xfffaf0),
|
||||
("forestgreen", 0x228b22),
|
||||
("fuchsia", 0xff00ff),
|
||||
("gainsboro", 0xdcdcdc),
|
||||
("ghostwhite", 0xf8f8ff),
|
||||
("gold", 0xffd700),
|
||||
("goldenrod", 0xdaa520),
|
||||
("gray", 0x808080),
|
||||
("green", 0x008000),
|
||||
("greenyellow", 0xadff2f),
|
||||
("grey", 0x808080),
|
||||
("honeydew", 0xf0fff0),
|
||||
("hotpink", 0xff69b4),
|
||||
("indianred", 0xcd5c5c),
|
||||
("indigo", 0x4b0082),
|
||||
("ivory", 0xfffff0),
|
||||
("khaki", 0xf0e68c),
|
||||
("lavender", 0xe6e6fa),
|
||||
("lavenderblush", 0xfff0f5),
|
||||
("lawngreen", 0x7cfc00),
|
||||
("lemonchiffon", 0xfffacd),
|
||||
("lightblue", 0xadd8e6),
|
||||
("lightcoral", 0xf08080),
|
||||
("lightcyan", 0xe0ffff),
|
||||
("lightgoldenrodyellow", 0xfafad2),
|
||||
("lightgray", 0xd3d3d3),
|
||||
("lightgreen", 0x90ee90),
|
||||
("lightgrey", 0xd3d3d3),
|
||||
("lightpink", 0xffb6c1),
|
||||
("lightsalmon", 0xffa07a),
|
||||
("lightseagreen", 0x20b2aa),
|
||||
("lightskyblue", 0x87cefa),
|
||||
("lightslategray", 0x778899),
|
||||
("lightslategrey", 0x778899),
|
||||
("lightsteelblue", 0xb0c4de),
|
||||
("lightyellow", 0xffffe0),
|
||||
("lime", 0x00ff00),
|
||||
("limegreen", 0x32cd32),
|
||||
("linen", 0xfaf0e6),
|
||||
("magenta", 0xff00ff),
|
||||
("maroon", 0x800000),
|
||||
("mediumaquamarine", 0x66cdaa),
|
||||
("mediumblue", 0x0000cd),
|
||||
("mediumorchid", 0xba55d3),
|
||||
("mediumpurple", 0x9370db),
|
||||
("mediumseagreen", 0x3cb371),
|
||||
("mediumslateblue", 0x7b68ee),
|
||||
("mediumspringgreen", 0x00fa9a),
|
||||
("mediumturquoise", 0x48d1cc),
|
||||
("mediumvioletred", 0xc71585),
|
||||
("midnightblue", 0x191970),
|
||||
("mintcream", 0xf5fffa),
|
||||
("mistyrose", 0xffe4e1),
|
||||
("moccasin", 0xffe4b5),
|
||||
("navajowhite", 0xffdead),
|
||||
("navy", 0x000080),
|
||||
("oldlace", 0xfdf5e6),
|
||||
("olive", 0x808000),
|
||||
("olivedrab", 0x6b8e23),
|
||||
("orange", 0xffa500),
|
||||
("orangered", 0xff4500),
|
||||
("orchid", 0xda70d6),
|
||||
("palegoldenrod", 0xeee8aa),
|
||||
("palegreen", 0x98fb98),
|
||||
("paleturquoise", 0xafeeee),
|
||||
("palevioletred", 0xdb7093),
|
||||
("papayawhip", 0xffefd5),
|
||||
("peachpuff", 0xffdab9),
|
||||
("peru", 0xcd853f),
|
||||
("pink", 0xffc0cb),
|
||||
("plum", 0xdda0dd),
|
||||
("powderblue", 0xb0e0e6),
|
||||
("purple", 0x800080),
|
||||
("rebeccapurple", 0x663399),
|
||||
("red", 0xff0000),
|
||||
("rosybrown", 0xbc8f8f),
|
||||
("royalblue", 0x4169e1),
|
||||
("saddlebrown", 0x8b4513),
|
||||
("salmon", 0xfa8072),
|
||||
("sandybrown", 0xf4a460),
|
||||
("seagreen", 0x2e8b57),
|
||||
("seashell", 0xfff5ee),
|
||||
("sienna", 0xa0522d),
|
||||
("silver", 0xc0c0c0),
|
||||
("skyblue", 0x87ceeb),
|
||||
("slateblue", 0x6a5acd),
|
||||
("slategray", 0x708090),
|
||||
("slategrey", 0x708090),
|
||||
("snow", 0xfffafa),
|
||||
("springgreen", 0x00ff7f),
|
||||
("steelblue", 0x4682b4),
|
||||
("tan", 0xd2b48c),
|
||||
("teal", 0x008080),
|
||||
("thistle", 0xd8bfd8),
|
||||
("tomato", 0xff6347),
|
||||
("turquoise", 0x40e0d0),
|
||||
("violet", 0xee82ee),
|
||||
("wheat", 0xf5deb3),
|
||||
("white", 0xffffff),
|
||||
("whitesmoke", 0xf5f5f5),
|
||||
("yellow", 0xffff00),
|
||||
("yellowgreen", 0x9acd32),
|
||||
];
|
||||
|
||||
impl std::str::FromStr for Color {
|
||||
type Err = ColorParseError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
parse_color(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Color {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.to_css())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::ok_expect)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn c(input: &str) -> Color {
|
||||
parse_color(input).ok().expect("test input should parse")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hex_forms() {
|
||||
assert_eq!(c("#e8573f"), Color::new(0xe8, 0x57, 0x3f));
|
||||
assert_eq!(c("#E8573F"), Color::new(0xe8, 0x57, 0x3f));
|
||||
assert_eq!(c(" #000000 "), Color::new(0, 0, 0));
|
||||
assert_eq!(c("#fff"), Color::new(255, 255, 255));
|
||||
assert_eq!(c("#000"), Color::new(0, 0, 0));
|
||||
assert_eq!(c("#abc"), Color::new(0xaa, 0xbb, 0xcc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_css_keywords() {
|
||||
assert_eq!(c("black"), Color::new(0, 0, 0));
|
||||
assert_eq!(c("WHITE"), Color::new(255, 255, 255));
|
||||
assert_eq!(c("Silver"), Color::new(192, 192, 192));
|
||||
assert_eq!(c("cyan"), Color::new(0, 255, 255));
|
||||
// css-color-4 keeps both spellings for the two aliases.
|
||||
assert_eq!(c("magenta"), c("fuchsia"));
|
||||
assert_eq!(c("gray"), c("grey"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_rgb_function() {
|
||||
assert_eq!(c("rgb(232, 87, 63)"), Color::new(232, 87, 63));
|
||||
assert_eq!(c("rgb(232,87,63)"), Color::new(232, 87, 63));
|
||||
assert_eq!(c("rgb(232 87 63)"), Color::new(232, 87, 63));
|
||||
// 0–255 only; out-of-range integers are a parse error, not a clamp.
|
||||
assert!(parse_color("rgb(256, 0, 0)").is_err());
|
||||
assert!(parse_color("rgb(-1, 0, 0)").is_err());
|
||||
assert!(parse_color("rgb(1 2)").is_err());
|
||||
assert!(parse_color("rgb(1, 2, 3, 4)").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_and_unsupported_forms() {
|
||||
assert!(parse_color("").is_err());
|
||||
assert!(parse_color("#").is_err());
|
||||
assert!(parse_color("#ff").is_err());
|
||||
assert!(parse_color("#fffff").is_err());
|
||||
assert!(parse_color("#fffffff").is_err());
|
||||
assert!(parse_color("transparent").is_err());
|
||||
assert!(parse_color("rgba(1, 2, 3, 1.5)").is_err());
|
||||
assert!(parse_color("hsl(120, 50, 50%)").is_err());
|
||||
assert!(parse_color("rgb(150%, 0%, 0%)").is_err());
|
||||
assert!(parse_color("rgb(1, 2 / 3)").is_err());
|
||||
assert!(parse_color("currentColor").is_err());
|
||||
assert!(parse_color("#ggg").is_err());
|
||||
assert!(parse_color("rgb(1, 2").is_err());
|
||||
// Nothing from a too-long string is parsed.
|
||||
assert!(parse_color(&"k".repeat(65)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_alpha_percentage_and_hsl_forms() {
|
||||
assert_eq!(c("#f008"), Color::new(255, 0, 0));
|
||||
assert_eq!(c("#00ff0080"), Color::new(0, 255, 0));
|
||||
assert_eq!(c("rgba(1, 2, 3, 0.5)"), Color::new(1, 2, 3));
|
||||
assert_eq!(c("rgb(1 2 3 / 50%)"), Color::new(1, 2, 3));
|
||||
assert_eq!(c("rgb(100%, 0%, 50%)"), Color::new(255, 0, 128));
|
||||
assert_eq!(c("RGB(10,20,30)"), Color::new(10, 20, 30));
|
||||
assert_eq!(c("hsl(0, 100%, 50%)"), Color::new(255, 0, 0));
|
||||
assert_eq!(c("hsl(120deg 100% 25%)"), Color::new(0, 128, 0));
|
||||
assert_eq!(c("hsla(240, 100%, 50%, 0.3)"), Color::new(0, 0, 255));
|
||||
assert_eq!(c("hsl(-120, 100%, 50%)"), Color::new(0, 0, 255));
|
||||
assert_eq!(c("hsl(0, 0%, 100%)"), Color::new(255, 255, 255));
|
||||
assert_eq!(c("rebeccapurple"), Color::new(0x66, 0x33, 0x99));
|
||||
assert_eq!(c("CornflowerBlue"), Color::new(0x64, 0x95, 0xed));
|
||||
assert_eq!(NAMED.len(), 148);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_fromstr_and_display() {
|
||||
let color: Color = "#3f88e8".parse().ok().expect("hex parses");
|
||||
assert_eq!(color.to_string(), "#3f88e8");
|
||||
// Display is canonical lowercase six-digit.
|
||||
assert_eq!(c("#ABC").to_string(), "#aabbcc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn luminance_is_zero_to_one() {
|
||||
assert_eq!(c("#000000").luminance(), 0.0);
|
||||
assert_eq!(c("#ffffff").luminance(), 1.0);
|
||||
let mid = c("#7f7f7f").luminance();
|
||||
assert!((0.2..0.7).contains(&mid), "middle gray ≈ {mid}");
|
||||
// Refined weighting only shifts one step, never flips extremes.
|
||||
assert!(c("#e8573f").luminance() < c("#ffffff").luminance());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_interpolates_and_clamps() {
|
||||
let black = c("#000000");
|
||||
let white = c("#ffffff");
|
||||
assert_eq!(black.blend(white, 0.0), black);
|
||||
assert_eq!(black.blend(white, 1.0), white);
|
||||
assert_eq!(black.blend(white, 0.5), c("#808080"));
|
||||
// Out-of-range and non-finite t are clamped, never wrap.
|
||||
assert_eq!(black.blend(white, 5.0), white);
|
||||
assert_eq!(black.blend(white, f32::NAN), black);
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,20 @@
|
||||
//! class. Input is treated as hostile: size, node, depth and instance limits, and cyclic
|
||||
//! references, are errors.
|
||||
//!
|
||||
//! Import only, and geometry only: this is not the shared SVG model of craft-libs#789. Boolean
|
||||
//! operations (applying [`Clip`]s) and colour parsing are left to the caller, so the crate
|
||||
//! depends on no other Craft crate.
|
||||
//! Geometry only: this is not the shared SVG model of craft-libs#789. Boolean operations
|
||||
//! (applying [`Clip`]s) are left to the caller. Stroke paint is returned as written;
|
||||
//! [`color::parse_color`] reads it as a CSS colour.
|
||||
//!
|
||||
//! [`write`] is the other direction: a small writer for SVG documents, their path data and
|
||||
//! numbers.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use kurbo::BezPath;
|
||||
|
||||
pub mod color;
|
||||
pub mod write;
|
||||
|
||||
/// Why an SVG could not be read; the message starts with the name given to [`import`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("{0}")]
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Writes SVG documents: the declaration and root element, elements and attributes with XML
|
||||
//! escaping (through `craft-xml`), path data and numbers.
|
||||
//!
|
||||
//! The caller decides what to draw and how the file is laid out; this module makes what it
|
||||
//! writes well-formed. Attribute values and text are escaped, so no value can inject markup.
|
||||
//!
|
||||
//! ```
|
||||
//! use craft_svg_paths::write::{SvgWriter, num, path_data};
|
||||
//!
|
||||
//! let mut path = kurbo::BezPath::new();
|
||||
//! path.move_to((1.0, 2.0));
|
||||
//! path.line_to((3.5, 2.0));
|
||||
//! let mut w = SvgWriter::new();
|
||||
//! w.root().attr("viewBox", format_args!("0 0 {} {}", num(10.0, 1), num(5.0, 1))).open();
|
||||
//! w.line(1);
|
||||
//! w.tag("path").attr("d", path_data(&path)).empty();
|
||||
//! w.line(0);
|
||||
//! w.close();
|
||||
//! assert_eq!(
|
||||
//! w.finish(),
|
||||
//! "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 10.0 5.0\">\n <path d=\"M1,2 L3.5,2\"/>\n</svg>"
|
||||
//! );
|
||||
//! ```
|
||||
|
||||
use std::fmt::{self, Display, Write as _};
|
||||
|
||||
pub use craft_xml::Tag;
|
||||
use kurbo::{BezPath, PathEl, Point};
|
||||
|
||||
/// The SVG namespace.
|
||||
pub const SVG_NS: &str = "http://www.w3.org/2000/svg";
|
||||
|
||||
/// A number written with a fixed count of decimals. A value that is not finite is written as
|
||||
/// zero, because SVG has no spelling for it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Num(f64, usize);
|
||||
|
||||
/// `value` with `decimals` decimals: `num(1.5, 3)` is written `1.500`.
|
||||
pub fn num(value: f64, decimals: usize) -> Num {
|
||||
Num(value, decimals)
|
||||
}
|
||||
|
||||
impl Display for Num {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let value = if self.0.is_finite() { self.0 } else { 0.0 };
|
||||
write!(f, "{value:.*}", self.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Path data (the `d` attribute) with every coordinate in its shortest form that reads back
|
||||
/// as the same number: `M1,2 L3.5,2 Z`.
|
||||
pub fn path_data(path: &BezPath) -> String {
|
||||
path.to_svg()
|
||||
}
|
||||
|
||||
/// Path data with `decimals` decimals per coordinate, written compactly: `M1.00 2.00L3.50 2.00Z`.
|
||||
/// Coordinates that are not finite are written as zero.
|
||||
pub fn path_data_fixed(path: &BezPath, decimals: usize) -> String {
|
||||
let mut d = String::new();
|
||||
let mut command = |letter: char, points: &[Point]| {
|
||||
d.push(letter);
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
let sep = if i == 0 { "" } else { " " };
|
||||
let _ = write!(d, "{sep}{} {}", num(p.x, decimals), num(p.y, decimals));
|
||||
}
|
||||
};
|
||||
for el in path.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => command('M', &[p]),
|
||||
PathEl::LineTo(p) => command('L', &[p]),
|
||||
PathEl::QuadTo(a, p) => command('Q', &[a, p]),
|
||||
PathEl::CurveTo(a, b, p) => command('C', &[a, b, p]),
|
||||
PathEl::ClosePath => command('Z', &[]),
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Builds an SVG document or fragment. Elements are closed for the caller and attribute
|
||||
/// values and text are escaped. Element and attribute names are written as given: they are
|
||||
/// the caller's own constants, not checked.
|
||||
///
|
||||
/// Whitespace between elements is the caller's: nothing is added unless [`SvgWriter::line`]
|
||||
/// is called.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SvgWriter(craft_xml::Writer);
|
||||
|
||||
impl SvgWriter {
|
||||
/// A document: starts with the XML declaration.
|
||||
pub fn new() -> SvgWriter {
|
||||
let mut xml = craft_xml::Writer::new();
|
||||
xml.decl(false);
|
||||
SvgWriter(xml)
|
||||
}
|
||||
/// A fragment, or a document without the XML declaration (for embedding in HTML or JSON).
|
||||
pub fn fragment() -> SvgWriter {
|
||||
SvgWriter::default()
|
||||
}
|
||||
/// Starts the root tag, `<svg xmlns="http://www.w3.org/2000/svg"`: add attributes and end
|
||||
/// it with [`Tag::open`].
|
||||
pub fn root(&mut self) -> Tag<'_> {
|
||||
self.0.tag("svg").attr("xmlns", SVG_NS)
|
||||
}
|
||||
/// Starts a tag: `w.tag("rect").attr("width", num(w, 3)).empty()`.
|
||||
pub fn tag<'w>(&'w mut self, name: &'w str) -> Tag<'w> {
|
||||
self.0.tag(name)
|
||||
}
|
||||
/// Closes the innermost open element. Nothing happens when none is open.
|
||||
pub fn close(&mut self) {
|
||||
self.0.close();
|
||||
}
|
||||
/// Escaped text.
|
||||
pub fn text(&mut self, text: &str) {
|
||||
self.0.text(text);
|
||||
}
|
||||
/// Starts a new line, indented `depth` levels of two spaces.
|
||||
pub fn line(&mut self, depth: usize) {
|
||||
self.0.raw("\n");
|
||||
(0..depth).for_each(|_| self.0.raw(" "));
|
||||
}
|
||||
/// Markup another [`SvgWriter`] has already written, as it is.
|
||||
pub fn raw(&mut self, svg: &str) {
|
||||
self.0.raw(svg);
|
||||
}
|
||||
/// How many bytes have been written so far.
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.as_str().len()
|
||||
}
|
||||
/// Has nothing been written yet?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
/// Closes every element still open and returns the text.
|
||||
pub fn finish(self) -> String {
|
||||
self.0.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn sample() -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
p.move_to((1.0, 2.0));
|
||||
p.line_to((3.5, -2.0));
|
||||
p.quad_to((4.0, 5.0), (6.0, 7.25));
|
||||
p.curve_to((1.0, 1.0), (2.0, 2.0), (3.0, 1.0 / 3.0));
|
||||
p.close_path();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_have_fixed_decimals_and_are_never_non_finite() {
|
||||
assert_eq!(num(1.5, 3).to_string(), "1.500");
|
||||
assert_eq!(num(-2.0, 0).to_string(), "-2");
|
||||
assert_eq!(num(0.126, 2).to_string(), "0.13");
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(num(bad, 2).to_string(), "0.00");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_data_reads_back_as_the_same_path() {
|
||||
let d = path_data(&sample());
|
||||
assert_eq!(d, "M1,2 L3.5,-2 Q4,5 6,7.25 C1,1 2,2 3,0.3333333333333333 Z");
|
||||
assert_eq!(BezPath::from_svg(&d).unwrap(), sample());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_path_data_is_compact_and_parses() {
|
||||
let d = path_data_fixed(&sample(), 2);
|
||||
assert_eq!(d, "M1.00 2.00L3.50 -2.00Q4.00 5.00 6.00 7.25C1.00 1.00 2.00 2.00 3.00 0.33Z");
|
||||
assert_eq!(BezPath::from_svg(&d).unwrap().elements().len(), 5);
|
||||
let mut bad = BezPath::new();
|
||||
bad.move_to((f64::NAN, 1.0));
|
||||
assert_eq!(path_data_fixed(&bad, 1), "M0.0 1.0");
|
||||
assert_eq!(path_data_fixed(&BezPath::new(), 2), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_a_document_the_reader_reads() {
|
||||
let mut w = SvgWriter::new();
|
||||
assert!(!w.is_empty());
|
||||
w.root().attr("width", format_args!("{}mm", num(20.0, 3))).attr("height", "10mm").attr("viewBox", "0 0 20 10").open();
|
||||
w.line(1);
|
||||
w.tag("title").text("a < b & \"c\"");
|
||||
w.line(1);
|
||||
w.tag("g").attr("class", "x\"><script>").open();
|
||||
w.line(2);
|
||||
w.tag("path").attr("d", path_data(&sample())).empty();
|
||||
w.line(1);
|
||||
w.close();
|
||||
w.line(0);
|
||||
let svg = w.finish();
|
||||
assert_eq!(
|
||||
svg,
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
|
||||
<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"20.000mm\" height=\"10mm\" viewBox=\"0 0 20 10\">\n \
|
||||
<title>a < b & \"c\"</title>\n \
|
||||
<g class=\"x"><script>\">\n \
|
||||
<path d=\"M1,2 L3.5,-2 Q4,5 6,7.25 C1,1 2,2 3,0.3333333333333333 Z\"/>\n \
|
||||
</g>\n</svg>"
|
||||
);
|
||||
#[derive(Clone, Default)]
|
||||
struct NoKind;
|
||||
impl crate::Kind for NoKind {
|
||||
const PROPERTIES: &'static [&'static str] = &[];
|
||||
fn of(_: &crate::StyleMap, _: &str) -> Option<Self> {
|
||||
None
|
||||
}
|
||||
}
|
||||
let read = crate::import::<NoKind>(&svg, "written.svg").unwrap();
|
||||
assert_eq!((read.width_mm, read.height_mm), (20.0, 10.0));
|
||||
assert_eq!(read.shapes.len(), 1);
|
||||
assert_eq!(read.shapes.first().map(|s| &s.path), Some(&sample()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragments_have_no_declaration_and_embed_as_written() {
|
||||
let mut inner = SvgWriter::fragment();
|
||||
assert!(inner.is_empty());
|
||||
inner.tag("path").attr("d", "M0 0").empty();
|
||||
let inner = inner.finish();
|
||||
let mut w = SvgWriter::fragment();
|
||||
w.root().open();
|
||||
let before = w.len();
|
||||
w.raw(&inner);
|
||||
assert_eq!(w.len(), before + inner.len());
|
||||
assert_eq!(w.finish(), "<svg xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M0 0\"/></svg>");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user