use anyhow::{Context, Result, anyhow};
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
pub fn strip_dead_root_id(src: &str, dynamic: bool) -> Result<String> {
if dynamic {
return Ok(src.to_string());
}
let Some(id) = root_id(src)? else {
return Ok(src.to_string());
};
if mentions(src, &id) > 1 {
return Ok(src.to_string());
}
let mut reader = Reader::from_str(src);
let mut writer = Writer::new(Vec::new());
let mut root_seen = false;
loop {
let event = reader.read_event().context("parsing SVG")?;
let is_root = !root_seen && matches!(event, Event::Start(_) | Event::Empty(_));
root_seen |= is_root;
match event {
Event::Eof => break,
Event::Start(e) if is_root => writer.write_event(Event::Start(without_id(&e)?))?,
Event::Empty(e) if is_root => writer.write_event(Event::Empty(without_id(&e)?))?,
other => writer.write_event(other)?,
}
}
Ok(String::from_utf8(writer.into_inner())?)
}
fn root_id(src: &str) -> Result<Option<String>> {
let mut reader = Reader::from_str(src);
loop {
let e = match reader.read_event().context("parsing SVG")? {
Event::Eof => return Ok(None),
Event::Start(e) | Event::Empty(e) => e,
_ => continue,
};
for attr in e.attributes() {
let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
if attr.key.into_inner() == "id" {
return Ok(Some(
attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)?
.into_owned(),
));
}
}
return Ok(None);
}
}
fn mentions(src: &str, id: &str) -> usize {
let mut count = 0;
let mut rest = src;
while let Some(at) = rest.find(id) {
let before = rest[..at].chars().next_back();
let after = &rest[at + id.len()..];
if !is_name_char(before) && !is_name_char(after.chars().next()) {
count += 1;
}
rest = after;
}
count
}
fn is_name_char(c: Option<char>) -> bool {
c.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_')
}
fn without_id(e: &BytesStart) -> Result<BytesStart<'static>> {
let mut out = BytesStart::new(e.name().into_inner().to_string());
for attr in e.attributes() {
let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
let key = attr.key.into_inner();
if key == "id" {
continue;
}
let val = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)?
.into_owned();
out.push_attribute((key, val.as_str()));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_dead_root_id_goes() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" id="svg2423"><path d="M0 0"/></svg>"#;
let out = strip_dead_root_id(src, false).unwrap();
assert!(!out.contains("id="), "{out}");
assert!(out.contains(r#"d="M0 0""#), "{out}");
}
#[test]
fn an_id_below_the_root_stays() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" id="a"><path id="b" d="M0 0"/></svg>"#;
let out = strip_dead_root_id(src, false).unwrap();
assert!(!out.contains(r#"id="a""#), "{out}");
assert!(out.contains(r#"id="b""#), "{out}");
}
#[test]
fn a_used_root_id_stays() {
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" id="a"><use href="#a"/></svg>"##;
let out = strip_dead_root_id(src, false).unwrap();
assert!(out.contains(r#"id="a""#), "{out}");
}
#[test]
fn a_dynamic_document_keeps_its_root_id() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" id="a"><path d="M0 0"/></svg>"#;
let out = strip_dead_root_id(src, true).unwrap();
assert!(out.contains(r#"id="a""#), "{out}");
}
#[test]
fn a_longer_name_is_no_mention() {
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" id="a"><use href="#ab"/></svg>"##;
let out = strip_dead_root_id(src, false).unwrap();
assert!(!out.contains(r#"id="a""#), "{out}");
}
#[test]
fn a_dot_ends_a_name() {
let src =
r#"<svg xmlns="http://www.w3.org/2000/svg" id="a"><animate begin="a.click"/></svg>"#;
let out = strip_dead_root_id(src, false).unwrap();
assert!(out.contains(r#"id="a""#), "{out}");
}
#[test]
fn a_document_without_a_root_id_is_untouched() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0"/></svg>"#;
assert_eq!(strip_dead_root_id(src, false).unwrap(), src);
}
}