use anyhow::{Context as _, 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_owned());
}
let Some(id) = root_id(src)? else {
return Ok(src.to_owned());
};
if is_named(src, &id)? {
return Ok(src.to_owned());
}
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(tag) if is_root => writer.write_event(Event::Start(without_id(&tag)?))?,
Event::Empty(tag) if is_root => writer.write_event(Event::Empty(without_id(&tag)?))?,
other => writer.write_event(other)?,
}
}
return Ok(String::from_utf8(writer.into_inner())?);
}
fn root_id(src: &str) -> Result<Option<String>> {
let mut reader = Reader::from_str(src);
loop {
let tag = match reader.read_event().context("parsing SVG")? {
Event::Eof => return Ok(None),
Event::Start(tag) | Event::Empty(tag) => tag,
_ => continue,
};
for attr in tag.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);
}
}
pub fn is_named(src: &str, id: &str) -> Result<bool> {
let mut reader = Reader::from_str(src);
loop {
match reader.read_event().context("parsing SVG")? {
Event::Eof => return Ok(false),
Event::Start(tag) | Event::Empty(tag) => {
for attr in tag.attributes() {
let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
let key = attr.key.into_inner();
if !can_point(key) {
continue;
}
let val = attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)?;
if mentions(&val, id) {
return Ok(true);
}
}
}
Event::Text(text) if mentions(&text, id) => return Ok(true),
Event::CData(text) if mentions(&text, id) => return Ok(true),
_ => {}
}
}
}
fn can_point(key: &str) -> bool {
match key.split_once(':') {
Some((prefix, _)) => return prefix == "xlink",
None => return key != "id" && key != "xmlns",
}
}
fn mentions(text: &str, id: &str) -> bool {
let mut rest = text;
while let Some((head, after)) = rest.split_once(id) {
let before = head.chars().next_back();
if !is_name_char(before) && !is_name_char(after.chars().next()) {
return true;
}
rest = after;
}
return false;
}
fn is_name_char(chr: Option<char>) -> bool {
return chr.is_some_and(|chr| return chr.is_alphanumeric() || chr == '-' || chr == '_');
}
fn without_id(tag: &BytesStart) -> Result<BytesStart<'static>> {
let mut out = BytesStart::new(tag.name().into_inner().to_owned());
for attr in tag.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()));
}
return 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);
}
#[test]
fn an_editor_attribute_is_no_use() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" id="dev" sodipodi:docname="dev.svg" inkscape:current-layer="dev"><path d="M0 0"/></svg>"#;
let out = strip_dead_root_id(src, false).unwrap();
assert!(!out.contains(r#"id="dev""#), "{out}");
}
#[test]
fn a_namespace_is_no_use() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" id="svg"><path d="M0 0"/></svg>"#;
let out = strip_dead_root_id(src, false).unwrap();
assert!(!out.contains(r#"id="svg""#), "{out}");
}
#[test]
fn an_xlink_href_is_a_use() {
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="a"><use xlink:href="#a"/></svg>"##;
let out = strip_dead_root_id(src, false).unwrap();
assert!(out.contains(r#"id="a""#), "{out}");
}
}