use anyhow::{Context, Result, anyhow, bail};
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
use std::collections::HashSet;
use crate::affine;
use crate::cli::Target;
use crate::numeric::{fmt_num, parse_num_list, parse_px};
struct State {
seen_root: bool,
scale: f64,
tx: f64,
ty: f64,
new_viewbox: String,
add_viewbox: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Canvas {
Padded,
Tight,
}
pub fn transform_svg(src: &str, target: Target, canvas: Canvas) -> Result<String> {
let mut reader = Reader::from_str(src);
let mut writer = Writer::new(Vec::new());
let mut state = State {
seen_root: false,
scale: 1.0,
tx: 0.0,
ty: 0.0,
new_viewbox: String::new(),
add_viewbox: false,
};
let templates = use_targets(src)?;
let mut template = 0usize;
loop {
match reader.read_event().context("parsing SVG")? {
Event::Eof => break,
Event::Start(e) => {
if template > 0 {
template += 1;
} else if state.seen_root && is_template(&e, &templates)? {
template = 1;
}
let out = process_element(&e, &mut state, target, canvas, template == 0)?;
writer.write_event(Event::Start(out))?;
}
Event::End(e) => {
template = template.saturating_sub(1);
writer.write_event(Event::End(e))?;
}
Event::Empty(e) => {
let inside = template > 0 || (state.seen_root && is_template(&e, &templates)?);
let out = process_element(&e, &mut state, target, canvas, !inside)?;
writer.write_event(Event::Empty(out))?;
}
other => writer.write_event(other)?,
}
}
Ok(String::from_utf8(writer.into_inner())?)
}
fn use_targets(src: &str) -> Result<HashSet<String>> {
let mut reader = Reader::from_str(src);
let mut ids = HashSet::new();
loop {
let e = match reader.read_event().context("parsing SVG")? {
Event::Eof => break,
Event::Start(e) | Event::Empty(e) => e,
_ => continue,
};
if e.local_name().into_inner() != "use" {
continue;
}
for key in ["href", "xlink:href"] {
if let Some(href) = attr_value(&e, key)?
&& let Some(id) = href.trim().strip_prefix('#')
{
ids.insert(id.to_string());
}
}
}
Ok(ids)
}
fn is_template(e: &BytesStart, templates: &HashSet<String>) -> Result<bool> {
if e.local_name().into_inner() == "symbol" {
return Ok(true);
}
Ok(attr_value(e, "id")?.is_some_and(|id| templates.contains(&id)))
}
fn process_element(
e: &BytesStart,
state: &mut State,
target: Target,
canvas: Canvas,
translate: bool,
) -> Result<BytesStart<'static>> {
let local = e.local_name().into_inner().to_string();
let is_root = !state.seen_root;
if is_root {
state.seen_root = true;
if local != "svg" {
bail!("root element is <{local}>, expected <svg>");
}
let (min_x, min_y, w, h, has_vb) = read_source_box(e)?;
state.scale = compute_scale(target, w, h);
let s = state.scale;
let (want_w, want_h) = target.canvas();
let (vw, tx) = place_axis(min_x * s, w * s, want_w, canvas);
let (vh, ty) = place_axis(min_y * s, h * s, want_h, canvas);
state.tx = tx;
state.ty = ty;
state.new_viewbox = format!("0 0 {} {}", fmt_num(vw), fmt_num(vh));
state.add_viewbox = !has_vb;
}
let s = state.scale;
let (tx, ty) = if translate {
(state.tx, state.ty)
} else {
(0.0, 0.0)
};
let is_grad = matches!(
local.as_str(),
"linearGradient" | "radialGradient" | "pattern"
);
let do_scale = if is_grad {
let key = if local == "pattern" {
"patternUnits"
} else {
"gradientUnits"
};
attr_value(e, key)?.as_deref() == Some("userSpaceOnUse")
} else {
true
};
let name = e.name().into_inner().to_string();
let mut out = BytesStart::new(name);
let mut seen_keys = Vec::new();
for attr in e.attributes() {
let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
let key = attr.key.into_inner().to_string();
let val = attr
.normalized_value(quick_xml::XmlVersion::Implicit1_0)?
.into_owned();
if is_root && (key == "width" || key == "height") {
continue; }
if is_root && key == "viewBox" {
out.push_attribute(("viewBox", state.new_viewbox.as_str()));
continue;
}
let new_val = if do_scale {
affine::attr(&key, &val, s, tx, ty)
} else {
val
};
seen_keys.push(key.clone());
out.push_attribute((key.as_str(), new_val.as_str()));
}
if do_scale && !is_root && (tx != 0.0 || ty != 0.0) {
for key in affine::implicit_origins(&local) {
if !seen_keys.iter().any(|k| k == key) {
let val = affine::origin_value(key, tx, ty);
out.push_attribute((*key, val.as_str()));
}
}
}
if is_root && state.add_viewbox {
out.push_attribute(("viewBox", state.new_viewbox.as_str()));
}
Ok(out)
}
fn read_source_box(e: &BytesStart) -> Result<(f64, f64, f64, f64, bool)> {
if let Some(vb) = attr_value(e, "viewBox")? {
let nums = parse_num_list(&vb);
if nums.len() != 4 {
bail!("viewBox must have 4 numbers, got {}", nums.len());
}
return Ok((nums[0], nums[1], nums[2], nums[3], true));
}
match (attr_value(e, "width")?, attr_value(e, "height")?) {
(Some(w), Some(h)) => {
let w = parse_px(&w).context("root width")?;
let h = parse_px(&h).context("root height")?;
Ok((0.0, 0.0, w, h, false))
}
_ => bail!("root <svg> has neither viewBox nor numeric width/height; cannot resize"),
}
}
const EDGE_EPS: f64 = 1e-6;
fn place_axis(start: f64, extent: f64, want: Option<f64>, canvas: Canvas) -> (f64, f64) {
let mut len = (extent - EDGE_EPS).ceil().max(1.0);
if canvas == Canvas::Padded
&& let Some(want) = want
{
len = len.max(want);
}
let centered = (len - extent) / 2.0 - start;
let (lo, hi) = (-start, len - extent - start);
let rounded = centered.round();
if rounded >= lo - EDGE_EPS && rounded <= hi + EDGE_EPS {
return (len, rounded);
}
(len, centered)
}
fn compute_scale(target: Target, w0: f64, h0: f64) -> f64 {
match target {
Target::Square(n) => f64::from(n) / w0.max(h0),
Target::Width(w) => f64::from(w) / w0,
Target::Height(h) => f64::from(h) / h0,
Target::Both(w, h) => (f64::from(w) / w0).min(f64::from(h) / h0),
}
}
fn attr_value(e: &BytesStart, key: &str) -> Result<Option<String>> {
for attr in e.attributes() {
let attr = attr.map_err(|err| anyhow!("attribute: {err}"))?;
if attr.key.as_ref() == key {
return Ok(Some(
attr.normalized_value(quick_xml::XmlVersion::Implicit1_0)?
.into_owned(),
));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn root_viewbox_rewritten_no_wh() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><rect x="1" y="2" width="4" height="6"/></svg>"#;
let out = transform_svg(src, Target::Square(48), Canvas::Padded).unwrap();
assert!(out.contains(r#"viewBox="0 0 48 48""#));
assert!(!out.contains("width=\"24\""));
assert!(!out.contains("height=\"24\""));
assert!(out.contains(r#"x="2""#));
assert!(out.contains(r#"width="8""#));
assert!(!out.contains("transform"));
}
#[test]
fn derives_viewbox_when_absent() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="20"><rect x="1" y="1" width="2" height="2"/></svg>"#;
let out = transform_svg(src, Target::Width(20), Canvas::Padded).unwrap();
assert!(out.contains(r#"viewBox="0 0 20 40""#));
assert!(!out.contains("width=\"10\""));
}
#[test]
fn viewbox_is_rounded_up_to_whole_units() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" width="620" height="720"><rect x="1" y="1" width="2" height="2"/></svg>"#;
let out = transform_svg(src, Target::Square(1024), Canvas::Tight).unwrap();
assert!(out.contains(r#"viewBox="0 0 882 1024""#), "{out}");
}
#[test]
fn padded_canvas_centers_the_artwork() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" width="620" height="720"><rect x="1" y="1" width="2" height="2"/></svg>"#;
let out = transform_svg(src, Target::Square(1024), Canvas::Padded).unwrap();
assert!(out.contains(r#"viewBox="0 0 1024 1024""#), "{out}");
assert!(out.contains(r#"x="72.422222""#), "{out}");
assert!(out.contains(r#"y="1.422222""#), "{out}");
}
#[test]
fn a_shifted_viewbox_is_moved_to_the_origin() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 10 10 10"><rect x="10" y="10" width="10" height="10"/></svg>"#;
let out = transform_svg(src, Target::Square(20), Canvas::Padded).unwrap();
assert!(out.contains(r#"viewBox="0 0 20 20""#), "{out}");
assert!(out.contains(r#"x="0""#), "{out}");
assert!(out.contains(r#"width="20""#), "{out}");
}
#[test]
fn place_axis_pads_only_when_asked() {
assert_eq!(place_axis(0.0, 881.777, None, Canvas::Padded), (882.0, 0.0));
assert_eq!(
place_axis(0.0, 1024.0, Some(1024.0), Canvas::Padded),
(1024.0, 0.0)
);
assert_eq!(
place_axis(0.0, 881.777, Some(1024.0), Canvas::Tight),
(882.0, 0.0)
);
assert_eq!(
place_axis(0.0, 881.777, Some(1024.0), Canvas::Padded),
(1024.0, 71.0)
);
assert_eq!(
place_axis(10.0, 20.0, Some(20.0), Canvas::Padded),
(20.0, -10.0)
);
}
#[test]
fn a_use_template_is_not_moved_twice() {
let src = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><defs><path id="a" d="M0 0h10"/></defs><use href="#a"/></svg>"##;
let out = transform_svg(src, Target::Square(200), Canvas::Padded).unwrap();
assert!(out.contains(r#"viewBox="0 0 200 200""#), "{out}");
assert!(out.contains(r#"d="M 0 0 h 10""#), "{out}");
assert!(out.contains(r#"y="50""#), "{out}");
}
#[test]
fn objectboundingbox_gradient_untouched() {
let src = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><linearGradient x1="0" y1="0" x2="1" y2="1"/></svg>"#;
let out = transform_svg(src, Target::Square(20), Canvas::Padded).unwrap();
assert!(out.contains(r#"x2="1""#));
}
}