use anyhow::{Context as _, Result, anyhow};
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
use std::collections::{HashMap, HashSet};
use crate::affine::attr_value;
use crate::numeric::{parse_num_list, parse_px};
type Bounds = (f64, f64, f64, f64);
const NEW_SPACE: [&str; 6] = ["svg", "symbol", "pattern", "marker", "mask", "clipPath"];
pub fn strip_canvas_clips(src: &str, dynamic: bool) -> Result<String> {
if dynamic {
return Ok(src.to_owned());
}
let Some(canvas) = canvas(src)? else {
return Ok(src.to_owned());
};
let full = full_clips(src, canvas)?;
if full.is_empty() {
return Ok(src.to_owned());
}
let mut reader = Reader::from_str(src);
let mut writer = Writer::new(Vec::new());
let mut plain = Vec::new();
loop {
match reader.read_event().context("parsing SVG")? {
Event::Eof => break,
Event::Start(elem) => {
let here = in_canvas_space(&elem, &plain)?;
let out = unclipped(&elem, here, &full)?;
plain.push(here && (plain.is_empty() || !NEW_SPACE.contains(&local(&elem))));
writer.write_event(Event::Start(out))?;
}
Event::End(elem) => {
plain.pop();
writer.write_event(Event::End(elem))?;
}
Event::Empty(elem) => {
let here = in_canvas_space(&elem, &plain)?;
writer.write_event(Event::Empty(unclipped(&elem, here, &full)?))?;
}
other => writer.write_event(other)?,
}
}
return Ok(String::from_utf8(writer.into_inner())?);
}
fn local<'a>(elem: &'a BytesStart) -> &'a str {
return elem.local_name().into_inner();
}
fn in_canvas_space(elem: &BytesStart, plain: &[bool]) -> Result<bool> {
let parent = plain.last().copied().unwrap_or(true);
return Ok(parent && attr_value(elem, "transform")?.is_none());
}
fn unclipped(
elem: &BytesStart,
plain: bool,
full: &HashSet<String>,
) -> Result<BytesStart<'static>> {
let mut out = BytesStart::new(elem.name().into_inner().to_owned());
for attr in elem.attributes() {
let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
let key = attr.key.into_inner();
let val = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)?
.into_owned();
if plain && key == "clip-path" && clip_id(&val).is_some_and(|id| full.contains(id)) {
continue;
}
out.push_attribute((key, val.as_str()));
}
return Ok(out);
}
fn clip_id(val: &str) -> Option<&str> {
let inner = val.trim().strip_prefix("url(")?.strip_suffix(')')?.trim();
let inner = inner.trim_matches(|ch| return ch == '"' || ch == '\'');
return inner.strip_prefix('#');
}
fn canvas(src: &str) -> Result<Option<Bounds>> {
let mut reader = Reader::from_str(src);
let root = loop {
match reader.read_event().context("parsing SVG")? {
Event::Eof => return Ok(None),
Event::Start(elem) | Event::Empty(elem) => break elem.into_owned(),
_ => {}
}
};
let size = match (attr_value(&root, "width")?, attr_value(&root, "height")?) {
(Some(width), Some(height)) => match (parse_px(&width), parse_px(&height)) {
(Ok(width), Ok(height)) => Some((width, height)),
_ => return Ok(None),
},
(None, None) => None,
_ => return Ok(None),
};
let Some(view_box) = attr_value(&root, "viewBox")? else {
return Ok(size.map(|(width, height)| return (0.0, 0.0, width, height)));
};
let nums = parse_num_list(&view_box);
let &[min_x, min_y, width, height] = nums.as_slice() else {
return Ok(None);
};
if let Some((view_w, view_h)) = size
&& (view_w * height - view_h * width).abs() > 1e-6 * view_w * height
{
return Ok(None);
}
return Ok(Some((min_x, min_y, min_x + width, min_y + height)));
}
fn full_clips(src: &str, canvas: Bounds) -> Result<HashSet<String>> {
let mut reader = Reader::from_str(src);
let mut rects: HashMap<String, Bounds> = HashMap::new();
let mut clips: Vec<(String, Vec<Option<Shape>>)> = Vec::new();
let mut open: Option<usize> = None;
loop {
match reader.read_event().context("parsing SVG")? {
Event::Eof => break,
Event::Start(elem) => {
if let Some(depth) = open.as_mut() {
if *depth == 0
&& let Some(clip) = clips.last_mut()
{
clip.1.push(None);
}
*depth += 1;
} else if let Some(id) = candidate_clip(&elem)? {
clips.push((id, Vec::new()));
open = Some(0);
}
}
Event::End(_) => match open {
Some(0) => open = None,
Some(depth) => open = Some(depth - 1),
None => {}
},
Event::Empty(elem) => {
if let Some(id) = attr_value(&elem, "id")?
&& let Some(bounds) = rect_bounds(&elem)?
{
rects.insert(id, bounds);
}
if open == Some(0)
&& let Some(clip) = clips.last_mut()
{
clip.1.push(shape(&elem)?);
}
}
_ => {}
}
}
let eps = 1e-6 * (canvas.2 - canvas.0).abs().max((canvas.3 - canvas.1).abs());
let covers = |bounds: Bounds| {
return bounds.0 <= canvas.0 + eps
&& bounds.1 <= canvas.1 + eps
&& bounds.2 >= canvas.2 - eps
&& bounds.3 >= canvas.3 - eps;
};
let mut full = HashSet::new();
for (id, shapes) in clips {
let [Some(shape)] = shapes.as_slice() else {
continue;
};
let bounds = match shape {
Shape::Rect(bounds) => Some(*bounds),
Shape::Use { href, dx, dy } => rects
.get(href)
.map(|rect| return (rect.0 + dx, rect.1 + dy, rect.2 + dx, rect.3 + dy)),
};
if bounds.is_some_and(covers) {
full.insert(id);
}
}
return Ok(full);
}
fn candidate_clip(elem: &BytesStart) -> Result<Option<String>> {
if local(elem) != "clipPath"
|| attr_value(elem, "transform")?.is_some()
|| attr_value(elem, "clip-path")?.is_some()
|| attr_value(elem, "clipPathUnits")?.is_some_and(|units| return units != "userSpaceOnUse")
{
return Ok(None);
}
return attr_value(elem, "id");
}
enum Shape {
Rect(Bounds),
Use {
href: String,
dx: f64,
dy: f64,
},
}
fn shape(elem: &BytesStart) -> Result<Option<Shape>> {
match local(elem) {
"rect" => return Ok(rect_bounds(elem)?.map(Shape::Rect)),
"use" if attr_value(elem, "transform")?.is_none() => {
let href = match attr_value(elem, "href")? {
Some(href) => Some(href),
None => attr_value(elem, "xlink:href")?,
};
let Some(href) =
href.and_then(|href| return href.trim().strip_prefix('#').map(str::to_owned))
else {
return Ok(None);
};
let (Some(dx), Some(dy)) = (length(elem, "x")?, length(elem, "y")?) else {
return Ok(None);
};
return Ok(Some(Shape::Use { href, dx, dy }));
}
_ => return Ok(None),
}
}
fn rect_bounds(elem: &BytesStart) -> Result<Option<Bounds>> {
if local(elem) != "rect" || attr_value(elem, "transform")?.is_some() {
return Ok(None);
}
for key in ["rx", "ry"] {
if length(elem, key)?.is_none_or(|radius| return radius != 0.0) {
return Ok(None);
}
}
let (Some(x), Some(y)) = (length(elem, "x")?, length(elem, "y")?) else {
return Ok(None);
};
let (Some(Some(width)), Some(Some(height))) = (
attr_value(elem, "width")?.map(|val| return parse_px(&val).ok()),
attr_value(elem, "height")?.map(|val| return parse_px(&val).ok()),
) else {
return Ok(None);
};
return Ok(Some((x, y, x + width, y + height)));
}
fn length(elem: &BytesStart, key: &str) -> Result<Option<f64>> {
match attr_value(elem, key)? {
None => return Ok(Some(0.0)),
Some(val) => return Ok(parse_px(&val).ok()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_artboard_clip_is_dropped() {
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="20px" height="10px" viewBox="-5 0 20 10"><defs><rect id="r" x="-5" y="0" width="20" height="10"/></defs><clipPath id="c"><use xlink:href="#r" overflow="visible"/></clipPath><g><path clip-path="url(#c)" d="M0 0h5"/></g></svg>"##;
let out = strip_canvas_clips(src, false).unwrap();
assert!(!out.contains("clip-path="), "{out}");
}
#[test]
fn a_smaller_clip_is_kept() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"><clipPath id="c"><rect width="19" height="10"/></clipPath><path clip-path="url(#c)" d="M0 0h5"/></svg>"#;
let out = strip_canvas_clips(src, false).unwrap();
assert_eq!(out, src);
}
#[test]
fn a_moved_element_keeps_its_clip() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"><clipPath id="c"><rect width="20" height="10"/></clipPath><g transform="translate(5 0)"><path clip-path="url(#c)" d="M0 0h5"/></g></svg>"#;
let out = strip_canvas_clips(src, false).unwrap();
assert_eq!(out, src);
}
#[test]
fn a_wider_viewport_keeps_the_clip() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="10" viewBox="0 0 20 10"><clipPath id="c"><rect width="20" height="10"/></clipPath><path clip-path="url(#c)" d="M0 0h5"/></svg>"#;
let out = strip_canvas_clips(src, false).unwrap();
assert_eq!(out, src);
}
#[test]
fn a_clip_of_two_shapes_is_kept() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 10"><clipPath id="c"><rect width="20" height="10"/><rect width="1" height="1"/></clipPath><path clip-path="url(#c)" d="M0 0h5"/></svg>"#;
let out = strip_canvas_clips(src, false).unwrap();
assert_eq!(out, src);
}
}