mod color;
mod gradient;
pub(crate) mod path_parser;
use std::collections::HashMap;
use teksilo_tokens::Color;
use crate::geometry::{Point, Rect, Transform2D};
use crate::paint::{FillRule, GradientStop, LineCap, LineJoin, Paint, StrokeStyle};
use crate::path::Path;
use crate::xml::{XmlElement, parse_dom};
use color::parse_color;
use gradient::{GradientDef, collect_gradients};
pub use gradient::{ResolvedGradient, SvgStop};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SvgParseError {
#[error("SVG XML error: {0}")]
XmlError(String),
#[error("no <svg> root element found")]
MissingSvgElement,
#[error("invalid viewBox: {0}")]
InvalidViewBox(String),
#[error("invalid path data at position {position}: {detail}")]
InvalidPathData { detail: String, position: usize },
#[error("invalid transform: {0}")]
InvalidTransform(String),
}
#[derive(Debug, Clone)]
pub struct SvgStroke {
pub path: Path,
pub width: f32,
pub line_cap: LineCap,
pub line_join: LineJoin,
pub miter_limit: f32,
pub opacity: f32,
pub dash: Option<(Vec<f32>, f32)>,
}
#[derive(Debug, Clone)]
pub struct SvgFill {
pub path: Path,
pub fill_rule: FillRule,
pub opacity: f32,
}
#[derive(Debug, Clone)]
pub struct SvgIcon {
path: Path,
extra_fills: Vec<SvgFill>,
strokes: Vec<SvgStroke>,
ops: Vec<SvgOp>,
view_box: Rect,
aspect: AspectRatio,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Align {
Min,
Mid,
Max,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct AspectRatio {
x: Align,
y: Align,
slice: bool,
stretch: bool,
}
impl Default for AspectRatio {
fn default() -> Self {
Self {
x: Align::Mid,
y: Align::Mid,
slice: false,
stretch: false,
}
}
}
impl SvgIcon {
pub fn parse(svg_str: &str) -> Result<Self, SvgParseError> {
let root = parse_dom(svg_str)
.map_err(SvgParseError::XmlError)?
.ok_or(SvgParseError::MissingSvgElement)?;
let svg_el = if root.tag_name() == "svg" {
&root
} else {
root.children()
.find(|n| n.tag_name() == "svg")
.ok_or(SvgParseError::MissingSvgElement)?
};
let view_box = parse_view_box(svg_el)?;
let mut id_map = HashMap::new();
build_id_map(svg_el, &mut id_map);
let css = collect_style_rules(svg_el);
let gradients = collect_gradients(&id_map, view_box);
let ctx = WalkCtx {
id_map: &id_map,
css: &css,
gradients: &gradients,
};
let mut builder = SvgBuilder::default();
walk_element(
svg_el,
&Transform2D::IDENTITY,
SvgPaintState::default(),
&mut builder,
&ctx,
0,
)?;
let aspect = svg_el
.attribute("preserveAspectRatio")
.map(parse_preserve_aspect_ratio)
.unwrap_or_default();
Ok(SvgIcon {
path: builder.fill,
extra_fills: builder.extra_fills,
strokes: builder.strokes,
ops: builder.ops,
view_box,
aspect,
})
}
pub fn width(&self) -> f32 {
self.view_box.width
}
pub fn height(&self) -> f32 {
self.view_box.height
}
pub fn to_path(&self, size: f32) -> Path {
self.to_path_in_rect(Rect::new(0.0, 0.0, size, size))
}
pub fn to_path_in_rect(&self, rect: Rect) -> Path {
if self.path.is_empty() {
return Path::new();
}
match self.fit_transform(rect) {
Some((transform, _)) => self.path.transformed(&transform),
None => Path::new(),
}
}
pub fn stroked_paths_in_rect(&self, rect: Rect) -> Vec<(Path, StrokeStyle, f32)> {
if self.strokes.is_empty() {
return Vec::new();
}
let Some((transform, scale)) = self.fit_transform(rect) else {
return Vec::new();
};
self.strokes
.iter()
.map(|s| {
let mut style = StrokeStyle::solid(s.width * scale);
style.line_cap = s.line_cap;
style.line_join = s.line_join;
style.miter_limit = s.miter_limit;
if let Some((arr, off)) = &s.dash {
style.dash_pattern = Some(arr.iter().map(|d| d * scale).collect());
style.dash_offset = off * scale;
}
(s.path.transformed(&transform), style, s.opacity)
})
.collect()
}
pub fn extra_fills_in_rect(&self, rect: Rect) -> Vec<(Path, FillRule, f32)> {
if self.extra_fills.is_empty() {
return Vec::new();
}
let Some((transform, _)) = self.fit_transform(rect) else {
return Vec::new();
};
self.extra_fills
.iter()
.map(|f| (f.path.transformed(&transform), f.fill_rule, f.opacity))
.collect()
}
pub fn draw_ops_in_rect(&self, rect: Rect, current: Color) -> Vec<SvgDrawOp> {
if self.ops.is_empty() {
return Vec::new();
}
let Some((transform, scale)) = self.fit_transform(rect) else {
return Vec::new();
};
let mut out = Vec::with_capacity(self.ops.len());
for op in &self.ops {
let path = op.path.transformed(&transform);
if path.is_empty() {
continue;
}
let paint =
resolve_draw_paint(&op.paint, current, op.opacity, &transform, scale, &path);
if paint_is_invisible(&paint) {
continue;
}
out.push(match &op.kind {
SvgOpKind::Fill { fill_rule } => SvgDrawOp::Fill {
path,
fill_rule: *fill_rule,
paint,
},
SvgOpKind::Stroke {
width,
line_cap,
line_join,
miter_limit,
dash,
} => {
let mut style = StrokeStyle::solid(width * scale);
style.line_cap = *line_cap;
style.line_join = *line_join;
style.miter_limit = *miter_limit;
if let Some((arr, off)) = dash {
style.dash_pattern = Some(arr.iter().map(|d| d * scale).collect());
style.dash_offset = off * scale;
}
SvgDrawOp::Stroke { path, style, paint }
}
});
}
out
}
pub fn is_monochrome(&self) -> bool {
self.ops
.iter()
.all(|op| matches!(op.paint, SvgPaint::Current))
}
pub fn ops(&self) -> &[SvgOp] {
&self.ops
}
fn fit_transform(&self, rect: Rect) -> Option<(Transform2D, f32)> {
let vb = self.view_box;
if vb.width <= 0.0 || vb.height <= 0.0 {
return None;
}
let sx = rect.width / vb.width;
let sy = rect.height / vb.height;
if self.aspect.stretch {
let transform = Transform2D::scale(sx, sy).then(&Transform2D::translate(
rect.x - vb.x * sx,
rect.y - vb.y * sy,
));
return Some((transform, (sx * sy).sqrt()));
}
let scale = if self.aspect.slice {
sx.max(sy)
} else {
sx.min(sy)
};
let align = |leftover: f32, a: Align| match a {
Align::Min => 0.0,
Align::Mid => leftover / 2.0,
Align::Max => leftover,
};
let offset_x = rect.x + align(rect.width - vb.width * scale, self.aspect.x);
let offset_y = rect.y + align(rect.height - vb.height * scale, self.aspect.y);
let transform = Transform2D::scale(scale, scale).then(&Transform2D::translate(
offset_x - vb.x * scale,
offset_y - vb.y * scale,
));
Some((transform, scale))
}
pub fn raw_path(&self) -> &Path {
&self.path
}
pub fn strokes(&self) -> &[SvgStroke] {
&self.strokes
}
pub fn extra_fills(&self) -> &[SvgFill] {
&self.extra_fills
}
pub fn is_empty(&self) -> bool {
self.path.is_empty() && self.extra_fills.is_empty() && self.strokes.is_empty()
}
pub fn view_box(&self) -> Rect {
self.view_box
}
}
fn tinted(base: Option<Color>, tint: Color, op_alpha: f32) -> Color {
match base {
Some(c) => c.with_alpha(c.a() * op_alpha * tint.a()),
None => tint.with_alpha(tint.a() * op_alpha),
}
}
fn resolve_draw_paint(
paint: &SvgPaint,
tint: Color,
op_alpha: f32,
transform: &Transform2D,
scale: f32,
path: &Path,
) -> Paint {
match paint {
SvgPaint::Current => Paint::Solid(tinted(None, tint, op_alpha)),
SvgPaint::Solid(c) => Paint::Solid(tinted(Some(*c), tint, op_alpha)),
SvgPaint::Gradient(g) => {
let b = path.bounds();
let local = |p: Point| {
let d = transform.apply_point(p);
Point::new(d.x - b.x, d.y - b.y)
};
let ramp = |stops: &[SvgStop]| -> Vec<GradientStop> {
stops
.iter()
.map(|s| GradientStop {
offset: s.offset,
color: tinted(s.color, tint, op_alpha * s.opacity),
})
.collect()
};
match g {
ResolvedGradient::Linear { start, end, stops } => Paint::LinearGradient {
start: local(*start),
end: local(*end),
stops: ramp(stops),
},
ResolvedGradient::Radial {
center,
radius,
stops,
} => Paint::RadialGradient {
center: local(*center),
radius: radius * scale,
stops: ramp(stops),
},
}
}
}
}
fn paint_is_invisible(paint: &Paint) -> bool {
match paint {
Paint::Solid(c) => c.a() <= 0.0,
Paint::LinearGradient { stops, .. }
| Paint::RadialGradient { stops, .. }
| Paint::ConicGradient { stops, .. } => stops.iter().all(|s| s.color.a() <= 0.0),
Paint::Image(_) => false,
}
}
fn parse_view_box(svg_el: &XmlElement) -> Result<Rect, SvgParseError> {
if let Some(vb) = svg_el.attribute("viewBox") {
let nums: Vec<f32> = vb
.split(|c: char| c.is_ascii_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<f32>()
.map_err(|_| SvgParseError::InvalidViewBox(format!("invalid number '{s}'")))
})
.collect::<Result<Vec<_>, _>>()?;
if nums.len() != 4 {
return Err(SvgParseError::InvalidViewBox(format!(
"expected 4 values, got {}",
nums.len()
)));
}
return Ok(Rect::new(nums[0], nums[1], nums[2], nums[3]));
}
let w = svg_el
.attribute("width")
.and_then(parse_length)
.ok_or_else(|| SvgParseError::InvalidViewBox("no viewBox or width attribute".into()))?;
let h = svg_el
.attribute("height")
.and_then(parse_length)
.ok_or_else(|| SvgParseError::InvalidViewBox("no viewBox or height attribute".into()))?;
Ok(Rect::new(0.0, 0.0, w, h))
}
fn parse_length(s: &str) -> Option<f32> {
let s = s.trim();
if s.ends_with('%') || s.ends_with("em") || s.ends_with("ex") {
return None;
}
let num = s
.trim_end_matches("px")
.trim_end_matches("pt")
.trim_end_matches("mm")
.trim_end_matches("cm")
.trim_end_matches("in")
.trim();
num.parse::<f32>().ok()
}
const GROUP_EPS: f32 = 1e-4;
#[derive(Default)]
struct SvgBuilder {
fill: Path,
extra_fills: Vec<SvgFill>,
strokes: Vec<SvgStroke>,
ops: Vec<SvgOp>,
}
impl SvgBuilder {
fn push_op(&mut self, op: SvgOp) {
self.ops.push(op);
}
fn push_fill(&mut self, path: Path, fill_rule: FillRule, opacity: f32) {
if fill_rule == FillRule::Winding && (opacity - 1.0).abs() < GROUP_EPS {
self.fill.append(&path);
return;
}
if let Some(group) = self
.extra_fills
.iter_mut()
.find(|f| f.fill_rule == fill_rule && (f.opacity - opacity).abs() < GROUP_EPS)
{
group.path.append(&path);
} else {
self.extra_fills.push(SvgFill {
path,
fill_rule,
opacity,
});
}
}
#[allow(clippy::too_many_arguments)] fn push_stroke(
&mut self,
path: Path,
width: f32,
line_cap: LineCap,
line_join: LineJoin,
miter_limit: f32,
opacity: f32,
dash: Option<(Vec<f32>, f32)>,
) {
if let Some(group) = self.strokes.iter_mut().find(|s| {
(s.width - width).abs() < GROUP_EPS
&& s.line_cap == line_cap
&& s.line_join == line_join
&& (s.miter_limit - miter_limit).abs() < GROUP_EPS
&& (s.opacity - opacity).abs() < GROUP_EPS
&& s.dash == dash
}) {
group.path.append(&path);
} else {
self.strokes.push(SvgStroke {
path,
width,
line_cap,
line_join,
miter_limit,
opacity,
dash,
});
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum PaintRef {
None,
Current,
Color(Color),
Ref { id: String, fallback: Option<Color> },
}
impl PaintRef {
fn is_painted(&self) -> bool {
!matches!(self, PaintRef::None)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum SvgPaint {
Current,
Solid(Color),
Gradient(ResolvedGradient),
}
#[derive(Debug, Clone)]
enum SvgOpKind {
Fill {
fill_rule: FillRule,
},
Stroke {
width: f32,
line_cap: LineCap,
line_join: LineJoin,
miter_limit: f32,
dash: Option<(Vec<f32>, f32)>,
},
}
#[derive(Debug, Clone)]
pub struct SvgOp {
path: Path,
kind: SvgOpKind,
paint: SvgPaint,
opacity: f32,
}
impl SvgOp {
pub fn paint(&self) -> &SvgPaint {
&self.paint
}
pub fn is_stroke(&self) -> bool {
matches!(self.kind, SvgOpKind::Stroke { .. })
}
}
#[derive(Debug, Clone)]
pub enum SvgDrawOp {
Fill {
path: Path,
fill_rule: FillRule,
paint: Paint,
},
Stroke {
path: Path,
style: StrokeStyle,
paint: Paint,
},
}
#[derive(Debug, Clone)]
struct SvgPaintState {
fill: PaintRef,
stroke: PaintRef,
stroke_width: f32,
line_cap: LineCap,
line_join: LineJoin,
miter_limit: f32,
fill_rule: FillRule,
opacity: f32,
fill_opacity: f32,
stroke_opacity: f32,
dash_array: Option<Vec<f32>>,
dash_offset: f32,
display_none: bool,
visible: bool,
}
impl Default for SvgPaintState {
fn default() -> Self {
Self {
fill: PaintRef::Color(Color::BLACK),
stroke: PaintRef::None,
stroke_width: 1.0,
line_cap: LineCap::Butt,
line_join: LineJoin::Miter,
miter_limit: 4.0,
fill_rule: FillRule::Winding,
opacity: 1.0,
fill_opacity: 1.0,
stroke_opacity: 1.0,
dash_array: None,
dash_offset: 0.0,
display_none: false,
visible: true,
}
}
}
struct WalkCtx<'a> {
id_map: &'a HashMap<&'a str, &'a XmlElement>,
css: &'a CssRules,
gradients: &'a HashMap<String, GradientDef>,
}
fn resolve_paint(
declared: &PaintRef,
local_bbox: Rect,
to_view_box: &Transform2D,
ctx: &WalkCtx,
) -> SvgPaint {
match declared {
PaintRef::None => SvgPaint::Current, PaintRef::Current => SvgPaint::Current,
PaintRef::Color(c) => SvgPaint::Solid(*c),
PaintRef::Ref { id, fallback } => ctx
.gradients
.get(id)
.and_then(|g| g.resolve(local_bbox, to_view_box))
.map(SvgPaint::Gradient)
.unwrap_or_else(|| match fallback {
Some(c) => SvgPaint::Solid(*c),
None => SvgPaint::Current,
}),
}
}
const MAX_WALK_DEPTH: usize = 256;
fn build_id_map<'a>(node: &'a XmlElement, map: &mut HashMap<&'a str, &'a XmlElement>) {
if let Some(id) = node.attribute("id") {
map.entry(id).or_insert(node);
}
for child in node.children() {
build_id_map(child, map);
}
}
fn is_non_rendering(tag: &str) -> bool {
matches!(
tag,
"defs"
| "symbol"
| "clipPath"
| "mask"
| "pattern"
| "marker"
| "style"
| "metadata"
| "title"
| "desc"
| "text"
| "tspan"
| "image"
| "switch"
| "linearGradient"
| "radialGradient"
| "stop"
)
}
fn walk_element(
node: &XmlElement,
parent_transform: &Transform2D,
parent_paint: SvgPaintState,
builder: &mut SvgBuilder,
ctx: &WalkCtx,
depth: usize,
) -> Result<(), SvgParseError> {
if depth > MAX_WALK_DEPTH {
return Ok(());
}
if is_non_rendering(node.tag_name()) {
#[cfg(debug_assertions)]
if matches!(node.tag_name(), "text" | "tspan") {
eprintln!(
"teksilo: SVG <{}> is not supported by the icon parser; element ignored",
node.tag_name()
);
}
return Ok(());
}
let transform = if let Some(t_attr) = node.attribute("transform") {
let local = parse_transform(t_attr)?;
local.then(parent_transform)
} else {
*parent_transform
};
let paint = resolve_paint_state(node, parent_paint, ctx.css);
if paint.display_none {
return Ok(());
}
if node.tag_name() == "use" {
instantiate_use(node, &transform, paint, builder, ctx, depth)?;
return Ok(());
}
let shape: Option<Path> = match node.tag_name() {
"path" => match node.attribute("d") {
Some(d) => {
let mut p = Path::new();
p.commands = path_parser::parse_svg_path_data(d)?;
Some(p)
}
None => None,
},
"rect" => parse_rect_element(node),
"circle" => parse_circle_element(node),
"ellipse" => parse_ellipse_element(node),
"line" => parse_line_element(node),
"polygon" => parse_polygon_element(node),
"polyline" => parse_polyline_element(node),
_ => None,
};
if paint.visible
&& let Some(local) = shape
{
let local_bbox = local.bounds();
let world = if transform != Transform2D::IDENTITY {
local.transformed(&transform)
} else {
local
};
let scale = transform.geometric_scale();
if paint.fill.is_painted() {
let fill_alpha = paint.opacity * paint.fill_opacity;
builder.push_fill(world.clone(), paint.fill_rule, fill_alpha);
builder.push_op(SvgOp {
path: world.clone(),
kind: SvgOpKind::Fill {
fill_rule: paint.fill_rule,
},
paint: resolve_paint(&paint.fill, local_bbox, &transform, ctx),
opacity: fill_alpha,
});
}
if paint.stroke.is_painted() && paint.stroke_width > 0.0 {
let width = paint.stroke_width * scale;
let stroke_alpha = paint.opacity * paint.stroke_opacity;
let dash = paint.dash_array.as_ref().map(|arr| {
(
arr.iter().map(|d| d * scale).collect::<Vec<f32>>(),
paint.dash_offset * scale,
)
});
builder.push_stroke(
world.clone(),
width,
paint.line_cap,
paint.line_join,
paint.miter_limit,
stroke_alpha,
dash.clone(),
);
builder.push_op(SvgOp {
path: world,
kind: SvgOpKind::Stroke {
width,
line_cap: paint.line_cap,
line_join: paint.line_join,
miter_limit: paint.miter_limit,
dash,
},
paint: resolve_paint(&paint.stroke, local_bbox, &transform, ctx),
opacity: stroke_alpha,
});
}
}
for child in node.children() {
walk_element(child, &transform, paint.clone(), builder, ctx, depth + 1)?;
}
Ok(())
}
fn instantiate_use(
node: &XmlElement,
use_transform: &Transform2D,
paint: SvgPaintState,
builder: &mut SvgBuilder,
ctx: &WalkCtx,
depth: usize,
) -> Result<(), SvgParseError> {
let Some(href) = node
.attribute("href")
.or_else(|| node.attribute("xlink:href"))
else {
return Ok(());
};
let id = href.strip_prefix('#').unwrap_or(href);
let Some(&target) = ctx.id_map.get(id) else {
return Ok(()); };
let x = attr_f32(node, "x").unwrap_or(0.0);
let y = attr_f32(node, "y").unwrap_or(0.0);
let placed = Transform2D::translate(x, y).then(use_transform);
match target.tag_name() {
"symbol" | "svg" => {
let inner = symbol_viewport_transform(target, node, &placed);
let tpaint = resolve_paint_state(target, paint, ctx.css);
for child in target.children() {
walk_element(child, &inner, tpaint.clone(), builder, ctx, depth + 1)?;
}
}
_ => {
walk_element(target, &placed, paint, builder, ctx, depth + 1)?;
}
}
Ok(())
}
fn symbol_viewport_transform(
symbol: &XmlElement,
use_node: &XmlElement,
placed: &Transform2D,
) -> Transform2D {
let vb = symbol.attribute("viewBox").and_then(parse_view_box_values);
match (
vb,
attr_f32(use_node, "width"),
attr_f32(use_node, "height"),
) {
(Some((vx, vy, vw, vh)), Some(uw), Some(uh)) if vw > 0.0 && vh > 0.0 => {
let vb_local =
Transform2D::translate(-vx, -vy).then(&Transform2D::scale(uw / vw, uh / vh));
vb_local.then(placed)
}
_ => *placed,
}
}
fn parse_preserve_aspect_ratio(s: &str) -> AspectRatio {
let mut it = s.split_whitespace();
let align = it.next().unwrap_or("xMidYMid");
if align.eq_ignore_ascii_case("none") {
return AspectRatio {
stretch: true,
..AspectRatio::default()
};
}
let slice = it.next().is_some_and(|m| m.eq_ignore_ascii_case("slice"));
let x = if align.contains("xMin") {
Align::Min
} else if align.contains("xMax") {
Align::Max
} else {
Align::Mid
};
let y = if align.contains("YMin") {
Align::Min
} else if align.contains("YMax") {
Align::Max
} else {
Align::Mid
};
AspectRatio {
x,
y,
slice,
stretch: false,
}
}
fn parse_view_box_values(vb: &str) -> Option<(f32, f32, f32, f32)> {
let nums: Vec<f32> = vb
.split(|c: char| c.is_ascii_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f32>().ok())
.collect();
if nums.len() == 4 {
Some((nums[0], nums[1], nums[2], nums[3]))
} else {
None
}
}
const PAINT_PROPERTIES: &[&str] = &[
"fill",
"stroke",
"stroke-width",
"stroke-linecap",
"stroke-linejoin",
"stroke-miterlimit",
"fill-rule",
"opacity",
"fill-opacity",
"stroke-opacity",
"stroke-dasharray",
"stroke-dashoffset",
"display",
"visibility",
];
fn resolve_paint_state(node: &XmlElement, parent: SvgPaintState, css: &CssRules) -> SvgPaintState {
let mut st = parent;
st.display_none = false;
for &prop in PAINT_PROPERTIES {
if let Some(v) = node.attribute(prop) {
apply_declaration(&mut st, prop, v);
}
}
css.apply_to(&mut st, node);
if let Some(style) = node.attribute("style") {
for (key, value) in parse_inline_style(style) {
apply_declaration(&mut st, key, value);
}
}
st
}
fn apply_declaration(st: &mut SvgPaintState, key: &str, value: &str) {
match key {
"fill" => {
if let Some(p) = parse_paint_ref(value) {
st.fill = p;
}
}
"stroke" => {
if let Some(p) = parse_paint_ref(value) {
st.stroke = p;
}
}
"stroke-width" => {
if let Some(w) = parse_length(value) {
st.stroke_width = w;
}
}
"stroke-linecap" => st.line_cap = parse_line_cap(value),
"stroke-linejoin" => st.line_join = parse_line_join(value),
"stroke-miterlimit" => {
if let Some(m) = value.trim().parse::<f32>().ok().filter(|m| *m >= 1.0) {
st.miter_limit = m;
}
}
"fill-rule" => {
st.fill_rule = if value.trim().eq_ignore_ascii_case("evenodd") {
FillRule::EvenOdd
} else {
FillRule::Winding
};
}
"opacity" => {
if let Some(o) = parse_opacity(value) {
st.opacity *= o;
}
}
"fill-opacity" => {
if let Some(o) = parse_opacity(value) {
st.fill_opacity = o;
}
}
"stroke-opacity" => {
if let Some(o) = parse_opacity(value) {
st.stroke_opacity = o;
}
}
"stroke-dasharray" => st.dash_array = parse_dash_array(value),
"stroke-dashoffset" => {
if let Some(off) = parse_length(value) {
st.dash_offset = off;
}
}
"display" => st.display_none = value.trim().eq_ignore_ascii_case("none"),
"visibility" => {
let v = value.trim();
if v.eq_ignore_ascii_case("hidden") || v.eq_ignore_ascii_case("collapse") {
st.visible = false;
} else if v.eq_ignore_ascii_case("visible") {
st.visible = true;
}
}
_ => {}
}
}
fn parse_paint_ref(value: &str) -> Option<PaintRef> {
let v = value.trim();
if v.eq_ignore_ascii_case("none") {
return Some(PaintRef::None);
}
if v.eq_ignore_ascii_case("currentcolor") {
return Some(PaintRef::Current);
}
if v.eq_ignore_ascii_case("inherit") {
return None;
}
if let Some(rest) = v.strip_prefix("url(").or_else(|| v.strip_prefix("URL(")) {
let (inside, after) = rest.split_once(')')?;
let id = inside.trim().trim_matches(['"', '\'']).trim();
let id = id.strip_prefix('#').unwrap_or(id);
if id.is_empty() {
return None;
}
let fallback = match after.trim() {
"" => None,
f if f.eq_ignore_ascii_case("none") => return Some(PaintRef::None),
f => parse_color(f),
};
return Some(PaintRef::Ref {
id: id.to_string(),
fallback,
});
}
parse_color(v).map(PaintRef::Color)
}
fn parse_line_cap(value: &str) -> LineCap {
match value.trim() {
"round" => LineCap::Round,
"square" => LineCap::Square,
_ => LineCap::Butt,
}
}
fn parse_line_join(value: &str) -> LineJoin {
match value.trim() {
"round" => LineJoin::Round,
"bevel" => LineJoin::Bevel,
_ => LineJoin::Miter,
}
}
fn parse_opacity(value: &str) -> Option<f32> {
let v = value.trim();
let n = if let Some(pct) = v.strip_suffix('%') {
pct.trim().parse::<f32>().ok()? / 100.0
} else {
v.parse::<f32>().ok()?
};
Some(n.clamp(0.0, 1.0))
}
fn parse_dash_array(value: &str) -> Option<Vec<f32>> {
let v = value.trim();
if v.is_empty() || v.eq_ignore_ascii_case("none") {
return None;
}
let mut nums: Vec<f32> = v
.split(|c: char| c.is_ascii_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.filter_map(parse_length)
.filter(|n| *n >= 0.0)
.collect();
if nums.is_empty() || nums.iter().all(|n| *n == 0.0) {
return None;
}
if nums.len() % 2 == 1 {
let dup = nums.clone();
nums.extend(dup);
}
Some(nums)
}
fn parse_inline_style(style: &str) -> impl Iterator<Item = (&str, &str)> {
style.split(';').filter_map(|decl| {
let (key, value) = decl.split_once(':')?;
Some((key.trim(), value.trim()))
})
}
enum CssSelector {
Universal,
Tag(String),
Class(String),
Id(String),
}
struct CssRule {
selector: CssSelector,
decls: Vec<(String, String)>,
}
impl CssRule {
fn matches(&self, tag: &str, classes: &[&str], id: Option<&str>) -> bool {
match &self.selector {
CssSelector::Universal => true,
CssSelector::Tag(t) => t == tag,
CssSelector::Class(c) => classes.contains(&c.as_str()),
CssSelector::Id(i) => id == Some(i.as_str()),
}
}
}
#[derive(Default)]
struct CssRules {
rules: Vec<CssRule>,
}
impl CssRules {
fn apply_to(&self, st: &mut SvgPaintState, node: &XmlElement) {
let tag = node.tag_name();
let id = node.attribute("id");
let class_attr = node.attribute("class").unwrap_or("");
let classes: Vec<&str> = class_attr.split_whitespace().collect();
let tiers = [
|s: &CssSelector| matches!(s, CssSelector::Universal | CssSelector::Tag(_)),
|s: &CssSelector| matches!(s, CssSelector::Class(_)),
|s: &CssSelector| matches!(s, CssSelector::Id(_)),
];
for in_tier in tiers {
for rule in &self.rules {
if in_tier(&rule.selector) && rule.matches(tag, &classes, id) {
for (k, v) in &rule.decls {
apply_declaration(st, k, v);
}
}
}
}
}
}
fn collect_style_rules(svg_el: &XmlElement) -> CssRules {
let mut rules = Vec::new();
collect_style_text(svg_el, &mut rules);
CssRules { rules }
}
fn collect_style_text(node: &XmlElement, rules: &mut Vec<CssRule>) {
if node.tag_name() == "style" {
let is_css = node
.attribute("type")
.map(|t| t.trim().eq_ignore_ascii_case("text/css"))
.unwrap_or(true);
if is_css {
let stripped = strip_css_comments(node.text_content());
parse_css_block(&stripped, rules);
}
}
for child in node.children() {
collect_style_text(child, rules);
}
}
fn strip_css_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("/*") {
out.push_str(&rest[..start]);
match rest[start + 2..].find("*/") {
Some(end) => rest = &rest[start + 2 + end + 2..],
None => {
rest = ""; break;
}
}
}
out.push_str(rest);
out
}
fn parse_css_block(text: &str, rules: &mut Vec<CssRule>) {
let mut rest = text;
loop {
rest = rest.trim_start();
if rest.is_empty() {
break;
}
if rest.starts_with('@') {
rest = skip_at_rule(rest);
continue;
}
let Some(open) = rest.find('{') else {
break;
};
let selector_part = rest[..open].trim();
let after = &rest[open + 1..];
let Some(close) = after.find('}') else {
break; };
let decl_block = &after[..close];
rest = &after[close + 1..];
if selector_part.is_empty() {
continue;
}
let decls: Vec<(String, String)> = parse_inline_style(decl_block)
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
if decls.is_empty() {
continue;
}
for sel in selector_part.split(',') {
if let Some(selector) = parse_simple_selector(sel.trim()) {
rules.push(CssRule {
selector,
decls: decls.clone(),
});
}
}
}
}
fn skip_at_rule(s: &str) -> &str {
let brace = s.find('{');
let semi = s.find(';');
match (brace, semi) {
(None, Some(sc)) => &s[sc + 1..],
(Some(b), Some(sc)) if sc < b => &s[sc + 1..],
(Some(b), _) => skip_balanced_braces(&s[b..]),
(None, None) => "", }
}
fn skip_balanced_braces(s: &str) -> &str {
let mut depth: u32 = 0;
for (i, c) in s.char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return &s[i + 1..];
}
}
_ => {}
}
}
"" }
fn parse_simple_selector(sel: &str) -> Option<CssSelector> {
let sel = sel.trim();
if sel.is_empty() {
return None;
}
if sel == "*" {
return Some(CssSelector::Universal);
}
if sel.contains([' ', '\t', '\n', '>', '+', '~', '[', ']', ':', '(']) {
return None;
}
if let Some(class) = sel.strip_prefix('.') {
if class.is_empty() || class.contains(['.', '#']) {
return None;
}
return Some(CssSelector::Class(class.to_string()));
}
if let Some(id) = sel.strip_prefix('#') {
if id.is_empty() || id.contains(['.', '#']) {
return None;
}
return Some(CssSelector::Id(id.to_string()));
}
if sel.contains(['.', '#']) {
return None;
}
Some(CssSelector::Tag(sel.to_string()))
}
fn parse_rect_element(node: &XmlElement) -> Option<Path> {
let x = attr_f32(node, "x").unwrap_or(0.0);
let y = attr_f32(node, "y").unwrap_or(0.0);
let w = attr_f32(node, "width")?;
let h = attr_f32(node, "height")?;
let (mut rx, mut ry) = match (attr_f32(node, "rx"), attr_f32(node, "ry")) {
(Some(rx), Some(ry)) => (rx, ry),
(Some(rx), None) => (rx, rx),
(None, Some(ry)) => (ry, ry),
(None, None) => (0.0, 0.0),
};
rx = rx.clamp(0.0, w / 2.0);
ry = ry.clamp(0.0, h / 2.0);
if rx > 0.0 && ry > 0.0 {
Some(rounded_rect_xy(Rect::new(x, y, w, h), rx, ry))
} else {
Some(Path::rect(Rect::new(x, y, w, h)))
}
}
fn rounded_rect_xy(r: Rect, rx: f32, ry: f32) -> Path {
let (x, y, right, bottom) = (r.x, r.y, r.right(), r.bottom());
let (dx, dy) = (2.0 * rx, 2.0 * ry);
let mut p = Path::new();
p.move_to(Point::new(x + rx, y));
p.line_to(Point::new(right - rx, y));
p.arc_to(Rect::new(right - dx, y, dx, dy), -90.0, 90.0); p.line_to(Point::new(right, bottom - ry));
p.arc_to(Rect::new(right - dx, bottom - dy, dx, dy), 0.0, 90.0); p.line_to(Point::new(x + rx, bottom));
p.arc_to(Rect::new(x, bottom - dy, dx, dy), 90.0, 90.0); p.line_to(Point::new(x, y + ry));
p.arc_to(Rect::new(x, y, dx, dy), 180.0, 90.0); p.close();
p
}
fn parse_circle_element(node: &XmlElement) -> Option<Path> {
let cx = attr_f32(node, "cx").unwrap_or(0.0);
let cy = attr_f32(node, "cy").unwrap_or(0.0);
let r = attr_f32(node, "r")?;
Some(Path::circle(Point::new(cx, cy), r))
}
fn parse_ellipse_element(node: &XmlElement) -> Option<Path> {
let cx = attr_f32(node, "cx").unwrap_or(0.0);
let cy = attr_f32(node, "cy").unwrap_or(0.0);
let rx = attr_f32(node, "rx")?;
let ry = attr_f32(node, "ry")?;
Some(Path::ellipse(Rect::new(
cx - rx,
cy - ry,
rx * 2.0,
ry * 2.0,
)))
}
fn parse_line_element(node: &XmlElement) -> Option<Path> {
let x1 = attr_f32(node, "x1").unwrap_or(0.0);
let y1 = attr_f32(node, "y1").unwrap_or(0.0);
let x2 = attr_f32(node, "x2").unwrap_or(0.0);
let y2 = attr_f32(node, "y2").unwrap_or(0.0);
Some(Path::line(Point::new(x1, y1), Point::new(x2, y2)))
}
fn parse_polygon_element(node: &XmlElement) -> Option<Path> {
let points = parse_points_attr(node)?;
Some(Path::polygon(&points))
}
fn parse_polyline_element(node: &XmlElement) -> Option<Path> {
let points = parse_points_attr(node)?;
if points.is_empty() {
return None;
}
let mut path = Path::new();
path.move_to(points[0]);
for &p in &points[1..] {
path.line_to(p);
}
Some(path)
}
fn parse_points_attr(node: &XmlElement) -> Option<Vec<Point>> {
let raw = node.attribute("points")?;
let nums: Vec<f32> = raw
.split(|c: char| c.is_ascii_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<f32>().ok())
.collect();
if nums.len() < 4 || !nums.len().is_multiple_of(2) {
return None;
}
Some(nums.chunks(2).map(|c| Point::new(c[0], c[1])).collect())
}
fn attr_f32(node: &XmlElement, name: &str) -> Option<f32> {
node.attribute(name)?.parse::<f32>().ok()
}
fn parse_transform(attr: &str) -> Result<Transform2D, SvgParseError> {
let mut result = Transform2D::IDENTITY;
let mut remaining = attr.trim();
while !remaining.is_empty() {
remaining = remaining.trim_start();
if remaining.is_empty() {
break;
}
let (op, rest) = if let Some(rest) = remaining.strip_prefix("translate") {
let (args, rest) = parse_transform_args(rest)?;
let tx = args.first().copied().unwrap_or(0.0);
let ty = args.get(1).copied().unwrap_or(0.0);
(Transform2D::translate(tx, ty), rest)
} else if let Some(rest) = remaining.strip_prefix("scale") {
let (args, rest) = parse_transform_args(rest)?;
let sx = args.first().copied().unwrap_or(1.0);
let sy = args.get(1).copied().unwrap_or(sx);
(Transform2D::scale(sx, sy), rest)
} else if let Some(rest) = remaining.strip_prefix("rotate") {
let (args, rest) = parse_transform_args(rest)?;
let angle = args.first().copied().unwrap_or(0.0).to_radians();
let op = if args.len() >= 3 {
let (cx, cy) = (args[1], args[2]);
Transform2D::translate(-cx, -cy)
.then(&Transform2D::rotate(angle))
.then(&Transform2D::translate(cx, cy))
} else {
Transform2D::rotate(angle)
};
(op, rest)
} else if let Some(rest) = remaining.strip_prefix("matrix") {
let (args, rest) = parse_transform_args(rest)?;
if args.len() != 6 {
return Err(SvgParseError::InvalidTransform(
"matrix requires 6 values".into(),
));
}
(
Transform2D {
m: [args[0], args[1], args[2], args[3], args[4], args[5]],
},
rest,
)
} else if let Some(rest) = remaining.strip_prefix("skewX") {
let (args, rest) = parse_transform_args(rest)?;
let angle = args.first().copied().unwrap_or(0.0).to_radians();
(
Transform2D {
m: [1.0, 0.0, angle.tan(), 1.0, 0.0, 0.0],
},
rest,
)
} else if let Some(rest) = remaining.strip_prefix("skewY") {
let (args, rest) = parse_transform_args(rest)?;
let angle = args.first().copied().unwrap_or(0.0).to_radians();
(
Transform2D {
m: [1.0, angle.tan(), 0.0, 1.0, 0.0, 0.0],
},
rest,
)
} else {
return Err(SvgParseError::InvalidTransform(format!(
"unrecognized transform: {remaining}"
)));
};
result = op.then(&result);
remaining = rest.trim_start();
remaining = remaining.strip_prefix(',').unwrap_or(remaining);
}
Ok(result)
}
fn parse_transform_args(s: &str) -> Result<(Vec<f32>, &str), SvgParseError> {
let s = s.trim_start();
let s = s.strip_prefix('(').ok_or_else(|| {
SvgParseError::InvalidTransform("expected '(' after transform function".into())
})?;
let end = s
.find(')')
.ok_or_else(|| SvgParseError::InvalidTransform("missing ')' in transform".into()))?;
let inner = &s[..end];
let rest = &s[end + 1..];
let args: Vec<f32> = inner
.split(|c: char| c.is_ascii_whitespace() || c == ',')
.filter(|s| !s.is_empty())
.map(|s| {
s.parse::<f32>()
.map_err(|_| SvgParseError::InvalidTransform(format!("invalid number '{s}'")))
})
.collect::<Result<Vec<_>, _>>()?;
Ok((args, rest))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::path::PathCommand;
#[test]
fn parse_simple_svg() {
let svg = r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!((icon.width() - 24.0).abs() < 0.01);
assert!((icon.height() - 24.0).abs() < 0.01);
assert!(!icon.raw_path().is_empty());
}
#[test]
fn parse_svg_with_rect() {
let svg = r#"<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="80" height="80"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty());
assert_eq!(icon.raw_path().commands.len(), 5);
}
#[test]
fn parse_svg_with_circle() {
let svg = r#"<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="50" r="40"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty());
}
#[test]
fn parse_svg_with_group_transform() {
let svg = r#"<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(10,20)">
<line x1="0" y1="0" x2="50" y2="50"/>
</g>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let cmds = &icon.raw_path().commands;
assert_eq!(cmds.len(), 2);
match cmds[0] {
PathCommand::MoveTo(p) => {
assert!((p.x - 10.0).abs() < 0.01);
assert!((p.y - 20.0).abs() < 0.01);
}
_ => panic!("expected MoveTo"),
}
match cmds[1] {
PathCommand::LineTo(p) => {
assert!((p.x - 60.0).abs() < 0.01);
assert!((p.y - 70.0).abs() < 0.01);
}
_ => panic!("expected LineTo"),
}
}
#[test]
fn to_path_scales_to_size() {
let svg = r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="0" y="0" width="24" height="24"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let path = icon.to_path(48.0);
let bounds = path.bounds();
assert!((bounds.width - 48.0).abs() < 1.0);
assert!((bounds.height - 48.0).abs() < 1.0);
}
#[test]
fn parse_svg_width_height_fallback() {
let svg = r#"<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0L16 16"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!((icon.width() - 16.0).abs() < 0.01);
}
#[test]
fn parse_svg_polygon() {
let svg = r#"<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<polygon points="50,0 100,100 0,100"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(icon.raw_path().commands.len(), 4);
}
#[test]
fn stroked_circle_subpath_opens_with_moveto() {
let svg = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor">
<circle cx="8" cy="8" r="2.3"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(
icon.strokes.len(),
1,
"the stroked circle should yield one stroke"
);
assert!(
matches!(
icon.strokes[0].path.commands.first(),
Some(PathCommand::MoveTo(_))
),
"a stroked circle subpath must open with MoveTo, got {:?}",
icon.strokes[0].path.commands.first()
);
}
#[test]
fn parse_svg_ellipse() {
let svg = r#"<svg viewBox="0 0 200 100" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="100" cy="50" rx="80" ry="40"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty());
}
#[test]
fn parse_svg_multiple_paths() {
let svg = r#"<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M0 0L10 10"/>
<path d="M20 20L24 24"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(icon.raw_path().commands.len(), 4);
}
#[test]
fn to_path_with_nonzero_viewbox_origin() {
let svg = r#"<svg viewBox="10 10 24 24" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="24" height="24"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let path = icon.to_path(48.0);
let bounds = path.bounds();
assert!(bounds.x.abs() < 1.0, "x should be near 0, got {}", bounds.x);
assert!(bounds.y.abs() < 1.0, "y should be near 0, got {}", bounds.y);
assert!(
(bounds.width - 48.0).abs() < 1.0,
"width should be ~48, got {}",
bounds.width
);
assert!(
(bounds.height - 48.0).abs() < 1.0,
"height should be ~48, got {}",
bounds.height
);
}
#[test]
fn missing_svg_element() {
let result = SvgIcon::parse("<html></html>");
assert!(result.is_err());
}
#[test]
fn missing_viewbox() {
let result =
SvgIcon::parse(r#"<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>"#);
assert!(result.is_err());
}
#[test]
fn material_design_home_icon() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty());
let path = icon.to_path(24.0);
assert!(!path.is_empty());
}
#[test]
fn filled_icon_has_no_strokes() {
let svg = r#"<svg viewBox="0 0 24 24"><path d="M0 0L24 0L24 24Z"/></svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty());
assert!(
icon.strokes().is_empty(),
"plain fill icon should not emit strokes"
);
}
#[test]
fn line_style_icon_strokes_not_fills() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="16"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.raw_path().is_empty(),
"line-style icon must have no fill geometry (else outlines become blobs)"
);
assert_eq!(icon.strokes().len(), 1);
let stroke = &icon.strokes()[0];
assert!((stroke.width - 2.0).abs() < 0.01);
assert_eq!(stroke.line_cap, LineCap::Round);
assert_eq!(stroke.line_join, LineJoin::Round);
assert!(!icon.is_empty());
}
#[test]
fn element_with_fill_and_stroke_emits_both() {
let svg = r#"<svg viewBox="0 0 24 24">
<rect x="2" y="2" width="20" height="20" fill="white" stroke="black" stroke-width="1"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty(), "filled rect should fill");
assert_eq!(icon.strokes().len(), 1, "bordered rect should also stroke");
}
#[test]
fn inline_style_drives_stroke() {
let svg = r#"<svg viewBox="0 0 24 24">
<path d="M2 12L22 12" style="fill:none;stroke:#333;stroke-width:3"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.raw_path().is_empty(),
"fill:none in inline style must suppress fill"
);
assert_eq!(icon.strokes().len(), 1);
assert!((icon.strokes()[0].width - 3.0).abs() < 0.01);
}
#[test]
fn stroke_inherits_through_group_with_override() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2">
<g stroke-width="4">
<line x1="0" y1="0" x2="10" y2="0"/>
</g>
<line x1="0" y1="10" x2="10" y2="10"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(icon.strokes().len(), 2);
let mut widths: Vec<f32> = icon.strokes().iter().map(|s| s.width).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap());
assert!((widths[0] - 2.0).abs() < 0.01);
assert!((widths[1] - 4.0).abs() < 0.01);
}
#[test]
fn group_scale_transform_scales_stroke_width() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2">
<g transform="scale(2)">
<line x1="0" y1="0" x2="5" y2="0"/>
</g>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(icon.strokes().len(), 1);
assert!((icon.strokes()[0].width - 4.0).abs() < 0.01);
}
#[test]
fn stroked_paths_scale_width_to_display() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2">
<line x1="0" y1="0" x2="24" y2="0"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let strokes = icon.stroked_paths_in_rect(Rect::new(0.0, 0.0, 48.0, 48.0));
assert_eq!(strokes.len(), 1);
assert!(
(strokes[0].1.width - 4.0).abs() < 0.01,
"stroke width must scale with the icon, got {}",
strokes[0].1.width
);
}
#[test]
fn stroke_none_suppresses_stroke() {
let svg = r#"<svg viewBox="0 0 24 24" stroke="black" stroke-width="2" fill="none">
<rect x="2" y="2" width="20" height="20" stroke="none" fill="black"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty(), "fill='black' should fill");
assert!(
icon.strokes().is_empty(),
"stroke='none' child must not stroke"
);
}
#[test]
fn nested_transforms_compose_in_svg_order() {
let svg = r#"<svg viewBox="0 0 100 100">
<g transform="translate(10,0)">
<g transform="scale(2)">
<rect x="5" y="5" width="1" height="1"/>
</g>
</g>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
match icon.raw_path().commands.first() {
Some(PathCommand::MoveTo(p)) => {
assert!((p.x - 20.0).abs() < 0.01, "x should be 20, got {}", p.x);
assert!((p.y - 10.0).abs() < 0.01, "y should be 10, got {}", p.y);
}
other => panic!("expected MoveTo, got {other:?}"),
}
}
#[test]
fn multi_op_transform_applies_left_to_right() {
let svg = r#"<svg viewBox="0 0 100 100">
<g transform="translate(10,0) scale(2)">
<rect x="5" y="5" width="1" height="1"/>
</g>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
match icon.raw_path().commands.first() {
Some(PathCommand::MoveTo(p)) => {
assert!(
(p.x - 20.0).abs() < 0.01 && (p.y - 10.0).abs() < 0.01,
"expected (20,10), got ({},{})",
p.x,
p.y
);
}
other => panic!("expected MoveTo, got {other:?}"),
}
}
#[test]
fn rotate_around_center_uses_correct_pivot() {
let svg = r#"<svg viewBox="0 0 100 100">
<g transform="rotate(90 10 10)">
<rect x="20" y="10" width="1" height="1"/>
</g>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
match icon.raw_path().commands.first() {
Some(PathCommand::MoveTo(p)) => {
assert!(
(p.x - 10.0).abs() < 0.01 && (p.y - 20.0).abs() < 0.01,
"expected (10,20), got ({},{})",
p.x,
p.y
);
}
other => panic!("expected MoveTo, got {other:?}"),
}
}
#[test]
fn defs_not_rendered() {
let svg = r#"<svg viewBox="0 0 24 24"><defs><path d="M0 0L24 24"/></defs></svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.is_empty(), "content inside <defs> must not render");
}
#[test]
fn symbol_use_renders_with_offset() {
let svg = r##"<svg viewBox="0 0 48 48">
<symbol id="sq"><rect x="0" y="0" width="10" height="10"/></symbol>
<use href="#sq" x="20" y="5"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
!icon.raw_path().is_empty(),
"<use> of a <symbol> must render"
);
match icon.raw_path().commands.first() {
Some(PathCommand::MoveTo(p)) => {
assert!(
(p.x - 20.0).abs() < 0.01 && (p.y - 5.0).abs() < 0.01,
"rect should be offset to (20,5), got ({},{})",
p.x,
p.y
);
}
other => panic!("expected MoveTo, got {other:?}"),
}
}
#[test]
fn unknown_id_use_is_empty() {
let svg = r##"<svg viewBox="0 0 24 24"><use href="#missing"/></svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.is_empty(), "dangling <use> renders nothing, no error");
}
#[test]
fn use_cycle_terminates() {
let svg = r##"<svg viewBox="0 0 24 24">
<symbol id="a"><use href="#a"/></symbol>
<use href="#a"/>
</svg>"##;
assert!(
SvgIcon::parse(svg).is_ok(),
"self-referential <use> must terminate cleanly"
);
}
#[test]
fn deeply_nested_groups_ok() {
let mut svg = String::from(r#"<svg viewBox="0 0 24 24">"#);
for _ in 0..100 {
svg.push_str("<g>");
}
svg.push_str(r#"<rect x="0" y="0" width="1" height="1"/>"#);
for _ in 0..100 {
svg.push_str("</g>");
}
svg.push_str("</svg>");
let icon = SvgIcon::parse(&svg).unwrap();
assert!(!icon.is_empty(), "100 nested groups (< depth bound) render");
}
#[test]
fn style_block_drives_stroke() {
let svg = r#"<svg viewBox="0 0 24 24">
<style>.icon{fill:none;stroke:#000;stroke-width:2}</style>
<path class="icon" d="M2 12L22 12"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.raw_path().is_empty(),
"CSS fill:none must suppress fill"
);
assert_eq!(icon.strokes().len(), 1, "CSS stroke must apply");
assert!((icon.strokes()[0].width - 2.0).abs() < 0.01);
}
#[test]
fn css_at_rules_dont_drop_real_rules() {
let svg = r#"<svg viewBox="0 0 24 24">
<style>@charset "UTF-8"; @media print { .x{fill:red} } .icon{fill:none;stroke:#000;stroke-width:2}</style>
<path class="icon" d="M2 12L22 12"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.raw_path().is_empty(),
".icon fill:none must apply despite the @-rules"
);
assert_eq!(icon.strokes().len(), 1);
assert!((icon.strokes()[0].width - 2.0).abs() < 0.01);
}
#[test]
fn tag_selector_fill() {
let svg = r#"<svg viewBox="0 0 24 24">
<style>path{fill:none;stroke:#000;stroke-width:1}</style>
<path d="M0 0L24 24"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.raw_path().is_empty(), "tag selector fill:none applies");
assert_eq!(icon.strokes().len(), 1);
}
#[test]
fn class_multiple_values() {
let svg = r#"<svg viewBox="0 0 24 24">
<style>.a{fill:none} .b{stroke:#000;stroke-width:3}</style>
<path class="a b" d="M0 0L24 0"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.raw_path().is_empty(), ".a fill:none applies");
assert_eq!(icon.strokes().len(), 1, ".b stroke applies");
assert!((icon.strokes()[0].width - 3.0).abs() < 0.01);
}
#[test]
fn css_class_beats_presentation_attr() {
let svg = r#"<svg viewBox="0 0 24 24">
<style>.x{fill:none}</style>
<rect x="0" y="0" width="10" height="10" fill="black" class="x"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.raw_path().is_empty(),
"class fill:none must beat presentation fill=black"
);
}
#[test]
fn inline_style_beats_css_class() {
let svg = r#"<svg viewBox="0 0 24 24">
<style>.x{fill:none}</style>
<rect x="0" y="0" width="10" height="10" class="x" style="fill:black"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
!icon.raw_path().is_empty(),
"inline fill:black must beat class fill:none"
);
}
#[test]
fn display_none_prunes() {
let svg = r#"<svg viewBox="0 0 24 24">
<rect x="0" y="0" width="10" height="10" display="none"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.is_empty(), "display=none element must not render");
}
#[test]
fn display_none_in_style_prunes_subtree() {
let svg = r#"<svg viewBox="0 0 24 24">
<g style="display:none"><rect x="0" y="0" width="10" height="10"/></g>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.is_empty(), "display:none group prunes its subtree");
}
#[test]
fn visibility_hidden_suppresses_shape() {
let svg = r#"<svg viewBox="0 0 24 24">
<rect x="0" y="0" width="10" height="10" visibility="hidden"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.is_empty(), "visibility=hidden suppresses the shape");
}
#[test]
fn visibility_visible_child_overrides_hidden_parent() {
let svg = r#"<svg viewBox="0 0 24 24">
<g visibility="hidden">
<rect x="0" y="0" width="10" height="10" visibility="visible"/>
</g>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
!icon.is_empty(),
"a visible child of a hidden group must render"
);
}
#[test]
fn fill_rule_winding_stays_in_path() {
let svg = r#"<svg viewBox="0 0 24 24"><path d="M0 0L24 0L24 24Z"/></svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(!icon.raw_path().is_empty());
assert!(
icon.extra_fills().is_empty(),
"default winding fill stays in the main path"
);
}
#[test]
fn fill_rule_evenodd_in_extra_fills() {
let svg = r#"<svg viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M0 0L24 0L24 24Z"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.raw_path().is_empty(),
"evenodd fill must go to extra_fills, not the main winding path"
);
assert_eq!(icon.extra_fills().len(), 1);
assert_eq!(icon.extra_fills()[0].fill_rule, FillRule::EvenOdd);
}
#[test]
fn opacity_below_1_goes_to_extra_fills() {
let svg = r#"<svg viewBox="0 0 24 24">
<rect x="0" y="0" width="10" height="10" opacity="0.5"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.raw_path().is_empty(),
"a translucent fill must go to extra_fills"
);
assert_eq!(icon.extra_fills().len(), 1);
assert!((icon.extra_fills()[0].opacity - 0.5).abs() < 0.01);
}
#[test]
fn stroke_dasharray_sets_dash_pattern() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-dasharray="5 3">
<line x1="0" y1="0" x2="24" y2="0"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(icon.strokes().len(), 1);
let dash = icon.strokes()[0].dash.as_ref().expect("dash present");
assert_eq!(dash.0, vec![5.0, 3.0]);
}
#[test]
fn stroke_dasharray_odd_length_doubles() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-dasharray="4">
<line x1="0" y1="0" x2="24" y2="0"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let dash = icon.strokes()[0].dash.as_ref().expect("dash present");
assert_eq!(
dash.0,
vec![4.0, 4.0],
"an odd-length dash array is doubled per spec"
);
}
#[test]
fn miter_limit_parses() {
let svg = r#"<svg viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-miterlimit="10">
<path d="M0 0L10 10L20 0"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(icon.strokes().len(), 1);
assert!((icon.strokes()[0].miter_limit - 10.0).abs() < 0.01);
}
#[test]
fn rect_rx_ry_distinct_uses_elliptical_corners() {
let svg = r#"<svg viewBox="0 0 24 24">
<rect x="0" y="0" width="20" height="10" rx="4" ry="2"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let cmds = &icon.raw_path().commands;
let arcs = cmds
.iter()
.filter(|c| matches!(c, PathCommand::ArcTo { .. }))
.count();
assert_eq!(arcs, 4, "four elliptical corner arcs");
let arc = cmds
.iter()
.find_map(|c| match c {
PathCommand::ArcTo { rect, .. } => Some(*rect),
_ => None,
})
.unwrap();
assert!(
(arc.width - 8.0).abs() < 0.01 && (arc.height - 4.0).abs() < 0.01,
"corner ellipse should be 8×4, got {}×{}",
arc.width,
arc.height
);
}
#[test]
fn aspect_ratio_none_stretches() {
let svg = r#"<svg viewBox="0 0 20 10" preserveAspectRatio="none">
<rect x="10" y="5" width="1" height="1"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let path = icon.to_path_in_rect(Rect::new(0.0, 0.0, 40.0, 40.0));
match path.commands.first() {
Some(PathCommand::MoveTo(p)) => {
assert!(
(p.x - 20.0).abs() < 0.01 && (p.y - 20.0).abs() < 0.01,
"stretched to ({},{})",
p.x,
p.y
);
}
other => panic!("expected MoveTo, got {other:?}"),
}
}
#[test]
fn aspect_ratio_xminymin_aligns_to_corner() {
let svg = r#"<svg viewBox="0 0 20 10" preserveAspectRatio="xMinYMin meet">
<rect x="0" y="0" width="1" height="1"/>
</svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
let path = icon.to_path_in_rect(Rect::new(0.0, 0.0, 40.0, 40.0));
match path.commands.first() {
Some(PathCommand::MoveTo(p)) => {
assert!(
p.x.abs() < 0.01 && p.y.abs() < 0.01,
"top-left aligned, got ({},{})",
p.x,
p.y
);
}
other => panic!("expected MoveTo, got {other:?}"),
}
}
#[test]
fn length_with_pt_unit_parses() {
let svg = r#"<svg width="24pt" height="24pt"><rect width="10" height="10"/></svg>"#;
let icon = SvgIcon::parse(svg).unwrap();
assert!((icon.width() - 24.0).abs() < 0.01);
}
#[test]
fn percent_width_no_viewbox_is_clean_error() {
let svg = r#"<svg width="100%" height="100%"><rect width="10" height="10"/></svg>"#;
assert!(SvgIcon::parse(svg).is_err());
}
const RECT: Rect = Rect {
x: 0.0,
y: 0.0,
width: 100.0,
height: 100.0,
};
const TINT: Color = Color::BLACK;
fn solid_paints(icon: &SvgIcon, tint: Color) -> Vec<Color> {
icon.draw_ops_in_rect(RECT, tint)
.into_iter()
.map(|op| match op {
SvgDrawOp::Fill { paint, .. } | SvgDrawOp::Stroke { paint, .. } => match paint {
Paint::Solid(c) => c,
other => panic!("expected a solid paint, got {other:?}"),
},
})
.collect()
}
#[test]
fn shapes_keep_their_own_colors() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="50" height="50" fill="#ff0000"/>
<circle cx="70" cy="70" r="20" fill="rgb(0, 255, 0)"/>
<rect x="0" y="60" width="10" height="10" fill="blue"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
let colors = solid_paints(&icon, TINT);
assert_eq!(colors.len(), 3);
assert_eq!(colors[0].to_array(), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(colors[1].to_array(), [0.0, 1.0, 0.0, 1.0]);
assert_eq!(colors[2].to_array(), [0.0, 0.0, 1.0, 1.0]);
assert!(!icon.is_monochrome());
}
#[test]
fn ops_keep_document_order() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="80" height="80" fill="blue"/>
<rect width="40" height="40" fill="red"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
let colors = solid_paints(&icon, TINT);
assert_eq!(
colors[0].to_array(),
[0.0, 0.0, 1.0, 1.0],
"the blue square is authored first, so it must paint first"
);
assert_eq!(colors[1].to_array(), [1.0, 0.0, 0.0, 1.0]);
}
#[test]
fn current_color_and_dangling_refs_take_the_tint() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="10" height="10" fill="currentColor"/>
<rect width="10" height="10" fill="url(#nope)"/>
<rect width="10" height="10" fill="#123456"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
let tint = Color::new(0.2, 0.4, 0.6, 1.0);
let colors = solid_paints(&icon, tint);
assert_eq!(colors[0].to_array(), tint.to_array());
assert_eq!(
colors[1].to_array(),
tint.to_array(),
"a dangling url(#…) must stay visible in the tint, not vanish"
);
assert_ne!(colors[2].to_array(), tint.to_array());
}
#[test]
fn a_dangling_ref_prefers_its_authored_fallback() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="10" height="10" fill="url(#nope) lime"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(
solid_paints(&icon, TINT)[0].to_array(),
[0.0, 1.0, 0.0, 1.0]
);
}
#[test]
fn color_inherits_through_groups_and_cascades() {
let svg = r##"<svg viewBox="0 0 100 100">
<style>.hot { fill: orange; }</style>
<g fill="red">
<rect width="10" height="10"/>
<rect width="10" height="10" fill="blue"/>
<rect width="10" height="10" class="hot"/>
<rect width="10" height="10" class="hot" style="fill: white"/>
</g>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
let c = solid_paints(&icon, TINT);
assert_eq!(c[0].to_array(), [1.0, 0.0, 0.0, 1.0], "inherited from <g>");
assert_eq!(c[1].to_array(), [0.0, 0.0, 1.0, 1.0], "own attribute wins");
assert_eq!(c[2].to_hex_upper(false), "#FFA500", "CSS rule beats <g>");
assert_eq!(c[3].to_array(), [1.0, 1.0, 1.0, 1.0], "inline style wins");
}
#[test]
fn a_current_color_icon_is_monochrome() {
let svg = r##"<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
</svg>"##;
assert!(SvgIcon::parse(svg).unwrap().is_monochrome());
let colored =
r##"<svg viewBox="0 0 24 24"><rect width="8" height="8" fill="black"/></svg>"##;
assert!(!SvgIcon::parse(colored).unwrap().is_monochrome());
}
#[test]
fn opacity_folds_in_without_double_counting_the_tint() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="10" height="10" fill="red" fill-opacity="0.5"/>
<rect width="10" height="10" fill="currentColor"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
let half_tint = Color::new(1.0, 1.0, 1.0, 0.5);
let c = solid_paints(&icon, half_tint);
assert!(
(c[0].a() - 0.25).abs() < 1e-5,
"0.5 fill-opacity × 0.5 tint alpha = 0.25, got {}",
c[0].a()
);
assert!(
(c[1].a() - 0.5).abs() < 1e-5,
"a currentColor shape carries the tint's alpha once, got {}",
c[1].a()
);
}
#[test]
fn invisible_shapes_are_dropped() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="10" height="10" fill="red" fill-opacity="0"/>
<rect width="10" height="10" fill="transparent"/>
<rect width="10" height="10" fill="red"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
assert_eq!(icon.draw_ops_in_rect(RECT, TINT).len(), 1);
}
#[test]
fn a_filled_and_stroked_shape_emits_both_ops_in_order() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="50" height="50" fill="red" stroke="blue" stroke-width="4"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
let ops = icon.draw_ops_in_rect(RECT, TINT);
assert_eq!(ops.len(), 2);
match &ops[0] {
SvgDrawOp::Fill { paint, .. } => {
assert_eq!(*paint, Paint::Solid(Color::new(1.0, 0.0, 0.0, 1.0)))
}
other => panic!("expected the fill first, got {other:?}"),
}
match &ops[1] {
SvgDrawOp::Stroke { style, paint, .. } => {
assert_eq!(*paint, Paint::Solid(Color::new(0.0, 0.0, 1.0, 1.0)));
assert!((style.width - 4.0).abs() < 1e-5);
}
other => panic!("expected the stroke second, got {other:?}"),
}
}
fn first_paint(svg: &str, tint: Color) -> Paint {
let icon = SvgIcon::parse(svg).unwrap();
match icon.draw_ops_in_rect(RECT, tint).remove(0) {
SvgDrawOp::Fill { paint, .. } | SvgDrawOp::Stroke { paint, .. } => paint,
}
}
#[test]
fn a_linear_gradient_binds_to_the_shape_bounding_box() {
let svg = r##"<svg viewBox="0 0 100 100">
<defs>
<linearGradient id="g">
<stop offset="0" stop-color="red"/>
<stop offset="1" stop-color="blue"/>
</linearGradient>
</defs>
<rect x="25" y="25" width="50" height="50" fill="url(#g)"/>
</svg>"##;
match first_paint(svg, TINT) {
Paint::LinearGradient { start, end, stops } => {
assert!(start.x.abs() < 1e-4 && start.y.abs() < 1e-4, "{start:?}");
assert!((end.x - 50.0).abs() < 1e-4 && end.y.abs() < 1e-4, "{end:?}");
assert_eq!(stops.len(), 2);
assert_eq!(stops[0].color.to_array(), [1.0, 0.0, 0.0, 1.0]);
assert_eq!(stops[1].color.to_array(), [0.0, 0.0, 1.0, 1.0]);
}
other => panic!("expected a linear gradient, got {other:?}"),
}
}
#[test]
fn user_space_gradient_coordinates_are_absolute() {
let svg = r##"<svg viewBox="0 0 100 100">
<linearGradient id="g" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="100" y2="0">
<stop offset="0" stop-color="red"/>
<stop offset="1" stop-color="blue"/>
</linearGradient>
<rect x="20" y="20" width="20" height="20" fill="url(#g)"/>
</svg>"##;
match first_paint(svg, TINT) {
Paint::LinearGradient { start, end, .. } => {
assert!((start.x + 20.0).abs() < 1e-4, "start {start:?}");
assert!((end.x - 80.0).abs() < 1e-4, "end {end:?}");
}
other => panic!("expected a linear gradient, got {other:?}"),
}
}
#[test]
fn a_gradient_inherits_stops_through_href() {
let svg = r##"<svg viewBox="0 0 100 100">
<defs>
<linearGradient id="ramp">
<stop offset="0" stop-color="red"/>
<stop offset="1" stop-color="lime"/>
</linearGradient>
<linearGradient id="down" xlink:href="#ramp" x1="0" y1="0" x2="0" y2="1"/>
</defs>
<rect width="100" height="100" fill="url(#down)"/>
</svg>"##;
match first_paint(svg, TINT) {
Paint::LinearGradient { start, end, stops } => {
assert_eq!(stops.len(), 2, "stops must come from the referenced ramp");
assert_eq!(stops[1].color.to_array(), [0.0, 1.0, 0.0, 1.0]);
assert!(start.y.abs() < 1e-4 && (end.y - 100.0).abs() < 1e-4);
assert!(end.x.abs() < 1e-4, "direction must be vertical, {end:?}");
}
other => panic!("expected a linear gradient, got {other:?}"),
}
}
#[test]
fn gradient_transform_applies() {
let svg = r##"<svg viewBox="0 0 100 100">
<linearGradient id="g" gradientTransform="rotate(90)">
<stop offset="0" stop-color="red"/>
<stop offset="1" stop-color="blue"/>
</linearGradient>
<rect width="100" height="100" fill="url(#g)"/>
</svg>"##;
match first_paint(svg, TINT) {
Paint::LinearGradient { start, end, .. } => {
assert!(start.x.abs() < 1e-3 && start.y.abs() < 1e-3, "{start:?}");
assert!(
end.x.abs() < 1e-3 && (end.y - 100.0).abs() < 1e-3,
"rotate(90) should turn the ramp downward, got {end:?}"
);
}
other => panic!("expected a linear gradient, got {other:?}"),
}
}
#[test]
fn a_radial_gradient_defaults_to_the_shape_centre() {
let svg = r##"<svg viewBox="0 0 100 100">
<radialGradient id="g">
<stop offset="0" stop-color="white"/>
<stop offset="1" stop-color="black"/>
</radialGradient>
<rect width="80" height="80" fill="url(#g)"/>
</svg>"##;
match first_paint(svg, TINT) {
Paint::RadialGradient {
center,
radius,
stops,
} => {
assert!((center.x - 40.0).abs() < 1e-4 && (center.y - 40.0).abs() < 1e-4);
assert!((radius - 40.0).abs() < 1e-4, "radius {radius}");
assert_eq!(stops.len(), 2);
}
other => panic!("expected a radial gradient, got {other:?}"),
}
}
#[test]
fn stops_carry_opacity_and_current_color() {
let svg = r##"<svg viewBox="0 0 100 100">
<linearGradient id="g">
<stop offset="0" stop-color="red" stop-opacity="0.5"/>
<stop offset="1" stop-color="currentColor"/>
</linearGradient>
<rect width="100" height="100" fill="url(#g)"/>
</svg>"##;
let tint = Color::new(0.0, 1.0, 0.0, 1.0);
match first_paint(svg, tint) {
Paint::LinearGradient { stops, .. } => {
assert!((stops[0].color.a() - 0.5).abs() < 1e-5);
assert_eq!(
stops[1].color.to_array(),
tint.to_array(),
"a currentColor stop must take the tint"
);
}
other => panic!("expected a linear gradient, got {other:?}"),
}
}
#[test]
fn a_one_stop_gradient_becomes_a_flat_ramp() {
let svg = r##"<svg viewBox="0 0 100 100">
<linearGradient id="g"><stop offset="0.5" stop-color="red"/></linearGradient>
<rect width="100" height="100" fill="url(#g)"/>
</svg>"##;
match first_paint(svg, TINT) {
Paint::LinearGradient { stops, .. } => {
assert_eq!(stops.len(), 2);
assert_eq!(stops[0].offset, 0.0);
assert_eq!(stops[1].offset, 1.0);
assert_eq!(stops[0].color.to_array(), [1.0, 0.0, 0.0, 1.0]);
}
other => panic!("expected a linear gradient, got {other:?}"),
}
}
#[test]
fn a_stopless_gradient_falls_back_to_the_tint() {
let svg = r##"<svg viewBox="0 0 100 100">
<linearGradient id="g"/>
<rect width="100" height="100" fill="url(#g)"/>
</svg>"##;
let tint = Color::new(0.1, 0.2, 0.3, 1.0);
assert_eq!(first_paint(svg, tint), Paint::Solid(tint));
}
#[test]
fn an_href_cycle_terminates() {
let svg = r##"<svg viewBox="0 0 100 100">
<linearGradient id="a" xlink:href="#b"/>
<linearGradient id="b" xlink:href="#a"/>
<rect width="100" height="100" fill="url(#a)"/>
</svg>"##;
assert_eq!(first_paint(svg, TINT), Paint::Solid(TINT));
}
#[test]
fn gradient_geometry_follows_the_fit() {
let svg = r##"<svg viewBox="0 0 100 100">
<linearGradient id="g">
<stop offset="0" stop-color="red"/><stop offset="1" stop-color="blue"/>
</linearGradient>
<rect width="100" height="100" fill="url(#g)"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
let ops = icon.draw_ops_in_rect(Rect::new(0.0, 0.0, 50.0, 50.0), TINT);
match &ops[0] {
SvgDrawOp::Fill {
paint: Paint::LinearGradient { end, .. },
..
} => assert!((end.x - 50.0).abs() < 1e-4, "end {end:?}"),
other => panic!("expected a linear gradient fill, got {other:?}"),
}
}
#[test]
fn the_tinted_representation_still_merges_colored_shapes() {
let svg = r##"<svg viewBox="0 0 100 100">
<rect width="50" height="50" fill="red"/>
<rect x="50" y="50" width="50" height="50" fill="blue"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
assert!(
icon.extra_fills().is_empty(),
"two opaque winding fills still merge into the single hot path"
);
assert!(!icon.raw_path().is_empty());
assert_eq!(icon.ops().len(), 2, "…while full-color keeps them apart");
}
#[test]
fn an_image_only_svg_has_no_geometry() {
let svg = r##"<svg viewBox="0 0 512 512">
<image x="0" y="0" width="512" height="512" xlink:href="data:image/png;base64,iVBOR"/>
</svg>"##;
let icon = SvgIcon::parse(svg).unwrap();
assert!(icon.is_empty());
assert!(icon.draw_ops_in_rect(RECT, TINT).is_empty());
}
}