Files
effectcraft/crates/svg/src/lib.rs
T
Brandon ThomasandClaude Opus 5.5 94e1c86d89 IO-2/SHP-5/PRV-2: PSD import, SVG import + vector/text conversions, persistent disk cache
- effectcraft-psd (L1): PSD/PSB reader from Adobe's published file format spec (layers, groups,
  masks, blend modes, text engine data, lfx2 effects, fill/adjustment layers, vector masks;
  1/8/16/32-bit, RGB/CMYK/Gray/Lab/Indexed, raw/RLE/ZIP) and a minimal writer for fixtures.
- File > Import: importAs footage|composition|compositionLayerSizes, layer (Choose Layer);
  groups -> precomps (pass-through collapses), text -> editable text, styles -> Layer Styles,
  adjustments -> adjustment layers with effects; UI asks the Import Kind for Photoshop files.
- effectcraft-svg (L2): SVG parser (shapes, paths, transforms, use, CSS, gradients) and
  rasteriser; .svg imports as footage, continuously rasterised when the switch is on.
- layer.create: shapesFromVector, shapesFromText, masksFromText, editableText (stub removed).
- Disk cache (render::disk_cache): LZ4 content-keyed frames and layer buffers, LRU size limit
  from prefs, atomic writes, restart persistence; edit.purge clears it, cache.diskStats query;
  viewer frames read/write it and the timeline shows disk-only frames in blue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 22:10:48 -04:00

950 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! SVG import (see README.md for the supported subset and the specifications used).
//!
//! [`parse`] resolves styles, `use` references and gradients into a small render tree
//! ([`Doc`]): groups with transforms and opacity, and shapes with fills and strokes in their own
//! user space. [`rasterize`] draws it at any scale; the engine converts it to shape layers.
mod color;
mod pathdata;
mod raster;
use std::collections::HashMap;
pub use color::parse_color;
pub use kurbo::{Affine, BezPath};
pub use pathdata::parse_path_data;
pub use raster::{rasterize, rasterize_with};
#[derive(Debug, Clone, PartialEq)]
pub enum Error {
Xml(String),
NotSvg,
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Xml(e) => write!(f, "invalid SVG: {e}"),
Error::NotSvg => write!(f, "not an SVG document"),
}
}
}
impl std::error::Error for Error {}
/// Quick sniff: an XML document whose root looks like `<svg`.
pub fn looks_like_svg(bytes: &[u8]) -> bool {
let head = &bytes[..bytes.len().min(2048)];
let s = String::from_utf8_lossy(head);
s.contains("<svg") && (s.trim_start().starts_with('<') || s.starts_with('\u{feff}'))
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FillRule {
#[default]
NonZero,
EvenOdd,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Cap {
#[default]
Butt,
Round,
Square,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Join {
#[default]
Miter,
Round,
Bevel,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Spread {
#[default]
Pad,
Reflect,
Repeat,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum GradientKind {
Linear { x1: f64, y1: f64, x2: f64, y2: f64 },
Radial { cx: f64, cy: f64, r: f64, fx: f64, fy: f64 },
}
#[derive(Clone, Debug, PartialEq)]
pub struct Gradient {
pub kind: GradientKind,
/// (offset 0..1, straight RGBA 0..1), sorted.
pub stops: Vec<(f64, [f64; 4])>,
/// `gradientUnits="objectBoundingBox"` (the default): coordinates are fractions of the
/// shape's bounding box.
pub bbox_units: bool,
pub transform: Affine,
pub spread: Spread,
}
#[derive(Clone, Debug, PartialEq)]
pub enum Paint {
Color([f64; 3]),
Gradient(Gradient),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Stroke {
pub paint: Paint,
pub opacity: f64,
pub width: f64,
pub cap: Cap,
pub join: Join,
pub miter: f64,
/// Dash array and offset.
pub dash: Option<(Vec<f64>, f64)>,
}
/// Shape geometry in its user space (basic shapes are kept so they can become parametric shape
/// layer items).
#[derive(Clone, Debug, PartialEq)]
pub enum Geom {
Rect { x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64 },
Ellipse { cx: f64, cy: f64, rx: f64, ry: f64 },
Path(BezPath),
}
impl Geom {
pub fn to_path(&self) -> BezPath {
use kurbo::Shape as _;
match self {
Geom::Rect { x, y, w, h, rx, ry } => {
let r = kurbo::Rect::new(*x, *y, x + w, y + h);
if *rx > 0.0 || *ry > 0.0 {
if (rx - ry).abs() < 1e-9 { kurbo::RoundedRect::from_rect(r, *rx).to_path(0.01) } else { elliptic_round_rect(*x, *y, *w, *h, *rx, *ry) }
} else {
r.to_path(0.01)
}
}
Geom::Ellipse { cx, cy, rx, ry } => kurbo::Ellipse::new((*cx, *cy), (*rx, *ry), 0.0).to_path(0.01),
Geom::Path(p) => p.clone(),
}
}
}
fn elliptic_round_rect(x: f64, y: f64, w: f64, h: f64, rx: f64, ry: f64) -> BezPath {
const K: f64 = 0.552_284_749_830_793_6;
let (kx, ky) = (rx * K, ry * K);
let mut p = BezPath::new();
p.move_to((x + rx, y));
p.line_to((x + w - rx, y));
p.curve_to((x + w - rx + kx, y), (x + w, y + ry - ky), (x + w, y + ry));
p.line_to((x + w, y + h - ry));
p.curve_to((x + w, y + h - ry + ky), (x + w - rx + kx, y + h), (x + w - rx, y + h));
p.line_to((x + rx, y + h));
p.curve_to((x + rx - kx, y + h), (x, y + h - ry + ky), (x, y + h - ry));
p.line_to((x, y + ry));
p.curve_to((x, y + ry - ky), (x + rx - kx, y), (x + rx, y));
p.close_path();
p
}
#[derive(Clone, Debug, PartialEq)]
pub struct Shape {
/// Element id, or the element name (`rect`, `path`…).
pub name: String,
pub geom: Geom,
/// User space → parent space.
pub transform: Affine,
pub fill: Option<Paint>,
pub fill_opacity: f64,
pub fill_rule: FillRule,
pub stroke: Option<Stroke>,
pub opacity: f64,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Group {
pub name: String,
pub transform: Affine,
pub opacity: f64,
pub children: Vec<Node>,
}
#[derive(Clone, Debug, PartialEq)]
#[allow(clippy::large_enum_variant)]
pub enum Node {
Group(Group),
Shape(Shape),
}
/// A parsed SVG document. `root.transform` maps the viewBox to `width`×`height` pixels.
#[derive(Clone, Debug, PartialEq)]
pub struct Doc {
pub width: f64,
pub height: f64,
pub root: Group,
/// Elements that were skipped (text, image, filter…), by name.
pub skipped: Vec<String>,
}
impl Doc {
/// Pixel size (rounded up, at least 1×1).
pub fn pixel_size(&self) -> (u32, u32) {
(self.width.ceil().clamp(1.0, 30000.0) as u32, self.height.ceil().clamp(1.0, 30000.0) as u32)
}
/// Every shape with its accumulated transform (to document pixels) and opacity, in paint
/// order.
pub fn flatten(&self) -> Vec<(Affine, f64, &Shape)> {
fn walk<'a>(g: &'a Group, m: Affine, o: f64, out: &mut Vec<(Affine, f64, &'a Shape)>) {
let m = m * g.transform;
let o = o * g.opacity;
for c in &g.children {
match c {
Node::Group(sub) => walk(sub, m, o, out),
Node::Shape(s) => out.push((m * s.transform, o * s.opacity, s)),
}
}
}
let mut out = vec![];
walk(&self.root, Affine::IDENTITY, 1.0, &mut out);
out
}
}
// ---------------------------------------------------------------- styles
/// Inherited style state.
#[derive(Clone, Debug)]
struct Style {
fill: Option<PaintRef>,
fill_opacity: f64,
fill_rule: FillRule,
stroke: Option<PaintRef>,
stroke_opacity: f64,
stroke_width: f64,
cap: Cap,
join: Join,
miter: f64,
dash: Option<Vec<f64>>,
dash_offset: f64,
color: [f64; 4],
visible: bool,
font_size: f64,
}
impl Default for Style {
fn default() -> Self {
Style {
fill: Some(PaintRef::Color([0.0, 0.0, 0.0, 1.0])),
fill_opacity: 1.0,
fill_rule: FillRule::NonZero,
stroke: None,
stroke_opacity: 1.0,
stroke_width: 1.0,
cap: Cap::Butt,
join: Join::Miter,
miter: 4.0,
dash: None,
dash_offset: 0.0,
color: [0.0, 0.0, 0.0, 1.0],
visible: true,
font_size: 16.0,
}
}
}
#[derive(Clone, Debug)]
enum PaintRef {
Color([f64; 4]),
Url(String, Option<[f64; 4]>),
Current,
}
/// A CSS rule: selector (type, class, id; `*`) with its specificity and declarations.
#[derive(Clone, Debug)]
struct Rule {
tag: Option<String>,
classes: Vec<String>,
id: Option<String>,
specificity: u32,
order: usize,
decls: Vec<(String, String)>,
}
fn parse_decls(s: &str) -> Vec<(String, String)> {
s.split(';')
.filter_map(|d| {
let (k, v) = d.split_once(':')?;
let v = v.trim().trim_end_matches("!important").trim();
Some((k.trim().to_ascii_lowercase(), v.to_string()))
})
.filter(|(k, _)| !k.is_empty())
.collect()
}
fn strip_comments(s: &str) -> String {
let mut out = String::new();
let mut rest = s;
while let Some(i) = rest.find("/*") {
out.push_str(&rest[..i]);
match rest[i + 2..].find("*/") {
Some(j) => rest = &rest[i + 2 + j + 2..],
None => return out,
}
}
out.push_str(rest);
out
}
fn parse_css(css: &str, rules: &mut Vec<Rule>) {
let css = strip_comments(css);
let mut rest = css.as_str();
while let Some(open) = rest.find('{') {
let sel = rest[..open].trim();
let Some(close) = rest[open..].find('}') else { break };
let body = &rest[open + 1..open + close];
rest = &rest[open + close + 1..];
if sel.starts_with('@') {
continue;
}
let decls = parse_decls(body);
for one in sel.split(',') {
let one = one.trim();
// Only simple selectors (no combinators / pseudo-classes / attributes).
if one.is_empty() || one.contains([' ', '>', '+', '~', ':', '[']) {
continue;
}
let mut tag = None;
let mut classes = vec![];
let mut id = None;
let mut cur = String::new();
let mut kind = 't';
let flush = |kind: char, cur: &mut String, tag: &mut Option<String>, classes: &mut Vec<String>, id: &mut Option<String>| {
if !cur.is_empty() {
match kind {
'.' => classes.push(std::mem::take(cur)),
'#' => *id = Some(std::mem::take(cur)),
_ => {
if cur != "*" {
*tag = Some(std::mem::take(cur));
}
cur.clear();
}
}
}
};
for c in one.chars() {
if c == '.' || c == '#' {
flush(kind, &mut cur, &mut tag, &mut classes, &mut id);
kind = c;
} else {
cur.push(c);
}
}
flush(kind, &mut cur, &mut tag, &mut classes, &mut id);
let specificity = id.is_some() as u32 * 100 + classes.len() as u32 * 10 + tag.is_some() as u32;
let order = rules.len();
rules.push(Rule { tag, classes, id, specificity, order, decls: decls.clone() });
}
}
}
const PROPS: &[&str] = &[
"fill",
"fill-opacity",
"fill-rule",
"stroke",
"stroke-width",
"stroke-opacity",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"stroke-dasharray",
"stroke-dashoffset",
"opacity",
"display",
"visibility",
"color",
"font-size",
"transform",
"stop-color",
"stop-opacity",
];
struct Parser<'a, 'i> {
ids: HashMap<String, roxmltree::Node<'a, 'i>>,
rules: Vec<Rule>,
vw: f64,
vh: f64,
skipped: Vec<String>,
}
fn is_svg_ns(n: &roxmltree::Node) -> bool {
n.is_element()
}
impl<'a, 'i> Parser<'a, 'i> {
/// The declared properties of an element: presentation attributes, then CSS rules (by
/// specificity), then the `style` attribute.
fn declared(&self, n: roxmltree::Node) -> HashMap<String, String> {
let mut out = HashMap::new();
for a in n.attributes() {
if PROPS.contains(&a.name()) && a.name() != "transform" {
out.insert(a.name().to_string(), a.value().to_string());
}
}
let tag = n.tag_name().name();
let classes: Vec<&str> = n.attribute("class").map(|c| c.split_whitespace().collect()).unwrap_or_default();
let id = n.attribute("id");
let mut matching: Vec<&Rule> = self
.rules
.iter()
.filter(|r| {
r.tag.as_deref().is_none_or(|t| t == tag)
&& r.id.as_deref().is_none_or(|i| Some(i) == id)
&& r.classes.iter().all(|c| classes.contains(&c.as_str()))
})
.collect();
matching.sort_by_key(|r| (r.specificity, r.order));
for r in matching {
for (k, v) in &r.decls {
out.insert(k.clone(), v.clone());
}
}
if let Some(st) = n.attribute("style") {
for (k, v) in parse_decls(st) {
out.insert(k, v);
}
}
out
}
fn length(&self, s: &str, axis: char, font: f64) -> Option<f64> {
let s = s.trim();
let num_end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E'))).unwrap_or(s.len());
// "e" also starts "em"/"ex": back off if the number does not parse.
let (mut num, mut unit) = (&s[..num_end], &s[num_end..]);
if num.parse::<f64>().is_err() && (num.ends_with('e') || num.ends_with('E')) {
num = &s[..num_end - 1];
unit = &s[num_end - 1..];
}
let v: f64 = num.parse().ok()?;
Some(match unit.trim() {
"" | "px" => v,
"pt" => v * 4.0 / 3.0,
"pc" => v * 16.0,
"mm" => v * 96.0 / 25.4,
"cm" => v * 96.0 / 2.54,
"in" => v * 96.0,
"em" => v * font,
"ex" => v * font / 2.0,
"%" => {
let base = match axis {
'x' => self.vw,
'y' => self.vh,
_ => ((self.vw * self.vw + self.vh * self.vh) / 2.0).sqrt(),
};
v / 100.0 * base
}
_ => v,
})
}
fn attr_len(&self, n: roxmltree::Node, name: &str, axis: char, st: &Style) -> f64 {
n.attribute(name).and_then(|v| self.length(v, axis, st.font_size)).unwrap_or(0.0)
}
fn paint_ref(&self, v: &str) -> Option<Option<PaintRef>> {
let v = v.trim();
if v == "none" {
return Some(None);
}
if v == "currentColor" || v == "currentcolor" {
return Some(Some(PaintRef::Current));
}
if let Some(rest) = v.strip_prefix("url(") {
let (url, fallback) = rest.split_once(')').unwrap_or((rest, ""));
let id = url.trim().trim_matches(['"', '\'']).trim_start_matches('#').to_string();
let fb = fallback.trim();
let fb = if fb.is_empty() || fb == "none" { None } else { parse_color(fb) };
return Some(Some(PaintRef::Url(id, fb)));
}
if v == "inherit" {
return None;
}
parse_color(v).map(|c| Some(PaintRef::Color(c)))
}
/// Apply an element's declared inherited properties to `st`. Returns (opacity, display).
fn cascade(&self, n: roxmltree::Node, st: &mut Style) -> (f64, bool) {
let d = self.declared(n);
let num =
|k: &str| d.get(k).and_then(|v| v.trim().trim_end_matches('%').parse::<f64>().ok().map(|x| if v.trim().ends_with('%') { x / 100.0 } else { x }));
if let Some(c) = d.get("color").and_then(|v| parse_color(v)) {
st.color = c;
}
if let Some(v) = d.get("font-size").and_then(|v| self.length(v, 'o', st.font_size)) {
st.font_size = v;
}
if let Some(p) = d.get("fill").and_then(|v| self.paint_ref(v)) {
st.fill = p;
}
if let Some(p) = d.get("stroke").and_then(|v| self.paint_ref(v)) {
st.stroke = p;
}
if let Some(v) = num("fill-opacity") {
st.fill_opacity = v.clamp(0.0, 1.0);
}
if let Some(v) = num("stroke-opacity") {
st.stroke_opacity = v.clamp(0.0, 1.0);
}
if let Some(v) = d.get("stroke-width").and_then(|v| self.length(v, 'o', st.font_size)) {
st.stroke_width = v.max(0.0);
}
if let Some(v) = num("stroke-miterlimit") {
st.miter = v.max(1.0);
}
if let Some(v) = d.get("fill-rule") {
st.fill_rule = if v.trim() == "evenodd" { FillRule::EvenOdd } else { FillRule::NonZero };
}
if let Some(v) = d.get("stroke-linecap") {
st.cap = match v.trim() {
"round" => Cap::Round,
"square" => Cap::Square,
_ => Cap::Butt,
};
}
if let Some(v) = d.get("stroke-linejoin") {
st.join = match v.trim() {
"round" => Join::Round,
"bevel" => Join::Bevel,
_ => Join::Miter,
};
}
if let Some(v) = d.get("stroke-dasharray") {
st.dash = if v.trim() == "none" {
None
} else {
let a: Vec<f64> =
v.split(|c: char| c == ',' || c.is_whitespace()).filter(|t| !t.is_empty()).filter_map(|t| self.length(t, 'o', st.font_size)).collect();
if a.iter().all(|x| *x >= 0.0) && a.iter().any(|x| *x > 0.0) { Some(a) } else { None }
};
}
if let Some(v) = d.get("stroke-dashoffset").and_then(|v| self.length(v, 'o', st.font_size)) {
st.dash_offset = v;
}
if let Some(v) = d.get("visibility") {
st.visible = !matches!(v.trim(), "hidden" | "collapse");
}
let display = d.get("display").is_none_or(|v| v.trim() != "none");
(num("opacity").unwrap_or(1.0).clamp(0.0, 1.0), display)
}
fn resolve_paint(&self, p: &PaintRef, st: &Style) -> Option<(Paint, f64)> {
match p {
PaintRef::Color(c) => Some((Paint::Color([c[0], c[1], c[2]]), c[3])),
PaintRef::Current => Some((Paint::Color([st.color[0], st.color[1], st.color[2]]), st.color[3])),
PaintRef::Url(id, fb) => match self.gradient(id, 0) {
Some(g) if g.stops.len() == 1 => {
let c = g.stops[0].1;
Some((Paint::Color([c[0], c[1], c[2]]), c[3]))
}
Some(g) if !g.stops.is_empty() => Some((Paint::Gradient(g), 1.0)),
Some(_) => None,
None => fb.map(|c| (Paint::Color([c[0], c[1], c[2]]), c[3])),
},
}
}
fn href(&self, n: roxmltree::Node<'a, 'i>) -> Option<roxmltree::Node<'a, 'i>> {
let h = n.attributes().find(|a| a.name() == "href").map(|a| a.value())?;
self.ids.get(h.trim().trim_start_matches('#')).copied()
}
/// A gradient by id, with `href` inheritance of attributes and stops.
fn gradient(&self, id: &str, depth: usize) -> Option<Gradient> {
let n = *self.ids.get(id)?;
let tag = n.tag_name().name();
if !matches!(tag, "linearGradient" | "radialGradient") || depth > 16 {
return None;
}
// Chain: this element, then what it references.
let mut chain = vec![n];
let mut cur = n;
while let Some(next) = self.href(cur) {
if chain.len() > 16 || chain.contains(&next) {
break;
}
chain.push(next);
cur = next;
}
let attr = |name: &str| chain.iter().find_map(|e| e.attribute(name));
let bbox_units = attr("gradientUnits") != Some("userSpaceOnUse");
let st = Style::default();
let len = |name: &str, axis: char, def: &str| -> f64 {
let v = attr(name).unwrap_or(def);
if bbox_units {
let v = v.trim();
if let Some(p) = v.strip_suffix('%') { p.parse::<f64>().unwrap_or(0.0) / 100.0 } else { v.parse::<f64>().unwrap_or(0.0) }
} else {
self.length(v, axis, st.font_size).unwrap_or(0.0)
}
};
let kind = if tag == "linearGradient" {
GradientKind::Linear { x1: len("x1", 'x', "0%"), y1: len("y1", 'y', "0%"), x2: len("x2", 'x', "100%"), y2: len("y2", 'y', "0%") }
} else {
let cx = len("cx", 'x', "50%");
let cy = len("cy", 'y', "50%");
let r = len("r", 'o', "50%");
let fx = if attr("fx").is_some() { len("fx", 'x', "50%") } else { cx };
let fy = if attr("fy").is_some() { len("fy", 'y', "50%") } else { cy };
GradientKind::Radial { cx, cy, r, fx, fy }
};
let transform = attr("gradientTransform").map(parse_transform).unwrap_or(Affine::IDENTITY);
let spread = match attr("spreadMethod") {
Some("reflect") => Spread::Reflect,
Some("repeat") => Spread::Repeat,
_ => Spread::Pad,
};
// Stops: from the first element in the chain that has any.
let mut stops = vec![];
if let Some(src) = chain.iter().find(|e| e.children().any(|c| c.is_element() && c.tag_name().name() == "stop")) {
let mut last = 0.0f64;
for s in src.children().filter(|c| c.is_element() && c.tag_name().name() == "stop") {
let d = self.declared(s);
let off = s
.attribute("offset")
.map(|o| {
let o = o.trim();
if let Some(p) = o.strip_suffix('%') { p.parse::<f64>().unwrap_or(0.0) / 100.0 } else { o.parse::<f64>().unwrap_or(0.0) }
})
.unwrap_or(0.0)
.clamp(0.0, 1.0)
.max(last);
last = off;
let c = d
.get("stop-color")
.and_then(|c| if c.trim() == "currentColor" { d.get("color").and_then(|x| parse_color(x)) } else { parse_color(c) })
.unwrap_or([0.0, 0.0, 0.0, 1.0]);
let o = d.get("stop-opacity").and_then(|v| v.trim().parse::<f64>().ok()).unwrap_or(1.0).clamp(0.0, 1.0);
stops.push((off, [c[0], c[1], c[2], c[3] * o]));
}
}
Some(Gradient { kind, stops, bbox_units, transform, spread })
}
fn element_name(n: roxmltree::Node) -> String {
n.attribute("id").map(str::to_string).unwrap_or_else(|| n.tag_name().name().to_string())
}
/// Convert an element (and its subtree) into nodes appended to `out`.
fn node(&mut self, n: roxmltree::Node<'a, 'i>, parent: &Style, out: &mut Vec<Node>, depth: usize) {
if depth > 64 || !is_svg_ns(&n) {
return;
}
let tag = n.tag_name().name();
match tag {
"defs" | "title" | "desc" | "metadata" | "style" | "linearGradient" | "radialGradient" | "symbol" | "clipPath" | "mask" | "pattern" | "marker"
| "script" => return,
"text" | "image" | "filter" | "foreignObject" | "switch" => {
self.skipped.push(tag.to_string());
return;
}
_ => {}
}
let mut st = parent.clone();
let (opacity, display) = self.cascade(n, &mut st);
if !display {
return;
}
let mut transform = n.attribute("transform").map(parse_transform).unwrap_or(Affine::IDENTITY);
match tag {
"g" | "a" => {
let mut children = vec![];
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
}
"svg" => {
// Nested viewport.
let x = self.attr_len(n, "x", 'x', &st);
let y = self.attr_len(n, "y", 'y', &st);
let w = n.attribute("width").and_then(|v| self.length(v, 'x', st.font_size)).unwrap_or(self.vw);
let h = n.attribute("height").and_then(|v| self.length(v, 'y', st.font_size)).unwrap_or(self.vh);
let vb = viewbox_transform(n, w, h);
let mut children = vec![];
let saved = (self.vw, self.vh);
if let Some((_, vw, vh)) = viewbox(n) {
self.vw = vw;
self.vh = vh;
}
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
(self.vw, self.vh) = saved;
out.push(Node::Group(Group { name: Self::element_name(n), transform: Affine::translate((x, y)) * vb, opacity, children }));
}
"use" => {
let Some(target) = self.href(n) else { return };
if target == n || n.ancestors().any(|a| a == target) {
return;
}
let x = self.attr_len(n, "x", 'x', &st);
let y = self.attr_len(n, "y", 'y', &st);
transform *= Affine::translate((x, y));
let mut children = vec![];
if target.tag_name().name() == "symbol" {
let w = n.attribute("width").and_then(|v| self.length(v, 'x', st.font_size)).unwrap_or(self.vw);
let h = n.attribute("height").and_then(|v| self.length(v, 'y', st.font_size)).unwrap_or(self.vh);
let vb = viewbox_transform(target, w, h);
let mut inner = vec![];
let mut sst = st.clone();
let (o, _) = self.cascade(target, &mut sst);
for c in target.children() {
self.node(c, &sst, &mut inner, depth + 1);
}
children.push(Node::Group(Group { name: Self::element_name(target), transform: vb, opacity: o, children: inner }));
} else {
self.node(target, &st, &mut children, depth + 1);
}
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
}
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
if !st.visible {
return;
}
let Some(geom) = self.geometry(n, &st) else { return };
let fill = if matches!(tag, "line") { None } else { st.fill.as_ref().and_then(|p| self.resolve_paint(p, &st)) };
let (fill, fill_alpha) = match fill {
Some((p, a)) => (Some(p), a),
None => (None, 1.0),
};
let stroke = st.stroke.as_ref().and_then(|p| self.resolve_paint(p, &st)).filter(|_| st.stroke_width > 0.0).map(|(paint, a)| Stroke {
paint,
opacity: st.stroke_opacity * a,
width: st.stroke_width,
cap: st.cap,
join: st.join,
miter: st.miter,
dash: st.dash.clone().map(|d| (d, st.dash_offset)),
});
out.push(Node::Shape(Shape {
name: Self::element_name(n),
geom,
transform,
fill,
fill_opacity: st.fill_opacity * fill_alpha,
fill_rule: st.fill_rule,
stroke,
opacity,
}));
}
_ => {
// Unknown container-ish elements: render their children.
if n.has_children() {
let mut children = vec![];
for c in n.children() {
self.node(c, &st, &mut children, depth + 1);
}
if !children.is_empty() {
out.push(Node::Group(Group { name: Self::element_name(n), transform, opacity, children }));
}
}
}
}
}
fn geometry(&self, n: roxmltree::Node, st: &Style) -> Option<Geom> {
let l = |name: &str, axis: char| self.attr_len(n, name, axis, st);
Some(match n.tag_name().name() {
"path" => {
let p = parse_path_data(n.attribute("d")?);
if p.elements().is_empty() {
return None;
}
Geom::Path(p)
}
"rect" => {
let (w, h) = (l("width", 'x'), l("height", 'y'));
if w <= 0.0 || h <= 0.0 {
return None;
}
let rx_a = n.attribute("rx").and_then(|v| self.length(v, 'x', st.font_size));
let ry_a = n.attribute("ry").and_then(|v| self.length(v, 'y', st.font_size));
let (rx, ry) = match (rx_a, ry_a) {
(Some(x), Some(y)) => (x, y),
(Some(x), None) => (x, x),
(None, Some(y)) => (y, y),
(None, None) => (0.0, 0.0),
};
Geom::Rect { x: l("x", 'x'), y: l("y", 'y'), w, h, rx: rx.clamp(0.0, w / 2.0), ry: ry.clamp(0.0, h / 2.0) }
}
"circle" => {
let r = l("r", 'o');
if r <= 0.0 {
return None;
}
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx: r, ry: r }
}
"ellipse" => {
let (rx, ry) = (l("rx", 'x'), l("ry", 'y'));
if rx <= 0.0 || ry <= 0.0 {
return None;
}
Geom::Ellipse { cx: l("cx", 'x'), cy: l("cy", 'y'), rx, ry }
}
"line" => {
let mut p = BezPath::new();
p.move_to((l("x1", 'x'), l("y1", 'y')));
p.line_to((l("x2", 'x'), l("y2", 'y')));
Geom::Path(p)
}
"polyline" | "polygon" => {
let nums = pathdata::numbers(n.attribute("points")?);
if nums.len() < 4 {
return None;
}
let mut p = BezPath::new();
p.move_to((nums[0], nums[1]));
for c in nums[2..].chunks_exact(2) {
p.line_to((c[0], c[1]));
}
if n.tag_name().name() == "polygon" {
p.close_path();
}
Geom::Path(p)
}
_ => return None,
})
}
}
fn viewbox(n: roxmltree::Node) -> Option<([f64; 2], f64, f64)> {
let v = pathdata::numbers(n.attribute("viewBox")?);
(v.len() == 4 && v[2] > 0.0 && v[3] > 0.0).then(|| ([v[0], v[1]], v[2], v[3]))
}
/// The viewBox → viewport transform (`preserveAspectRatio`, default `xMidYMid meet`).
fn viewbox_transform(n: roxmltree::Node, w: f64, h: f64) -> Affine {
let Some(([x, y], vw, vh)) = viewbox(n) else { return Affine::IDENTITY };
let par = n.attribute("preserveAspectRatio").unwrap_or("xMidYMid meet");
let mut parts = par.split_whitespace();
let mut align = parts.next().unwrap_or("xMidYMid");
if align == "defer" {
align = parts.next().unwrap_or("xMidYMid");
}
let slice = parts.next() == Some("slice");
let (sx, sy) = (w / vw, h / vh);
if align == "none" {
return Affine::scale_non_uniform(sx, sy) * Affine::translate((-x, -y));
}
let s = if slice { sx.max(sy) } else { sx.min(sy) };
let fx = if align.contains("xMid") {
0.5
} else if align.contains("xMax") {
1.0
} else {
0.0
};
let fy = if align.contains("YMid") {
0.5
} else if align.contains("YMax") {
1.0
} else {
0.0
};
let tx = (w - vw * s) * fx;
let ty = (h - vh * s) * fy;
Affine::translate((tx, ty)) * Affine::scale(s) * Affine::translate((-x, -y))
}
/// Parse a `transform` list.
pub fn parse_transform(s: &str) -> Affine {
let mut m = Affine::IDENTITY;
let mut rest = s;
while let Some(open) = rest.find('(') {
let name = rest[..open].trim().trim_start_matches(',').trim();
let Some(close) = rest[open..].find(')') else { break };
let a = pathdata::numbers(&rest[open + 1..open + close]);
rest = &rest[open + close + 1..];
let t = match (name, a.len()) {
("matrix", 6) => Affine::new([a[0], a[1], a[2], a[3], a[4], a[5]]),
("translate", 1) => Affine::translate((a[0], 0.0)),
("translate", 2) => Affine::translate((a[0], a[1])),
("scale", 1) => Affine::scale(a[0]),
("scale", 2) => Affine::scale_non_uniform(a[0], a[1]),
("rotate", 1) => Affine::rotate(a[0].to_radians()),
("rotate", 3) => Affine::translate((a[1], a[2])) * Affine::rotate(a[0].to_radians()) * Affine::translate((-a[1], -a[2])),
("skewX", 1) => Affine::new([1.0, 0.0, a[0].to_radians().tan(), 1.0, 0.0, 0.0]),
("skewY", 1) => Affine::new([1.0, a[0].to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),
_ => Affine::IDENTITY,
};
m *= t;
}
m
}
/// Parse an SVG document.
pub fn parse(bytes: &[u8]) -> Result<Doc, Error> {
let text = String::from_utf8_lossy(bytes);
let text = text.trim_start_matches('\u{feff}');
let opts = roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() };
let xml = roxmltree::Document::parse_with_options(text, opts).map_err(|e| Error::Xml(e.to_string()))?;
let root = xml.root_element();
if root.tag_name().name() != "svg" {
return Err(Error::NotSvg);
}
let mut ids = HashMap::new();
let mut rules = vec![];
for n in root.descendants().filter(|n| n.is_element()) {
if let Some(id) = n.attribute("id") {
ids.entry(id.to_string()).or_insert(n);
}
if n.tag_name().name() == "style" {
let css: String = n.children().filter_map(|c| c.text()).collect();
parse_css(&css, &mut rules);
}
}
let vb = viewbox(root);
let mut p = Parser { ids, rules, vw: vb.map_or(300.0, |v| v.1), vh: vb.map_or(150.0, |v| v.2), skipped: vec![] };
let st = Style::default();
let pct_or = |name: &str, axis: char, def: f64| -> f64 {
match root.attribute(name) {
Some(v) if v.trim().ends_with('%') => {
let f = v.trim().trim_end_matches('%').parse::<f64>().unwrap_or(100.0) / 100.0;
def * f
}
Some(v) => p.length(v, axis, st.font_size).filter(|x| *x > 0.0).unwrap_or(def),
None => def,
}
};
// Without width/height the viewBox size is used (or 300×150).
let (dw, dh) = match vb {
Some((_, w, h)) => {
let w2 = pct_or("width", 'x', w);
let h2 = match (root.attribute("width"), root.attribute("height")) {
// Only width given: keep the aspect ratio.
(Some(_), None) => w2 * h / w,
_ => pct_or("height", 'y', h),
};
let w2 = if root.attribute("width").is_none() && root.attribute("height").is_some() { h2 * w / h } else { w2 };
(w2, h2)
}
None => (pct_or("width", 'x', 300.0), pct_or("height", 'y', 150.0)),
};
let mut rst = st.clone();
let (opacity, _) = p.cascade(root, &mut rst);
let mut children = vec![];
for c in root.children() {
p.node(c, &rst, &mut children, 1);
}
let transform = viewbox_transform(root, dw, dh);
Ok(Doc { width: dw, height: dh, root: Group { name: "svg".into(), transform, opacity, children }, skipped: std::mem::take(&mut p.skipped) })
}
#[cfg(test)]
mod tests;