svgy 0.7.0

Convert an SVG to optimized icons for all platforms
// src/svg_namespace.rs

//! Drop namespace declarations that nothing uses.
//!
//! Editors leave declarations like `xmlns:svg` behind, and the optimizer copies them onto every
//! element it writes. A prefix that no element or attribute name uses carries no meaning, so the
//! declaration goes. The default `xmlns` always stays: it is what makes the document an SVG.

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::HashSet;

/// Remove every `xmlns:<prefix>` whose prefix is unused in `src`.
pub fn strip_unused(src: &str) -> Result<String> {
	let used = used_prefixes(src)?;
	let mut reader = Reader::from_str(src);
	let mut writer = Writer::new(Vec::new());

	loop {
		match reader.read_event().context("parsing SVG")? {
			Event::Eof => break,
			Event::Start(element) => {
				writer.write_event(Event::Start(without_unused(&element, &used)?))?;
			}
			Event::Empty(element) => {
				writer.write_event(Event::Empty(without_unused(&element, &used)?))?;
			}
			other => writer.write_event(other)?,
		}
	}

	return Ok(String::from_utf8(writer.into_inner())?);
}

/// Prefixes that an element or attribute name spells out, `xlink:href` -> `xlink`.
fn used_prefixes(src: &str) -> Result<HashSet<String>> {
	let mut reader = Reader::from_str(src);
	let mut used = HashSet::new();

	loop {
		let element = match reader.read_event().context("parsing SVG")? {
			Event::Eof => break,
			Event::Start(element) | Event::Empty(element) => element,
			_ => continue,
		};
		if let Some(prefix) = prefix_of(element.name().into_inner()) {
			used.insert(prefix.to_owned());
		}
		for attr in element.attributes() {
			let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
			let key = attr.key.into_inner();
			// A declaration is not a use of the prefix it declares.
			if key.starts_with("xmlns:") {
				continue;
			}
			if let Some(prefix) = prefix_of(key) {
				used.insert(prefix.to_owned());
			}
		}
	}
	return Ok(used);
}

/// The prefix of a qualified name, `svg:path` -> `svg`.
fn prefix_of(name: &str) -> Option<&str> {
	return name.split_once(':').map(|(prefix, _)| return prefix);
}

/// Copy of the element without unused namespaces.
fn without_unused(element: &BytesStart, used: &HashSet<String>) -> Result<BytesStart<'static>> {
	let mut out = BytesStart::new(element.name().into_inner().to_owned());

	for attr in element.attributes() {
		let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
		let key = attr.key.into_inner();
		if let Some(prefix) = key.strip_prefix("xmlns:")
			&& !used.contains(prefix)
		{
			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 an_unused_declaration_goes() {
		let src = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><path xmlns:svg="http://www.w3.org/2000/svg" d="M0 0"/></svg>"#;
		let out = strip_unused(src).unwrap();
		assert!(!out.contains("xmlns:svg"), "{out}");
		assert!(
			out.contains(r#"xmlns="http://www.w3.org/2000/svg""#),
			"{out}"
		);
		assert!(out.contains(r#"d="M0 0""#), "{out}");
	}

	#[test]
	fn a_used_declaration_stays() {
		let src = r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="#a"/></svg>"##;
		let out = strip_unused(src).unwrap();
		assert!(out.contains("xmlns:xlink"), "{out}");
	}

	/// A prefix is also used when it qualifies an element name.
	#[test]
	fn a_prefixed_element_keeps_its_declaration() {
		let src = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg"><svg:path d="M0 0"/></svg>"#;
		let out = strip_unused(src).unwrap();
		assert!(out.contains("xmlns:svg"), "{out}");
	}
}