From 5fe916a425db9148b3f4fd742864269592379f3e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 13 Oct 2026 08:32:45 +0700 Subject: [PATCH] SVG import: percentage lengths in a symbol with a viewBox are of the viewBox (#1561) --- crates/svg/src/import.rs | 7 ++- crates/svg/src/import/symbol_units.rs | 72 +++++++++++++++++++++++++++ crates/svg/tests/import_details.rs | 14 ++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 crates/svg/src/import/symbol_units.rs diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 473a820e..5a591bad 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -25,6 +25,7 @@ mod css_transform; mod files; mod fx; mod spread; +mod symbol_units; mod text; mod vector_effect; @@ -465,7 +466,8 @@ impl Edits { /// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded, /// to come back hidden; one something links to (a `` template) stays as it is; /// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]); -/// * linked files are read ([`files`]). +/// * linked files are read ([`files`]); +/// * percentage lengths in a `` with a `viewBox` become numbers ([`symbol_units`]). fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { let mut found = Found::default(); // (`:` for prefixed elements such as ``.) @@ -511,6 +513,9 @@ fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) { } } found.files = files::Files::read(svg, &xml, opts, &mut edits, &mut found.warnings); + if !symbols.is_empty() && svg.contains('%') { + edits.list.extend(symbol_units::rewrites(&xml)); + } if css_transform { edits.list.extend(css_transform::rewrites(svg, &xml)); } diff --git a/crates/svg/src/import/symbol_units.rs b/crates/svg/src/import/symbol_units.rs new file mode 100644 index 00000000..b038b20f --- /dev/null +++ b/crates/svg/src/import/symbol_units.rs @@ -0,0 +1,72 @@ +//! Percentage lengths inside a `` with a `viewBox`, as numbers, for usvg. +//! +//! A percentage is of the nearest viewport's size in user units, and a symbol's `viewBox` sets +//! that size for its content as a nested ``'s does (SVG 2, 8.9 Units; 5.6 The `use` element). +//! usvg takes the percentages of the ``'s width and height instead and then scales the art by +//! the `viewBox`, so it comes out too large and out of place (#1561). Before usvg runs, the +//! percentages of the shapes' geometry are rewritten in the `viewBox`'s units. + +use std::ops::Range; +use std::str::FromStr; + +use usvg::roxmltree; + +/// The elements whose geometry attributes are rewritten. +const SHAPES: &[&str] = &["rect", "circle", "ellipse", "line", "image", "use", "text"]; +/// The elements whose content has lengths of its own kind (or a viewport of its own). +const SCOPES: &[&str] = &["symbol", "svg", "pattern", "mask", "clipPath", "marker"]; + +/// What a percentage of attribute `name` is of, in a viewport of `w` × `h`: its width, its height, +/// or (a radius) its diagonal over √2. +fn basis(name: &str, w: f64, h: f64) -> Option { + match name { + "x" | "x1" | "x2" | "cx" | "width" | "rx" => Some(w), + "y" | "y1" | "y2" | "cy" | "height" | "ry" => Some(h), + "r" => Some(((w * w + h * h) / 2.0).sqrt()), + _ => None, + } +} + +/// The percentage geometry of shapes in symbols with a `viewBox`, each as (value range, number). +pub(super) fn rewrites(xml: &roxmltree::Document) -> Vec<(Range, String)> { + let mut out = Vec::new(); + for n in xml.descendants().filter(|n| n.is_element() && SHAPES.contains(&n.tag_name().name())) { + let Some(scope) = n.ancestors().skip(1).find(|a| SCOPES.contains(&a.tag_name().name())) else { continue }; + let Some(vb) = Some(scope) + .filter(|s| s.tag_name().name() == "symbol") + .and_then(|s| s.attribute("viewBox")) + .and_then(|v| svgtypes::ViewBox::from_str(v).ok()) + else { + continue; + }; + if !(vb.w > 0.0 && vb.h > 0.0 && vb.w.is_finite() && vb.h.is_finite()) { + continue; + } + for a in n.attributes().filter(|a| a.namespace().is_none()) { + let percent = a.value().trim().strip_suffix('%').and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); + if let (Some(p), Some(of)) = (percent, basis(a.name(), vb.w, vb.h)) { + out.push((a.range_value(), (p / 100.0 * of).to_string())); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn percentages_in_a_symbol_are_of_its_view_box() { + let svg = r##" + + + "##; + let xml = roxmltree::Document::parse(svg).unwrap(); + let found: Vec<(&str, String)> = rewrites(&xml).into_iter().map(|(r, v)| (&svg[r], v)).collect(); + let r = (200.0f64 * 200.0 / 2.0 + 100.0 * 100.0 / 2.0).sqrt().to_string(); + let expected = + [("10%", "20"), ("10%", "10"), ("100%", "200"), ("50%", "50"), ("50%", "100"), ("100%", r.as_str()), ("25%", "50"), (" 25% ", "25")]; + assert_eq!(found.iter().map(|(a, b)| (*a, b.as_str())).collect::>(), expected); + } +} diff --git a/crates/svg/tests/import_details.rs b/crates/svg/tests/import_details.rs index 3890aa59..4634dec7 100644 --- a/crates/svg/tests/import_details.rs +++ b/crates/svg/tests/import_details.rs @@ -202,3 +202,17 @@ fn css_transforms_move_objects() { assert!(near(b[1], 100.0, 0.0, 150.0, 50.0), "{:?}", b[1]); assert!(near(b[2], -20.0, 0.0, 0.0, 50.0), "{:?}", b[2]); } + +/// #1561: percentages in a `` with a `viewBox` are of the `viewBox`, so the symbol draws as +/// the same one written with numbers. +#[test] +fn percentages_in_a_symbol_are_of_its_view_box() { + let drawing = |w: &str, h: &str, cx: &str, cy: &str| { + let s = svg(&format!( + r##" + "## + )); + render_artboard(&import_with(&s, &ImportOptions::default()).unwrap().0) + }; + assert_similar(&drawing("100%", "100%", "25%", "50%"), &drawing("100", "50", "25", "25"), 24.0, 0.001); +}