use crate::commands::{
Command, FillRule, GlyphGradientSpread, GlyphGradientStop, GlyphGradientUnits,
GlyphLinearGradient, GlyphPaint, GlyphRadialGradient, PathGlyphLayer,
};
use std::collections::HashMap;
#[derive(Debug, Clone)]
enum SvgNode {
Element(SvgElement),
Text,
}
#[derive(Debug, Clone)]
struct SvgElement {
name: String,
attrs: HashMap<String, String>,
children: Vec<SvgNode>,
}
#[derive(Debug, Clone)]
struct RenderState {
transform: Transform2D,
fill: Option<GlyphPaint>,
fill_rule: FillRule,
stroke: Option<GlyphPaint>,
stroke_width: f32,
clip_path: Option<ClipPathSpec>,
mask_path: Option<ClipPathSpec>,
}
impl Default for RenderState {
fn default() -> Self {
Self {
transform: Transform2D::IDENTITY,
fill: Some(GlyphPaint::CurrentColor),
fill_rule: FillRule::NonZero,
stroke: None,
stroke_width: 1.0,
clip_path: None,
mask_path: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ClipPathUnits {
UserSpaceOnUse,
ObjectBoundingBox,
}
#[derive(Debug, Clone)]
struct ClipPathSpec {
commands: Vec<Command>,
units: ClipPathUnits,
}
#[derive(Debug, Clone, Copy)]
struct Transform2D {
a: f32,
b: f32,
c: f32,
d: f32,
e: f32,
f: f32,
}
impl Transform2D {
const IDENTITY: Self = Self {
a: 1.0,
b: 0.0,
c: 0.0,
d: 1.0,
e: 0.0,
f: 0.0,
};
fn multiply(self, next: Self) -> Self {
Self {
a: self.a * next.a + self.c * next.b,
b: self.b * next.a + self.d * next.b,
c: self.a * next.c + self.c * next.d,
d: self.b * next.c + self.d * next.d,
e: self.a * next.e + self.c * next.f + self.e,
f: self.b * next.e + self.d * next.f + self.f,
}
}
fn translate(x: f32, y: f32) -> Self {
Self {
e: x,
f: y,
..Self::IDENTITY
}
}
fn scale(x: f32, y: f32) -> Self {
Self {
a: x,
d: y,
..Self::IDENTITY
}
}
fn rotate_radians(angle: f32) -> Self {
let (sin, cos) = angle.sin_cos();
Self {
a: cos,
b: sin,
c: -sin,
d: cos,
..Self::IDENTITY
}
}
fn skew_x_radians(angle: f32) -> Self {
Self {
c: angle.tan(),
..Self::IDENTITY
}
}
fn skew_y_radians(angle: f32) -> Self {
Self {
b: angle.tan(),
..Self::IDENTITY
}
}
fn apply(self, x: f32, y: f32) -> (f32, f32) {
(
self.a * x + self.c * y + self.e,
self.b * x + self.d * y + self.f,
)
}
}
pub(crate) fn svg_to_path_layers(
document: &str,
scale_x: f32,
scale_y: f32,
) -> Vec<PathGlyphLayer> {
let Ok(root) = parse_svg_document(document) else {
return Vec::new();
};
let mut defs = HashMap::new();
collect_definitions(&root, &mut defs);
let mut layers = Vec::new();
let state = RenderState::default();
flatten_node(
&root,
&defs,
&state,
scale_x,
scale_y,
&mut layers,
);
layers
}
pub(crate) fn svg_requires_svg_fallback(document: &str) -> bool {
let lowered = document.to_ascii_lowercase();
lowered.contains("<pattern")
|| lowered.contains("<mask")
|| lowered.contains("<filter")
|| lowered.contains("patternunits=")
|| lowered.contains("maskunits=")
|| lowered.contains("maskcontentunits=")
|| lowered.contains(" filter=")
|| lowered.contains("filter=\"")
|| lowered.contains("mask=\"")
|| lowered.contains("mask:")
}
fn parse_svg_document(document: &str) -> Result<SvgNode, ()> {
let mut stack: Vec<SvgElement> = vec![SvgElement {
name: "#document".to_string(),
attrs: HashMap::new(),
children: Vec::new(),
}];
let mut cursor = 0usize;
while cursor < document.len() {
let Some(relative_start) = document[cursor..].find('<') else {
let trailing = &document[cursor..];
if !trailing.trim().is_empty() {
stack
.last_mut()
.expect("document root")
.children
.push(SvgNode::Text);
}
break;
};
let start = cursor + relative_start;
let text = &document[cursor..start];
if !text.trim().is_empty() {
stack
.last_mut()
.expect("document root")
.children
.push(SvgNode::Text);
}
let Some(relative_end) = document[start..].find('>') else {
return Err(());
};
let end = start + relative_end + 1;
let tag = &document[start..end];
cursor = end;
if tag.starts_with("<?") || tag.starts_with("<!") {
continue;
}
if tag.starts_with("</") {
let Some(name) = closing_tag_name(tag) else {
return Err(());
};
let element = stack.pop().ok_or(())?;
if element.name != name.to_ascii_lowercase() {
return Err(());
}
stack
.last_mut()
.ok_or(())?
.children
.push(SvgNode::Element(element));
continue;
}
let Some(name) = tag_name(tag) else {
return Err(());
};
let attrs = parse_attributes(tag);
let self_closing = tag[..tag.len().saturating_sub(1)].trim_end().ends_with('/');
let element = SvgElement {
name: name.to_ascii_lowercase(),
attrs,
children: Vec::new(),
};
if self_closing {
stack
.last_mut()
.ok_or(())?
.children
.push(SvgNode::Element(element));
} else {
stack.push(element);
}
}
while stack.len() > 1 {
let element = stack.pop().ok_or(())?;
stack
.last_mut()
.ok_or(())?
.children
.push(SvgNode::Element(element));
}
Ok(SvgNode::Element(stack.pop().ok_or(())?))
}
fn collect_definitions(node: &SvgNode, defs: &mut HashMap<String, SvgElement>) {
let SvgNode::Element(element) = node else {
return;
};
if element.name == "defs" {
collect_id_elements(&element.children, defs);
}
for child in &element.children {
collect_definitions(child, defs);
}
}
fn collect_id_elements(children: &[SvgNode], defs: &mut HashMap<String, SvgElement>) {
for child in children {
let SvgNode::Element(element) = child else {
continue;
};
if let Some(id) = element.attrs.get("id") {
defs.entry(id.clone()).or_insert_with(|| element.clone());
}
collect_id_elements(&element.children, defs);
}
}
fn flatten_node(
node: &SvgNode,
defs: &HashMap<String, SvgElement>,
state: &RenderState,
scale_x: f32,
scale_y: f32,
out: &mut Vec<PathGlyphLayer>,
) {
let SvgNode::Element(element) = node else {
return;
};
if element.name == "#document" {
for child in &element.children {
flatten_node(child, defs, state, scale_x, scale_y, out);
}
return;
}
if element.name == "defs" {
return;
}
let style = element.attrs.get("style").map(|value| parse_style(value));
let local_fill = resolve_paint(
style.as_ref().and_then(|style| style.get("fill")),
element.attrs.get("fill"),
state.fill.as_ref(),
defs,
scale_x,
scale_y,
);
let local_fill_rule = style
.as_ref()
.and_then(|style| style.get("fill-rule"))
.or_else(|| element.attrs.get("fill-rule"))
.map(|value| parse_fill_rule(value))
.unwrap_or(state.fill_rule);
let local_stroke = resolve_paint(
style.as_ref().and_then(|style| style.get("stroke")),
element.attrs.get("stroke"),
state.stroke.as_ref(),
defs,
scale_x,
scale_y,
);
let local_stroke_width = style
.as_ref()
.and_then(|style| style.get("stroke-width"))
.or_else(|| element.attrs.get("stroke-width"))
.and_then(|value| parse_number(value))
.map(|value| value * scale_x.abs().max(scale_y.abs()))
.unwrap_or(state.stroke_width);
let local_clip_path = resolve_clip_path(
style.as_ref().and_then(|style| style.get("clip-path")),
element.attrs.get("clip-path"),
state.clip_path.as_ref(),
defs,
scale_x,
scale_y,
);
let local_mask_path = resolve_mask_path(
style.as_ref().and_then(|style| style.get("mask")),
element.attrs.get("mask"),
state.mask_path.as_ref(),
defs,
scale_x,
scale_y,
);
let next_state = RenderState {
transform: state.transform.multiply(parse_transform(
style
.as_ref()
.and_then(|style| style.get("transform"))
.or_else(|| element.attrs.get("transform")),
scale_x,
scale_y,
)),
fill: local_fill,
fill_rule: local_fill_rule,
stroke: local_stroke,
stroke_width: local_stroke_width,
clip_path: local_clip_path,
mask_path: local_mask_path,
};
match element.name.as_str() {
"svg" | "g" | "symbol" => {
for child in &element.children {
flatten_node(child, defs, &next_state, scale_x, scale_y, out);
}
}
"use" => flatten_use(element, defs, next_state, scale_x, scale_y, out),
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
out.extend(element_to_path_layers(element, &next_state, scale_x, scale_y));
}
_ => {}
}
}
fn flatten_use(
element: &SvgElement,
defs: &HashMap<String, SvgElement>,
state: RenderState,
scale_x: f32,
scale_y: f32,
out: &mut Vec<PathGlyphLayer>,
) {
let Some(reference) = element
.attrs
.get("href")
.or_else(|| element.attrs.get("xlink:href"))
.and_then(|value| value.strip_prefix('#'))
else {
return;
};
let Some(referenced) = defs.get(reference) else {
return;
};
let mut referenced = referenced.clone();
for key in ["fill", "fill-rule", "stroke", "stroke-width"] {
if let Some(value) = element.attrs.get(key) {
referenced.attrs.insert(key.to_string(), value.clone());
}
}
let use_state = RenderState {
transform: state.transform.multiply(Transform2D::translate(
parse_attr_f32(&element.attrs, "x").unwrap_or(0.0) * scale_x,
parse_attr_f32(&element.attrs, "y").unwrap_or(0.0) * scale_y,
)),
fill: state.fill,
fill_rule: state.fill_rule,
stroke: state.stroke,
stroke_width: state.stroke_width,
clip_path: state.clip_path,
mask_path: state.mask_path,
};
flatten_node(
&SvgNode::Element(referenced),
defs,
&use_state,
scale_x,
scale_y,
out,
);
}
fn element_to_path_layers(
element: &SvgElement,
state: &RenderState,
scale_x: f32,
scale_y: f32,
) -> Vec<PathGlyphLayer> {
let commands = match element.name.as_str() {
"path" => element
.attrs
.get("d")
.and_then(|d| parse_path_data(d, scale_x, scale_y)),
"rect" => rect_to_commands(&element.attrs, scale_x, scale_y),
"circle" => circle_to_commands(&element.attrs, scale_x, scale_y),
"ellipse" => ellipse_to_commands(&element.attrs, scale_x, scale_y),
"line" => line_to_commands(&element.attrs, scale_x, scale_y),
"polyline" => poly_points_to_commands(&element.attrs, false, scale_x, scale_y),
"polygon" => poly_points_to_commands(&element.attrs, true, scale_x, scale_y),
_ => return Vec::new(),
};
let Some(commands) = commands else {
return Vec::new();
};
if commands.is_empty() {
return Vec::new();
}
let transformed = transform_commands(&commands, state.transform);
let bounds = command_bounds(&transformed);
let mut layers = Vec::new();
let supports_fill = !matches!(element.name.as_str(), "line");
if supports_fill {
if let Some(paint) = state.fill.clone() {
let mut layer = PathGlyphLayer::new(
transformed.clone(),
resolve_gradient_paint(paint, bounds, state.transform),
);
layer.clip_commands = resolve_combined_clip_commands(
state.clip_path.as_ref(),
state.mask_path.as_ref(),
bounds,
state.transform,
);
layer.fill_rule = state.fill_rule;
layers.push(layer);
}
}
if let Some(paint) = state.stroke.clone() {
if state.stroke_width > 0.0 {
let mut layer = PathGlyphLayer::stroke(
transformed,
resolve_gradient_paint(paint, bounds, state.transform),
state.stroke_width,
);
layer.clip_commands = resolve_combined_clip_commands(
state.clip_path.as_ref(),
state.mask_path.as_ref(),
bounds,
state.transform,
);
layers.push(layer);
}
}
layers
}
fn resolve_gradient_paint(
paint: GlyphPaint,
bounds: Option<(f32, f32, f32, f32)>,
element_transform: Transform2D,
) -> GlyphPaint {
match paint {
GlyphPaint::LinearGradient(gradient) => {
GlyphPaint::LinearGradient(resolve_linear_gradient(gradient, bounds, element_transform))
}
GlyphPaint::RadialGradient(gradient) => {
GlyphPaint::RadialGradient(resolve_radial_gradient(gradient, bounds, element_transform))
}
other => other,
}
}
fn resolve_combined_clip_commands(
clip_path: Option<&ClipPathSpec>,
mask_path: Option<&ClipPathSpec>,
bounds: Option<(f32, f32, f32, f32)>,
element_transform: Transform2D,
) -> Vec<Command> {
let mut commands = Vec::new();
if let Some(clip_path) = clip_path {
commands.extend(resolve_clip_spec_commands(clip_path, bounds, element_transform));
}
if let Some(mask_path) = mask_path {
commands.extend(resolve_clip_spec_commands(mask_path, bounds, element_transform));
}
commands
}
fn resolve_clip_spec_commands(
clip_path: &ClipPathSpec,
bounds: Option<(f32, f32, f32, f32)>,
element_transform: Transform2D,
) -> Vec<Command> {
if matches!(clip_path.units, ClipPathUnits::ObjectBoundingBox) {
let (min_x, min_y, max_x, max_y) = bounds.unwrap_or((0.0, 0.0, 0.0, 0.0));
let width = max_x - min_x;
let height = max_y - min_y;
let transform = Transform2D {
a: width,
b: 0.0,
c: 0.0,
d: height,
e: min_x,
f: min_y,
};
transform_commands(&clip_path.commands, transform)
} else {
transform_commands(&clip_path.commands, element_transform)
}
}
fn resolve_linear_gradient(
mut gradient: GlyphLinearGradient,
bounds: Option<(f32, f32, f32, f32)>,
element_transform: Transform2D,
) -> GlyphLinearGradient {
let matrix = matrix_from_array(gradient.transform);
let (x1, y1, x2, y2) = if matches!(gradient.units, GlyphGradientUnits::ObjectBoundingBox) {
let (min_x, min_y, max_x, max_y) = bounds.unwrap_or((0.0, 0.0, 0.0, 0.0));
let width = max_x - min_x;
let height = max_y - min_y;
(
min_x + gradient.x1 * width,
min_y + gradient.y1 * height,
min_x + gradient.x2 * width,
min_y + gradient.y2 * height,
)
} else {
let start = element_transform.apply(gradient.x1, gradient.y1);
let end = element_transform.apply(gradient.x2, gradient.y2);
(start.0, start.1, end.0, end.1)
};
let start = matrix.apply(x1, y1);
let end = matrix.apply(x2, y2);
gradient.x1 = start.0;
gradient.y1 = start.1;
gradient.x2 = end.0;
gradient.y2 = end.1;
gradient.units = GlyphGradientUnits::UserSpaceOnUse;
gradient.transform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
gradient
}
fn resolve_radial_gradient(
mut gradient: GlyphRadialGradient,
bounds: Option<(f32, f32, f32, f32)>,
element_transform: Transform2D,
) -> GlyphRadialGradient {
let matrix = matrix_from_array(gradient.transform);
let (cx, cy, fx, fy, radius) = if matches!(gradient.units, GlyphGradientUnits::ObjectBoundingBox) {
let (min_x, min_y, max_x, max_y) = bounds.unwrap_or((0.0, 0.0, 0.0, 0.0));
let width = max_x - min_x;
let height = max_y - min_y;
let scale = width.abs().max(height.abs());
(
min_x + gradient.cx * width,
min_y + gradient.cy * height,
min_x + gradient.fx * width,
min_y + gradient.fy * height,
gradient.r * scale,
)
} else {
let center = element_transform.apply(gradient.cx, gradient.cy);
let focus = element_transform.apply(gradient.fx, gradient.fy);
(
center.0,
center.1,
focus.0,
focus.1,
gradient.r * transform_max_scale(element_transform),
)
};
let center = matrix.apply(cx, cy);
let focus = matrix.apply(fx, fy);
gradient.cx = center.0;
gradient.cy = center.1;
gradient.fx = focus.0;
gradient.fy = focus.1;
gradient.r = radius * transform_max_scale(matrix);
gradient.units = GlyphGradientUnits::UserSpaceOnUse;
gradient.transform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
gradient
}
fn matrix_from_array(values: [f32; 6]) -> Transform2D {
Transform2D {
a: values[0],
b: values[1],
c: values[2],
d: values[3],
e: values[4],
f: values[5],
}
}
fn transform_max_scale(transform: Transform2D) -> f32 {
let sx = (transform.a * transform.a + transform.b * transform.b).sqrt();
let sy = (transform.c * transform.c + transform.d * transform.d).sqrt();
sx.max(sy)
}
fn command_bounds(commands: &[Command]) -> Option<(f32, f32, f32, f32)> {
let mut bounds = None;
for command in commands {
match command {
Command::MoveTo(x, y) | Command::Line(x, y) => extend_command_bounds(&mut bounds, *x, *y),
Command::Bezier((cx, cy), (x, y)) => {
extend_command_bounds(&mut bounds, *cx, *cy);
extend_command_bounds(&mut bounds, *x, *y);
}
Command::CubicBezier((x1, y1), (x2, y2), (x, y)) => {
extend_command_bounds(&mut bounds, *x1, *y1);
extend_command_bounds(&mut bounds, *x2, *y2);
extend_command_bounds(&mut bounds, *x, *y);
}
Command::Close => {}
}
}
bounds
}
fn extend_command_bounds(bounds: &mut Option<(f32, f32, f32, f32)>, x: f32, y: f32) {
if let Some((min_x, min_y, max_x, max_y)) = bounds.as_mut() {
*min_x = min_x.min(x);
*min_y = min_y.min(y);
*max_x = max_x.max(x);
*max_y = max_y.max(y);
} else {
*bounds = Some((x, y, x, y));
}
}
fn tag_name(tag: &str) -> Option<&str> {
let start = 1usize;
let end = tag[start..]
.find(|ch: char| ch.is_whitespace() || ch == '>' || ch == '/')
.map(|offset| start + offset)?;
Some(&tag[start..end])
}
fn closing_tag_name(tag: &str) -> Option<&str> {
let start = 2usize;
let end = tag[start..]
.find(|ch: char| ch.is_whitespace() || ch == '>')
.map(|offset| start + offset)?;
Some(&tag[start..end])
}
fn parse_attributes(tag: &str) -> HashMap<String, String> {
let mut attrs = HashMap::new();
let Some(mut index) = tag.find(char::is_whitespace) else {
return attrs;
};
while index < tag.len() {
while index < tag.len()
&& matches!(
tag.as_bytes()[index],
b' ' | b'\n' | b'\r' | b'\t' | b'/' | b'>'
)
{
index += 1;
}
if index >= tag.len() || tag.as_bytes()[index] == b'>' {
break;
}
let key_start = index;
while index < tag.len()
&& !matches!(
tag.as_bytes()[index],
b'=' | b' ' | b'\n' | b'\r' | b'\t' | b'>'
)
{
index += 1;
}
if index >= tag.len() || tag.as_bytes()[index] != b'=' {
while index < tag.len() && tag.as_bytes()[index] != b'>' {
if tag.as_bytes()[index] == b' ' {
break;
}
index += 1;
}
continue;
}
let key = tag[key_start..index].trim().to_ascii_lowercase();
index += 1;
if index >= tag.len() {
break;
}
let quote = tag.as_bytes()[index];
if quote != b'"' && quote != b'\'' {
continue;
}
index += 1;
let value_start = index;
while index < tag.len() && tag.as_bytes()[index] != quote {
index += 1;
}
if index > value_start {
attrs.insert(key, tag[value_start..index].to_string());
}
index += 1;
}
attrs
}
fn parse_style(style: &str) -> HashMap<String, String> {
style
.split(';')
.filter_map(|entry| {
let (key, value) = entry.split_once(':')?;
Some((key.trim().to_ascii_lowercase(), value.trim().to_string()))
})
.collect()
}
fn parse_fill(
value: &str,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<GlyphPaint> {
let value = value.trim();
if let Some(paint) = parse_basic_paint(value) {
return Some(paint);
}
if let Some(reference) = parse_url_reference(value) {
return defs
.get(reference)
.and_then(|element| parse_gradient(element, defs, scale_x, scale_y));
}
None
}
fn parse_basic_paint(value: &str) -> Option<GlyphPaint> {
let value = value.trim();
if value.eq_ignore_ascii_case("currentcolor") {
return Some(GlyphPaint::CurrentColor);
}
if let Some(hex) = value.strip_prefix('#') {
return parse_hex_color(hex).map(GlyphPaint::Solid);
}
None
}
fn parse_url_reference(value: &str) -> Option<&str> {
value
.strip_prefix("url(")?
.strip_suffix(')')?
.trim()
.strip_prefix('#')
}
fn parse_gradient(
element: &SvgElement,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<GlyphPaint> {
match element.name.as_str() {
"lineargradient" => parse_linear_gradient(element, defs, scale_x, scale_y)
.map(GlyphPaint::LinearGradient),
"radialgradient" => parse_radial_gradient(element, defs, scale_x, scale_y)
.map(GlyphPaint::RadialGradient),
_ => None,
}
}
fn parse_linear_gradient(
element: &SvgElement,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<GlyphLinearGradient> {
let inherited = gradient_reference(element, defs);
let inherited_gradient = inherited.and_then(|gradient| {
parse_linear_gradient(gradient, defs, scale_x, scale_y).or_else(|| {
parse_radial_gradient(gradient, defs, scale_x, scale_y).map(|gradient| {
let center_x = gradient.cx;
let center_y = gradient.cy;
GlyphLinearGradient {
x1: center_x - gradient.r,
y1: center_y,
x2: center_x + gradient.r,
y2: center_y,
units: gradient.units,
transform: gradient.transform,
spread: gradient.spread,
stops: gradient.stops,
}
})
})
});
let units = gradient_units(element, inherited, inherited_gradient.as_ref().map(|g| g.units));
let x1 = gradient_number(element, inherited, "x1").or_else(|| inherited_gradient.as_ref().map(|g| g.x1)).unwrap_or(0.0);
let y1 = gradient_number(element, inherited, "y1").or_else(|| inherited_gradient.as_ref().map(|g| g.y1)).unwrap_or(0.0);
let x2 = gradient_number(element, inherited, "x2").or_else(|| inherited_gradient.as_ref().map(|g| g.x2)).unwrap_or(1.0);
let y2 = gradient_number(element, inherited, "y2").or_else(|| inherited_gradient.as_ref().map(|g| g.y2)).unwrap_or(0.0);
let (x1, y1, x2, y2) = if matches!(units, GlyphGradientUnits::UserSpaceOnUse) {
(x1 * scale_x, y1 * scale_y, x2 * scale_x, y2 * scale_y)
} else {
(x1, y1, x2, y2)
};
let transform = gradient_transform(
element,
inherited,
inherited_gradient.as_ref().map(|g| g.transform),
scale_x,
scale_y,
);
let spread = gradient_spread(
element,
inherited,
inherited_gradient.as_ref().map(|g| g.spread),
);
let stops = gradient_stops(
element,
inherited,
inherited_gradient.as_ref().map(|g| g.stops.as_slice()),
defs,
scale_x,
scale_y,
);
if stops.is_empty() {
return None;
}
Some(GlyphLinearGradient {
x1,
y1,
x2,
y2,
units,
transform,
spread,
stops,
})
}
fn parse_radial_gradient(
element: &SvgElement,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<GlyphRadialGradient> {
let inherited = gradient_reference(element, defs);
let inherited_gradient = inherited.and_then(|gradient| parse_radial_gradient(gradient, defs, scale_x, scale_y));
let units = gradient_units(element, inherited, inherited_gradient.as_ref().map(|g| g.units));
let cx = gradient_number(element, inherited, "cx").or_else(|| inherited_gradient.as_ref().map(|g| g.cx)).unwrap_or(0.5);
let cy = gradient_number(element, inherited, "cy").or_else(|| inherited_gradient.as_ref().map(|g| g.cy)).unwrap_or(0.5);
let r = gradient_number(element, inherited, "r").or_else(|| inherited_gradient.as_ref().map(|g| g.r)).unwrap_or(0.5);
let fx = gradient_number(element, inherited, "fx").or_else(|| inherited_gradient.as_ref().map(|g| g.fx)).unwrap_or(cx);
let fy = gradient_number(element, inherited, "fy").or_else(|| inherited_gradient.as_ref().map(|g| g.fy)).unwrap_or(cy);
let (cx, cy, fx, fy, r) = if matches!(units, GlyphGradientUnits::UserSpaceOnUse) {
(
cx * scale_x,
cy * scale_y,
fx * scale_x,
fy * scale_y,
r * scale_x.abs().max(scale_y.abs()),
)
} else {
(cx, cy, fx, fy, r)
};
let transform = gradient_transform(
element,
inherited,
inherited_gradient.as_ref().map(|g| g.transform),
scale_x,
scale_y,
);
let spread = gradient_spread(
element,
inherited,
inherited_gradient.as_ref().map(|g| g.spread),
);
let stops = gradient_stops(
element,
inherited,
inherited_gradient.as_ref().map(|g| g.stops.as_slice()),
defs,
scale_x,
scale_y,
);
if stops.is_empty() {
return None;
}
Some(GlyphRadialGradient {
cx,
cy,
r,
fx,
fy,
units,
transform,
spread,
stops,
})
}
fn gradient_reference<'a>(
element: &'a SvgElement,
defs: &'a HashMap<String, SvgElement>,
) -> Option<&'a SvgElement> {
element
.attrs
.get("href")
.or_else(|| element.attrs.get("xlink:href"))
.and_then(|value| value.strip_prefix('#'))
.and_then(|reference| defs.get(reference))
}
fn gradient_number(element: &SvgElement, inherited: Option<&SvgElement>, key: &str) -> Option<f32> {
element
.attrs
.get(key)
.and_then(|value| parse_number_or_percent(value))
.or_else(|| {
inherited.and_then(|inherited| {
inherited
.attrs
.get(key)
.and_then(|value| parse_number_or_percent(value))
})
})
}
fn gradient_spread(
element: &SvgElement,
inherited: Option<&SvgElement>,
inherited_value: Option<GlyphGradientSpread>,
) -> GlyphGradientSpread {
let value = element
.attrs
.get("spreadmethod")
.or_else(|| inherited.and_then(|gradient| gradient.attrs.get("spreadmethod")));
match value.map(|value| value.trim().to_ascii_lowercase()) {
Some(value) if value == "reflect" => GlyphGradientSpread::Reflect,
Some(value) if value == "repeat" => GlyphGradientSpread::Repeat,
_ => inherited_value.unwrap_or(GlyphGradientSpread::Pad),
}
}
fn gradient_units(
element: &SvgElement,
inherited: Option<&SvgElement>,
inherited_value: Option<GlyphGradientUnits>,
) -> GlyphGradientUnits {
let value = element
.attrs
.get("gradientunits")
.or_else(|| inherited.and_then(|gradient| gradient.attrs.get("gradientunits")));
match value.map(|value| value.trim().to_ascii_lowercase()) {
Some(value) if value == "userspaceonuse" => GlyphGradientUnits::UserSpaceOnUse,
_ => inherited_value.unwrap_or(GlyphGradientUnits::ObjectBoundingBox),
}
}
fn gradient_transform(
element: &SvgElement,
inherited: Option<&SvgElement>,
inherited_value: Option<[f32; 6]>,
scale_x: f32,
scale_y: f32,
) -> [f32; 6] {
let transform = element
.attrs
.get("gradienttransform")
.or_else(|| inherited.and_then(|gradient| gradient.attrs.get("gradienttransform")));
if let Some(transform) = transform {
let transform = parse_transform(Some(transform), scale_x, scale_y);
[
transform.a,
transform.b,
transform.c,
transform.d,
transform.e,
transform.f,
]
} else {
inherited_value.unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])
}
}
fn gradient_stops(
element: &SvgElement,
inherited: Option<&SvgElement>,
inherited_stops: Option<&[GlyphGradientStop]>,
_defs: &HashMap<String, SvgElement>,
_scale_x: f32,
_scale_y: f32,
) -> Vec<GlyphGradientStop> {
let mut stops = collect_gradient_stops(element);
if stops.is_empty() {
if let Some(inherited) = inherited {
stops = collect_gradient_stops(inherited);
if stops.is_empty() {
stops = gradient_stops(
inherited,
gradient_reference(inherited, _defs),
inherited_stops,
_defs,
_scale_x,
_scale_y,
);
}
}
}
if stops.is_empty() {
inherited_stops.unwrap_or(&[]).to_vec()
} else {
stops
}
}
fn collect_gradient_stops(element: &SvgElement) -> Vec<GlyphGradientStop> {
let mut stops = Vec::new();
for child in &element.children {
let SvgNode::Element(stop) = child else {
continue;
};
if stop.name != "stop" {
continue;
}
let style = stop.attrs.get("style").map(|value| parse_style(value));
let offset = style
.as_ref()
.and_then(|style| style.get("offset"))
.or_else(|| stop.attrs.get("offset"))
.and_then(|value| parse_number_or_percent(value))
.unwrap_or(0.0)
.clamp(0.0, 1.0);
let color = style
.as_ref()
.and_then(|style| style.get("stop-color"))
.or_else(|| stop.attrs.get("stop-color"))
.and_then(|value| parse_basic_paint(value));
let opacity = style
.as_ref()
.and_then(|style| style.get("stop-opacity"))
.or_else(|| stop.attrs.get("stop-opacity"))
.and_then(|value| parse_number_or_percent(value))
.unwrap_or(1.0)
.clamp(0.0, 1.0);
let Some(color) = color else {
continue;
};
let argb = match color {
GlyphPaint::Solid(argb) => apply_alpha(argb, opacity),
GlyphPaint::CurrentColor => apply_alpha(0xff00_0000, opacity),
GlyphPaint::LinearGradient(_) | GlyphPaint::RadialGradient(_) => continue,
};
stops.push(GlyphGradientStop {
offset,
color: argb,
});
}
stops
}
fn apply_alpha(color: u32, opacity: f32) -> u32 {
let alpha = (((color >> 24) & 0xff) as f32 * opacity).round().clamp(0.0, 255.0) as u32;
(color & 0x00ff_ffff) | (alpha << 24)
}
fn resolve_paint(
style_value: Option<&String>,
attr_value: Option<&String>,
inherited: Option<&GlyphPaint>,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<GlyphPaint> {
let Some(value) = style_value.or(attr_value) else {
return inherited.cloned();
};
let value = value.trim();
if value.eq_ignore_ascii_case("none") {
None
} else {
parse_fill(value, defs, scale_x, scale_y)
}
}
fn resolve_clip_path(
style_value: Option<&String>,
attr_value: Option<&String>,
inherited: Option<&ClipPathSpec>,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<ClipPathSpec> {
let Some(value) = style_value.or(attr_value) else {
return inherited.cloned();
};
let value = value.trim();
if value.eq_ignore_ascii_case("none") {
return None;
}
let reference = parse_url_reference(value)?;
let element = defs.get(reference)?;
parse_clip_path_spec(element, defs, scale_x, scale_y).or_else(|| inherited.cloned())
}
fn resolve_mask_path(
style_value: Option<&String>,
attr_value: Option<&String>,
inherited: Option<&ClipPathSpec>,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<ClipPathSpec> {
let Some(value) = style_value.or(attr_value) else {
return inherited.cloned();
};
let value = value.trim();
if value.eq_ignore_ascii_case("none") {
return None;
}
let reference = parse_url_reference(value)?;
let element = defs.get(reference)?;
parse_mask_spec(element, defs, scale_x, scale_y).or_else(|| inherited.cloned())
}
fn parse_clip_path_spec(
element: &SvgElement,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<ClipPathSpec> {
if element.name != "clippath" {
return None;
}
let units = match element
.attrs
.get("clippathunits")
.map(|value| value.trim().to_ascii_lowercase())
{
Some(value) if value == "objectboundingbox" => ClipPathUnits::ObjectBoundingBox,
_ => ClipPathUnits::UserSpaceOnUse,
};
let mut commands = Vec::new();
for child in &element.children {
collect_clip_commands(
child,
defs,
Transform2D::IDENTITY,
scale_x,
scale_y,
&mut commands,
);
}
if commands.is_empty() {
None
} else {
Some(ClipPathSpec { commands, units })
}
}
fn parse_mask_spec(
element: &SvgElement,
defs: &HashMap<String, SvgElement>,
scale_x: f32,
scale_y: f32,
) -> Option<ClipPathSpec> {
if element.name != "mask" {
return None;
}
let units = match element
.attrs
.get("maskcontentunits")
.or_else(|| element.attrs.get("maskunits"))
.map(|value| value.trim().to_ascii_lowercase())
{
Some(value) if value == "objectboundingbox" => ClipPathUnits::ObjectBoundingBox,
_ => ClipPathUnits::UserSpaceOnUse,
};
let mut commands = Vec::new();
for child in &element.children {
collect_clip_commands(
child,
defs,
Transform2D::IDENTITY,
scale_x,
scale_y,
&mut commands,
);
}
if commands.is_empty() {
None
} else {
Some(ClipPathSpec { commands, units })
}
}
fn collect_clip_commands(
node: &SvgNode,
defs: &HashMap<String, SvgElement>,
transform: Transform2D,
scale_x: f32,
scale_y: f32,
out: &mut Vec<Command>,
) {
let SvgNode::Element(element) = node else {
return;
};
let style = element.attrs.get("style").map(|value| parse_style(value));
let next_transform = transform.multiply(parse_transform(
style
.as_ref()
.and_then(|style| style.get("transform"))
.or_else(|| element.attrs.get("transform")),
scale_x,
scale_y,
));
match element.name.as_str() {
"g" | "svg" | "symbol" | "clippath" => {
for child in &element.children {
collect_clip_commands(child, defs, next_transform, scale_x, scale_y, out);
}
}
"use" => {
let Some(reference) = element
.attrs
.get("href")
.or_else(|| element.attrs.get("xlink:href"))
.and_then(|value| value.strip_prefix('#'))
else {
return;
};
let Some(referenced) = defs.get(reference) else {
return;
};
let translate = Transform2D::translate(
parse_attr_f32(&element.attrs, "x").unwrap_or(0.0) * scale_x,
parse_attr_f32(&element.attrs, "y").unwrap_or(0.0) * scale_y,
);
collect_clip_commands(
&SvgNode::Element(referenced.clone()),
defs,
next_transform.multiply(translate),
scale_x,
scale_y,
out,
);
}
"path" | "rect" | "circle" | "ellipse" | "line" | "polyline" | "polygon" => {
let commands = match element.name.as_str() {
"path" => element
.attrs
.get("d")
.and_then(|d| parse_path_data(d, scale_x, scale_y)),
"rect" => rect_to_commands(&element.attrs, scale_x, scale_y),
"circle" => circle_to_commands(&element.attrs, scale_x, scale_y),
"ellipse" => ellipse_to_commands(&element.attrs, scale_x, scale_y),
"line" => line_to_commands(&element.attrs, scale_x, scale_y),
"polyline" => poly_points_to_commands(&element.attrs, false, scale_x, scale_y),
"polygon" => poly_points_to_commands(&element.attrs, true, scale_x, scale_y),
_ => None,
};
if let Some(commands) = commands {
out.extend(transform_commands(&commands, next_transform));
}
}
_ => {}
}
}
fn parse_hex_color(hex: &str) -> Option<u32> {
match hex.len() {
3 => {
let mut chars = hex.chars();
let r = chars.next()?.to_digit(16)? as u8;
let g = chars.next()?.to_digit(16)? as u8;
let b = chars.next()?.to_digit(16)? as u8;
Some(
0xff00_0000
| (((r << 4) | r) as u32) << 16
| (((g << 4) | g) as u32) << 8
| ((b << 4) | b) as u32,
)
}
6 => u32::from_str_radix(hex, 16)
.ok()
.map(|rgb| 0xff00_0000 | rgb),
8 => u32::from_str_radix(hex, 16)
.ok()
.map(|rgba| (rgba << 24) | (rgba >> 8)),
_ => None,
}
}
fn parse_number_or_percent(value: &str) -> Option<f32> {
let trimmed = value.trim();
if let Some(percent) = trimmed.strip_suffix('%') {
return percent.trim().parse::<f32>().ok().map(|value| value / 100.0);
}
parse_number(trimmed)
}
fn parse_fill_rule(value: &str) -> FillRule {
if value.trim().eq_ignore_ascii_case("evenodd") {
FillRule::EvenOdd
} else {
FillRule::NonZero
}
}
fn parse_transform(transform: Option<&String>, scale_x: f32, scale_y: f32) -> Transform2D {
let Some(transform) = transform else {
return Transform2D::IDENTITY;
};
let mut current = Transform2D::IDENTITY;
let mut source = transform.as_str();
while let Some(open) = source.find('(') {
let name = source[..open].trim().to_ascii_lowercase();
let Some(close) = source[open + 1..].find(')') else {
break;
};
let values = parse_numbers(&source[open + 1..open + 1 + close]);
let next = match name.as_str() {
"translate" if !values.is_empty() => Transform2D::translate(
values[0] * scale_x,
values.get(1).copied().unwrap_or(0.0) * scale_y,
),
"scale" if !values.is_empty() => {
Transform2D::scale(values[0], values.get(1).copied().unwrap_or(values[0]))
}
"rotate" if !values.is_empty() => {
let angle = values[0].to_radians();
if values.len() >= 3 {
let cx = values[1] * scale_x;
let cy = values[2] * scale_y;
Transform2D::translate(cx, cy)
.multiply(Transform2D::rotate_radians(angle))
.multiply(Transform2D::translate(-cx, -cy))
} else {
Transform2D::rotate_radians(angle)
}
}
"skewx" if !values.is_empty() => Transform2D::skew_x_radians(values[0].to_radians()),
"skewy" if !values.is_empty() => Transform2D::skew_y_radians(values[0].to_radians()),
"matrix" if values.len() == 6 => Transform2D {
a: values[0],
b: values[1],
c: values[2],
d: values[3],
e: values[4] * scale_x,
f: values[5] * scale_y,
},
_ => Transform2D::IDENTITY,
};
current = current.multiply(next);
source = &source[open + 1 + close + 1..];
}
current
}
fn transform_commands(commands: &[Command], transform: Transform2D) -> Vec<Command> {
commands
.iter()
.map(|command| match command {
Command::MoveTo(x, y) => {
let (x, y) = transform.apply(*x, *y);
Command::MoveTo(x, y)
}
Command::Line(x, y) => {
let (x, y) = transform.apply(*x, *y);
Command::Line(x, y)
}
Command::Bezier((cx, cy), (x, y)) => {
let (cx, cy) = transform.apply(*cx, *cy);
let (x, y) = transform.apply(*x, *y);
Command::Bezier((cx, cy), (x, y))
}
Command::CubicBezier((x1, y1), (x2, y2), (x, y)) => {
let (x1, y1) = transform.apply(*x1, *y1);
let (x2, y2) = transform.apply(*x2, *y2);
let (x, y) = transform.apply(*x, *y);
Command::CubicBezier((x1, y1), (x2, y2), (x, y))
}
Command::Close => Command::Close,
})
.collect()
}
fn rect_to_commands(
attrs: &HashMap<String, String>,
scale_x: f32,
scale_y: f32,
) -> Option<Vec<Command>> {
let x = parse_attr_f32(attrs, "x").unwrap_or(0.0) * scale_x;
let y = parse_attr_f32(attrs, "y").unwrap_or(0.0) * scale_y;
let width = parse_attr_f32(attrs, "width")? * scale_x;
let height = parse_attr_f32(attrs, "height")? * scale_y;
Some(vec![
Command::MoveTo(x, y),
Command::Line(x + width, y),
Command::Line(x + width, y + height),
Command::Line(x, y + height),
Command::Close,
])
}
fn line_to_commands(
attrs: &HashMap<String, String>,
scale_x: f32,
scale_y: f32,
) -> Option<Vec<Command>> {
Some(vec![
Command::MoveTo(
parse_attr_f32(attrs, "x1")? * scale_x,
parse_attr_f32(attrs, "y1")? * scale_y,
),
Command::Line(
parse_attr_f32(attrs, "x2")? * scale_x,
parse_attr_f32(attrs, "y2")? * scale_y,
),
])
}
fn circle_to_commands(
attrs: &HashMap<String, String>,
scale_x: f32,
scale_y: f32,
) -> Option<Vec<Command>> {
let cx = parse_attr_f32(attrs, "cx")? * scale_x;
let cy = parse_attr_f32(attrs, "cy")? * scale_y;
let r = parse_attr_f32(attrs, "r")?;
ellipse_commands(cx, cy, r * scale_x, r * scale_y)
}
fn ellipse_to_commands(
attrs: &HashMap<String, String>,
scale_x: f32,
scale_y: f32,
) -> Option<Vec<Command>> {
ellipse_commands(
parse_attr_f32(attrs, "cx")? * scale_x,
parse_attr_f32(attrs, "cy")? * scale_y,
parse_attr_f32(attrs, "rx")? * scale_x,
parse_attr_f32(attrs, "ry")? * scale_y,
)
}
fn ellipse_commands(cx: f32, cy: f32, rx: f32, ry: f32) -> Option<Vec<Command>> {
if rx == 0.0 || ry == 0.0 {
return None;
}
let kappa = 0.552_284_8f32;
Some(vec![
Command::MoveTo(cx + rx, cy),
Command::CubicBezier(
(cx + rx, cy + ry * kappa),
(cx + rx * kappa, cy + ry),
(cx, cy + ry),
),
Command::CubicBezier(
(cx - rx * kappa, cy + ry),
(cx - rx, cy + ry * kappa),
(cx - rx, cy),
),
Command::CubicBezier(
(cx - rx, cy - ry * kappa),
(cx - rx * kappa, cy - ry),
(cx, cy - ry),
),
Command::CubicBezier(
(cx + rx * kappa, cy - ry),
(cx + rx, cy - ry * kappa),
(cx + rx, cy),
),
Command::Close,
])
}
fn poly_points_to_commands(
attrs: &HashMap<String, String>,
close: bool,
scale_x: f32,
scale_y: f32,
) -> Option<Vec<Command>> {
let points = attrs.get("points")?;
let numbers = parse_numbers(points);
if numbers.len() < 4 || numbers.len() % 2 != 0 {
return None;
}
let mut commands = Vec::new();
commands.push(Command::MoveTo(numbers[0] * scale_x, numbers[1] * scale_y));
let mut index = 2usize;
while index + 1 < numbers.len() {
commands.push(Command::Line(
numbers[index] * scale_x,
numbers[index + 1] * scale_y,
));
index += 2;
}
if close {
commands.push(Command::Close);
}
Some(commands)
}
fn parse_attr_f32(attrs: &HashMap<String, String>, key: &str) -> Option<f32> {
attrs.get(key).and_then(|value| parse_number(value))
}
fn parse_number(value: &str) -> Option<f32> {
let trimmed = value.trim();
let end = trimmed
.find(|ch: char| !(ch.is_ascii_digit() || matches!(ch, '.' | '-' | '+' | 'e' | 'E')))
.unwrap_or(trimmed.len());
trimmed[..end].parse::<f32>().ok()
}
fn parse_numbers(source: &str) -> Vec<f32> {
let mut numbers = Vec::new();
let mut current = String::new();
let mut prev = '\0';
for ch in source.chars() {
if ch.is_ascii_digit() || matches!(ch, '.' | 'e' | 'E') {
current.push(ch);
} else if matches!(ch, '-' | '+') {
if !current.is_empty() && prev != 'e' && prev != 'E' {
if let Ok(value) = current.parse::<f32>() {
numbers.push(value);
}
current.clear();
}
current.push(ch);
} else if !current.is_empty() {
if let Ok(value) = current.parse::<f32>() {
numbers.push(value);
}
current.clear();
}
prev = ch;
}
if !current.is_empty() {
if let Ok(value) = current.parse::<f32>() {
numbers.push(value);
}
}
numbers
}
fn parse_path_data(d: &str, scale_x: f32, scale_y: f32) -> Option<Vec<Command>> {
let tokens = tokenize_path_data(d);
let mut index = 0usize;
let mut commands = Vec::new();
let mut current = (0.0f32, 0.0f32);
let mut subpath_start = current;
let mut last_command = 'M';
while index < tokens.len() {
let command = if let PathToken::Command(command) = tokens[index] {
index += 1;
last_command = command;
command
} else {
last_command
};
match command {
'M' | 'm' => {
let absolute = command == 'M';
let mut first = true;
while let Some((x, y)) = read_pair(&tokens, &mut index) {
let target = if absolute {
(x * scale_x, y * scale_y)
} else {
(current.0 + x * scale_x, current.1 + y * scale_y)
};
if first {
commands.push(Command::MoveTo(target.0, target.1));
subpath_start = target;
first = false;
} else {
commands.push(Command::Line(target.0, target.1));
}
current = target;
if index < tokens.len() && matches!(tokens[index], PathToken::Command(_)) {
break;
}
}
}
'L' | 'l' => {
let absolute = command == 'L';
while let Some((x, y)) = read_pair(&tokens, &mut index) {
current = if absolute {
(x * scale_x, y * scale_y)
} else {
(current.0 + x * scale_x, current.1 + y * scale_y)
};
commands.push(Command::Line(current.0, current.1));
if index < tokens.len() && matches!(tokens[index], PathToken::Command(_)) {
break;
}
}
}
'H' | 'h' => {
let absolute = command == 'H';
while let Some(x) = read_number(&tokens, &mut index) {
current.0 = if absolute {
x * scale_x
} else {
current.0 + x * scale_x
};
commands.push(Command::Line(current.0, current.1));
if index < tokens.len() && matches!(tokens[index], PathToken::Command(_)) {
break;
}
}
}
'V' | 'v' => {
let absolute = command == 'V';
while let Some(y) = read_number(&tokens, &mut index) {
current.1 = if absolute {
y * scale_y
} else {
current.1 + y * scale_y
};
commands.push(Command::Line(current.0, current.1));
if index < tokens.len() && matches!(tokens[index], PathToken::Command(_)) {
break;
}
}
}
'Q' | 'q' => {
let absolute = command == 'Q';
while let Some((cx, cy)) = read_pair(&tokens, &mut index) {
let (x, y) = read_pair(&tokens, &mut index)?;
let control = if absolute {
(cx * scale_x, cy * scale_y)
} else {
(current.0 + cx * scale_x, current.1 + cy * scale_y)
};
let target = if absolute {
(x * scale_x, y * scale_y)
} else {
(current.0 + x * scale_x, current.1 + y * scale_y)
};
commands.push(Command::Bezier(control, target));
current = target;
if index < tokens.len() && matches!(tokens[index], PathToken::Command(_)) {
break;
}
}
}
'C' | 'c' => {
let absolute = command == 'C';
while let Some((x1, y1)) = read_pair(&tokens, &mut index) {
let (x2, y2) = read_pair(&tokens, &mut index)?;
let (x, y) = read_pair(&tokens, &mut index)?;
let control1 = if absolute {
(x1 * scale_x, y1 * scale_y)
} else {
(current.0 + x1 * scale_x, current.1 + y1 * scale_y)
};
let control2 = if absolute {
(x2 * scale_x, y2 * scale_y)
} else {
(current.0 + x2 * scale_x, current.1 + y2 * scale_y)
};
let target = if absolute {
(x * scale_x, y * scale_y)
} else {
(current.0 + x * scale_x, current.1 + y * scale_y)
};
commands.push(Command::CubicBezier(control1, control2, target));
current = target;
if index < tokens.len() && matches!(tokens[index], PathToken::Command(_)) {
break;
}
}
}
'A' | 'a' => {
let absolute = command == 'A';
while let Some(rx) = read_number(&tokens, &mut index) {
let ry = read_number(&tokens, &mut index)?;
let axis_rotation = read_number(&tokens, &mut index)?;
let large_arc = read_number(&tokens, &mut index)? != 0.0;
let sweep = read_number(&tokens, &mut index)? != 0.0;
let (x, y) = read_pair(&tokens, &mut index)?;
let target = if absolute {
(x * scale_x, y * scale_y)
} else {
(current.0 + x * scale_x, current.1 + y * scale_y)
};
commands.extend(arc_to_cubic_beziers(
current,
target,
rx.abs() * scale_x.abs(),
ry.abs() * scale_y.abs(),
axis_rotation,
large_arc,
sweep,
));
current = target;
if index < tokens.len() && matches!(tokens[index], PathToken::Command(_)) {
break;
}
}
}
'Z' | 'z' => {
commands.push(Command::Close);
current = subpath_start;
}
_ => return None,
}
}
Some(commands)
}
fn arc_to_cubic_beziers(
start: (f32, f32),
end: (f32, f32),
mut rx: f32,
mut ry: f32,
x_axis_rotation_degrees: f32,
large_arc: bool,
sweep: bool,
) -> Vec<Command> {
if nearly_equal_points(start, end) {
return Vec::new();
}
if rx <= f32::EPSILON || ry <= f32::EPSILON {
return vec![Command::Line(end.0, end.1)];
}
let phi = x_axis_rotation_degrees.to_radians();
let cos_phi = phi.cos();
let sin_phi = phi.sin();
let dx = (start.0 - end.0) * 0.5;
let dy = (start.1 - end.1) * 0.5;
let x1p = cos_phi * dx + sin_phi * dy;
let y1p = -sin_phi * dx + cos_phi * dy;
let mut rx_sq = rx * rx;
let mut ry_sq = ry * ry;
let x1p_sq = x1p * x1p;
let y1p_sq = y1p * y1p;
let radius_scale = x1p_sq / rx_sq + y1p_sq / ry_sq;
if radius_scale > 1.0 {
let scale = radius_scale.sqrt();
rx *= scale;
ry *= scale;
rx_sq = rx * rx;
ry_sq = ry * ry;
}
let numerator = (rx_sq * ry_sq) - (rx_sq * y1p_sq) - (ry_sq * x1p_sq);
let denominator = (rx_sq * y1p_sq) + (ry_sq * x1p_sq);
let factor = if denominator <= f32::EPSILON {
0.0
} else {
let sign = if large_arc == sweep { -1.0 } else { 1.0 };
sign * (numerator / denominator).max(0.0).sqrt()
};
let cxp = factor * (rx * y1p / ry);
let cyp = factor * (-ry * x1p / rx);
let center = (
cos_phi * cxp - sin_phi * cyp + (start.0 + end.0) * 0.5,
sin_phi * cxp + cos_phi * cyp + (start.1 + end.1) * 0.5,
);
let start_vector = ((x1p - cxp) / rx, (y1p - cyp) / ry);
let end_vector = ((-x1p - cxp) / rx, (-y1p - cyp) / ry);
let start_angle = start_vector.1.atan2(start_vector.0);
let mut delta_angle = vector_angle(start_vector, end_vector);
if !sweep && delta_angle > 0.0 {
delta_angle -= std::f32::consts::TAU;
} else if sweep && delta_angle < 0.0 {
delta_angle += std::f32::consts::TAU;
}
let segments = (delta_angle.abs() / (std::f32::consts::FRAC_PI_2)).ceil() as usize;
let segments = segments.max(1);
let step = delta_angle / segments as f32;
let mut commands = Vec::with_capacity(segments);
for index in 0..segments {
let theta1 = start_angle + step * index as f32;
let theta2 = theta1 + step;
let alpha = (4.0 / 3.0) * ((theta2 - theta1) * 0.25).tan();
let (sin1, cos1) = theta1.sin_cos();
let (sin2, cos2) = theta2.sin_cos();
let control1 = map_arc_point(
center,
rx,
ry,
cos_phi,
sin_phi,
cos1 - alpha * sin1,
sin1 + alpha * cos1,
);
let control2 = map_arc_point(
center,
rx,
ry,
cos_phi,
sin_phi,
cos2 + alpha * sin2,
sin2 - alpha * cos2,
);
let target = map_arc_point(center, rx, ry, cos_phi, sin_phi, cos2, sin2);
commands.push(Command::CubicBezier(control1, control2, target));
}
commands
}
fn map_arc_point(
center: (f32, f32),
rx: f32,
ry: f32,
cos_phi: f32,
sin_phi: f32,
x: f32,
y: f32,
) -> (f32, f32) {
(
center.0 + cos_phi * rx * x - sin_phi * ry * y,
center.1 + sin_phi * rx * x + cos_phi * ry * y,
)
}
fn vector_angle(start: (f32, f32), end: (f32, f32)) -> f32 {
let cross = start.0 * end.1 - start.1 * end.0;
let dot = start.0 * end.0 + start.1 * end.1;
cross.atan2(dot)
}
fn nearly_equal_points(left: (f32, f32), right: (f32, f32)) -> bool {
(left.0 - right.0).abs() <= 0.001 && (left.1 - right.1).abs() <= 0.001
}
#[derive(Clone, Copy)]
enum PathToken {
Command(char),
Number(f32),
}
fn tokenize_path_data(d: &str) -> Vec<PathToken> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut prev = '\0';
for ch in d.chars() {
if ch.is_ascii_alphabetic() {
flush_path_number(&mut current, &mut tokens);
tokens.push(PathToken::Command(ch));
} else if ch.is_ascii_digit() || matches!(ch, '.' | 'e' | 'E') {
current.push(ch);
} else if matches!(ch, '-' | '+') {
if !current.is_empty() && prev != 'e' && prev != 'E' {
flush_path_number(&mut current, &mut tokens);
}
current.push(ch);
} else {
flush_path_number(&mut current, &mut tokens);
}
prev = ch;
}
flush_path_number(&mut current, &mut tokens);
tokens
}
fn flush_path_number(current: &mut String, tokens: &mut Vec<PathToken>) {
if current.is_empty() {
return;
}
if let Ok(value) = current.parse::<f32>() {
tokens.push(PathToken::Number(value));
}
current.clear();
}
fn read_number(tokens: &[PathToken], index: &mut usize) -> Option<f32> {
match tokens.get(*index)? {
PathToken::Number(value) => {
*index += 1;
Some(*value)
}
PathToken::Command(_) => None,
}
}
fn read_pair(tokens: &[PathToken], index: &mut usize) -> Option<(f32, f32)> {
Some((read_number(tokens, index)?, read_number(tokens, index)?))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_svg_document_builds_nested_nodes() {
let root = parse_svg_document("<svg><g id=\"a\"><path d=\"M0 0\"/></g></svg>").unwrap();
let SvgNode::Element(document) = root else {
panic!("expected document root");
};
assert_eq!(document.children.len(), 1);
let SvgNode::Element(svg) = &document.children[0] else {
panic!("expected svg element");
};
assert_eq!(svg.name, "svg");
assert_eq!(svg.children.len(), 1);
}
#[test]
fn svg_to_path_layers_parses_path_rect_and_fill_rule() {
let document = concat!(
"<svg>",
"<path d=\"M0 0 L10 0 L10 10 Z\" fill=\"#ff00aa\" fill-rule=\"evenodd\"/>",
"<rect x=\"2\" y=\"3\" width=\"4\" height=\"5\" style=\"fill:#00ff00\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 2);
assert!(matches!(layers[0].fill_rule, FillRule::EvenOdd));
assert!(matches!(layers[0].paint, GlyphPaint::Solid(_)));
assert!(matches!(layers[0].commands[0], Command::MoveTo(0.0, 0.0)));
assert!(matches!(layers[1].commands[0], Command::MoveTo(2.0, 3.0)));
}
#[test]
fn svg_to_path_layers_parses_circle_and_polyline() {
let document = concat!(
"<svg>",
"<circle cx=\"10\" cy=\"20\" r=\"5\" fill=\"currentColor\"/>",
"<polyline points=\"0,0 4,4 8,0\" fill=\"#000\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 2);
assert!(matches!(layers[0].commands[0], Command::MoveTo(_, _)));
assert!(matches!(layers[0].paint, GlyphPaint::CurrentColor));
assert!(matches!(layers[1].commands[1], Command::Line(8.0, 12.0)));
}
#[test]
fn svg_to_path_layers_expands_defs_and_use_with_offsets() {
let document = concat!(
"<svg>",
"<defs>",
"<g id=\"shape\"><path d=\"M0 0 L4 0 L4 4 Z\" fill=\"#123456\"/></g>",
"</defs>",
"<use href=\"#shape\" x=\"10\" y=\"20\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].paint, GlyphPaint::Solid(_)));
assert!(matches!(layers[0].commands[0], Command::MoveTo(20.0, 60.0)));
assert!(matches!(layers[0].commands[1], Command::Line(28.0, 60.0)));
}
#[test]
fn svg_to_path_layers_allows_use_to_override_fill() {
let document = concat!(
"<svg>",
"<defs><path id=\"dot\" d=\"M1 1 L2 1\" fill=\"#123456\"/></defs>",
"<use xlink:href=\"#dot\" fill=\"currentColor\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].paint, GlyphPaint::CurrentColor));
}
#[test]
fn svg_to_path_layers_applies_parent_translate_transform() {
let document = concat!(
"<svg>",
"<g transform=\"translate(10,20)\">",
"<path d=\"M1 2 L3 4\"/>",
"</g>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(22.0, 66.0)));
assert!(matches!(layers[0].commands[1], Command::Line(26.0, 72.0)));
}
#[test]
fn svg_to_path_layers_applies_element_scale_transform() {
let document = "<svg><path d=\"M1 2 L3 4\" transform=\"scale(2,3)\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(2.0, 6.0)));
assert!(matches!(layers[0].commands[1], Command::Line(6.0, 12.0)));
}
#[test]
fn svg_to_path_layers_applies_rotate_transform() {
let document = "<svg><path d=\"M1 0 L3 0\" transform=\"rotate(90)\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(x, y) if x.abs() < 0.01 && (y - 1.0).abs() < 0.01));
assert!(matches!(layers[0].commands[1], Command::Line(x, y) if x.abs() < 0.01 && (y - 3.0).abs() < 0.01));
}
#[test]
fn svg_to_path_layers_applies_rotate_about_center_transform() {
let document = "<svg><path d=\"M2 1 L4 1\" transform=\"rotate(90 2 1)\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(x, y) if (x - 2.0).abs() < 0.01 && (y - 1.0).abs() < 0.01));
assert!(matches!(layers[0].commands[1], Command::Line(x, y) if (x - 2.0).abs() < 0.01 && (y - 3.0).abs() < 0.01));
}
#[test]
fn svg_to_path_layers_applies_skewx_transform() {
let document = "<svg><path d=\"M0 1 L0 3\" transform=\"skewX(45)\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(x, y) if (x - 1.0).abs() < 0.01 && (y - 1.0).abs() < 0.01));
assert!(matches!(layers[0].commands[1], Command::Line(x, y) if (x - 3.0).abs() < 0.01 && (y - 3.0).abs() < 0.01));
}
#[test]
fn svg_to_path_layers_applies_skewy_transform() {
let document = "<svg><path d=\"M1 0 L3 0\" transform=\"skewY(45)\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(x, y) if (x - 1.0).abs() < 0.01 && (y - 1.0).abs() < 0.01));
assert!(matches!(layers[0].commands[1], Command::Line(x, y) if (x - 3.0).abs() < 0.01 && (y - 3.0).abs() < 0.01));
}
#[test]
fn svg_to_path_layers_applies_use_and_transform_together() {
let document = concat!(
"<svg>",
"<defs><path id=\"dot\" d=\"M1 1 L2 1\"/></defs>",
"<g transform=\"translate(5,6)\"><use href=\"#dot\" x=\"7\" y=\"8\"/></g>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(26.0, 45.0)));
assert!(matches!(layers[0].commands[1], Command::Line(28.0, 45.0)));
}
#[test]
fn svg_to_path_layers_emits_fill_and_stroke_layers() {
let document = concat!(
"<svg>",
"<rect x=\"1\" y=\"2\" width=\"3\" height=\"4\" fill=\"#123456\" stroke=\"#abcdef\" stroke-width=\"2\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 2);
assert!(matches!(layers[0].paint, GlyphPaint::Solid(_)));
assert!(matches!(layers[1].paint, GlyphPaint::Solid(_)));
assert_eq!(layers[1].stroke_width, 6.0);
}
#[test]
fn svg_to_path_layers_keeps_line_as_stroke_only() {
let document = "<svg><line x1=\"1\" y1=\"2\" x2=\"3\" y2=\"4\" stroke=\"#123456\" stroke-width=\"2\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].paint_mode, crate::commands::PathPaintMode::Stroke));
assert_eq!(layers[0].stroke_width, 2.0);
}
#[test]
fn svg_to_path_layers_resolves_linear_gradient_fill() {
let document = concat!(
"<svg>",
"<defs>",
"<linearGradient id=\"grad\"><stop offset=\"0%\" stop-color=\"#112233\"/><stop offset=\"100%\" stop-color=\"#445566\" stop-opacity=\"0.5\"/></linearGradient>",
"</defs>",
"<rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" fill=\"url(#grad)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 1);
match &layers[0].paint {
GlyphPaint::LinearGradient(gradient) => {
assert_eq!(gradient.units, GlyphGradientUnits::UserSpaceOnUse);
assert_eq!(gradient.x1, 0.0);
assert_eq!(gradient.y1, 0.0);
assert_eq!(gradient.x2, 20.0);
assert_eq!(gradient.y2, 0.0);
assert_eq!(gradient.stops.len(), 2);
}
other => panic!("expected linear gradient paint, got {other:?}"),
}
}
#[test]
fn svg_to_path_layers_resolves_radial_gradient_stroke() {
let document = concat!(
"<svg>",
"<defs>",
"<radialGradient id=\"grad\" cx=\"50%\" cy=\"50%\" r=\"25%\"><stop offset=\"0\" stop-color=\"#abcdef\"/></radialGradient>",
"</defs>",
"<circle cx=\"5\" cy=\"6\" r=\"4\" stroke=\"url(#grad)\" stroke-width=\"2\" fill=\"none\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 1);
match &layers[0].paint {
GlyphPaint::RadialGradient(gradient) => {
assert_eq!(gradient.units, GlyphGradientUnits::UserSpaceOnUse);
assert_eq!(gradient.cx, 10.0);
assert_eq!(gradient.cy, 18.0);
assert_eq!(gradient.r, 6.0);
}
other => panic!("expected radial gradient paint, got {other:?}"),
}
}
#[test]
fn svg_to_path_layers_keeps_gradient_units_and_transform() {
let document = concat!(
"<svg>",
"<defs>",
"<linearGradient id=\"grad\" gradientUnits=\"userSpaceOnUse\" gradientTransform=\"translate(3,4) scale(2,3)\">",
"<stop offset=\"0%\" stop-color=\"#112233\"/>",
"<stop offset=\"100%\" stop-color=\"#445566\"/>",
"</linearGradient>",
"</defs>",
"<rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" fill=\"url(#grad)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
match &layers[0].paint {
GlyphPaint::LinearGradient(gradient) => {
assert_eq!(gradient.units, GlyphGradientUnits::UserSpaceOnUse);
assert_eq!(gradient.transform, [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);
assert_eq!(gradient.x1, 6.0);
assert_eq!(gradient.y1, 12.0);
}
other => panic!("expected linear gradient paint, got {other:?}"),
}
}
#[test]
fn svg_to_path_layers_inherits_gradient_stops_via_href() {
let document = concat!(
"<svg>",
"<defs>",
"<linearGradient id=\"base\"><stop offset=\"0%\" stop-color=\"#112233\"/><stop offset=\"100%\" stop-color=\"#445566\"/></linearGradient>",
"<linearGradient id=\"derived\" href=\"#base\" x1=\"25%\" x2=\"75%\"/>",
"</defs>",
"<rect x=\"0\" y=\"0\" width=\"20\" height=\"10\" fill=\"url(#derived)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 1.0, 1.0);
match &layers[0].paint {
GlyphPaint::LinearGradient(gradient) => {
assert_eq!(gradient.stops.len(), 2);
assert_eq!(gradient.x1, 5.0);
assert_eq!(gradient.x2, 15.0);
}
other => panic!("expected inherited linear gradient paint, got {other:?}"),
}
}
#[test]
fn svg_to_path_layers_parses_absolute_arc_path() {
let document = "<svg><path d=\"M10 10 A10 10 0 0 1 20 20\" fill=\"#000\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].commands[0], Command::MoveTo(10.0, 10.0)));
assert!(
layers[0]
.commands
.iter()
.any(|command| matches!(command, Command::CubicBezier(_, _, _))),
"expected arc to expand into cubic segments"
);
}
#[test]
fn svg_to_path_layers_parses_relative_arc_path() {
let document = "<svg><path d=\"M10 10 a10 10 0 0 1 10 10\" fill=\"#000\"/></svg>";
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(
matches!(layers[0].commands.last(), Some(Command::CubicBezier(_, _, (x, y))) if (*x - 20.0).abs() < 0.01 && (*y - 20.0).abs() < 0.01),
"expected relative arc to end at the translated target"
);
}
#[test]
fn svg_requires_svg_fallback_detects_mask_pattern_and_filter() {
assert!(svg_requires_svg_fallback("<svg><mask id=\"m\"/></svg>"));
assert!(svg_requires_svg_fallback("<svg><pattern id=\"p\"/></svg>"));
assert!(svg_requires_svg_fallback("<svg><filter id=\"f\"/></svg>"));
assert!(!svg_requires_svg_fallback("<svg><path d=\"M0 0 L1 1\"/></svg>"));
}
#[test]
fn svg_to_path_layers_does_not_fallback_to_current_color_for_pattern_fill() {
let document = concat!(
"<svg>",
"<defs><pattern id=\"p\"><rect x=\"0\" y=\"0\" width=\"2\" height=\"2\"/></pattern></defs>",
"<rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" fill=\"url(#p)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert!(
layers.is_empty(),
"unsupported pattern fill should not silently become a path paint"
);
}
#[test]
fn svg_to_path_layers_does_not_fallback_to_current_color_for_pattern_stroke() {
let document = concat!(
"<svg>",
"<defs><pattern id=\"p\"><rect x=\"0\" y=\"0\" width=\"2\" height=\"2\"/></pattern></defs>",
"<rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" fill=\"none\" stroke=\"url(#p)\" stroke-width=\"2\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert!(
layers.is_empty(),
"unsupported pattern stroke should not silently become a path paint"
);
}
#[test]
fn svg_to_path_layers_resolves_simple_clip_path() {
let document = concat!(
"<svg>",
"<defs><clipPath id=\"clip\"><rect x=\"1\" y=\"2\" width=\"3\" height=\"4\"/></clipPath></defs>",
"<rect x=\"10\" y=\"20\" width=\"30\" height=\"40\" clip-path=\"url(#clip)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 1);
assert!(!layers[0].clip_commands.is_empty());
assert!(matches!(layers[0].clip_commands[0], Command::MoveTo(2.0, 6.0)));
}
#[test]
fn svg_to_path_layers_resolves_clip_path_use_reference() {
let document = concat!(
"<svg>",
"<defs>",
"<path id=\"shape\" d=\"M0 0 L1 0 L1 1 Z\"/>",
"<clipPath id=\"clip\"><use href=\"#shape\" x=\"2\" y=\"3\"/></clipPath>",
"</defs>",
"<rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" clip-path=\"url(#clip)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 2.0, 3.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].clip_commands[0], Command::MoveTo(4.0, 9.0)));
}
#[test]
fn svg_to_path_layers_resolves_simple_mask_as_clip_commands() {
let document = concat!(
"<svg>",
"<defs><mask id=\"m\"><rect x=\"2\" y=\"3\" width=\"4\" height=\"5\"/></mask></defs>",
"<rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" fill=\"#123456\" mask=\"url(#m)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(!layers[0].clip_commands.is_empty());
assert!(matches!(layers[0].clip_commands[0], Command::MoveTo(2.0, 3.0)));
}
#[test]
fn svg_to_path_layers_resolves_object_bounding_box_mask_as_clip_commands() {
let document = concat!(
"<svg>",
"<defs><mask id=\"m\" maskUnits=\"objectBoundingBox\"><rect x=\"0.25\" y=\"0.5\" width=\"0.5\" height=\"0.5\"/></mask></defs>",
"<rect x=\"10\" y=\"20\" width=\"8\" height=\"6\" fill=\"#123456\" mask=\"url(#m)\"/>",
"</svg>"
);
let layers = svg_to_path_layers(document, 1.0, 1.0);
assert_eq!(layers.len(), 1);
assert!(matches!(layers[0].clip_commands[0], Command::MoveTo(12.0, 23.0)));
}
}