SVG import: CSS transforms in CSS syntax move objects; the font shorthand styles type

usvg takes a `transform` CSS property as a presentation attribute that
overrides the element's own, but reads its value as SVG: CSS units
(`translate(100px, 0)`, `rotate(45deg)`) don't parse, and the object
was left with no transform at all. Before usvg runs, transform
declarations in `style` attributes and `<style>` rules are now written
in SVG syntax (lengths in px, angles in deg/rad/grad/turn, translateX/Y,
scaleX/Y and skew as SVG's functions); values that can't be are left.

The text pass's own cascade now expands the `font` shorthand into its
longhands, as usvg does for the rest of the drawing, so text styled
with `font: italic bold 30px …` keeps its size, weight and style.

Refs #1557
This commit is contained in:
Gert Pellin 2026-10-13 03:14:10 +02:00 committed by bflatastic
parent 45334df881
commit 2c01a42b35
5 changed files with 230 additions and 2 deletions

View File

@ -21,6 +21,7 @@ use text::TextSlots;
pub(crate) use fx::BLEND;
mod css;
mod css_transform;
mod files;
mod fx;
mod spread;
@ -468,7 +469,10 @@ impl Edits {
fn prepass<'s>(svg: &'s str, opts: &ImportOptions) -> (Cow<'s, str>, Found) {
let mut found = Found::default();
// (`:` for prefixed elements such as `<svg:image>`.)
if !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t)) {
let css_transform = has_css_transform(svg);
if !css_transform
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t))
{
return (svg.into(), found);
}
let Ok(xml) = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }) else {
@ -507,9 +511,17 @@ 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 css_transform {
edits.list.extend(css_transform::rewrites(svg, &xml));
}
(edits.apply(svg), found)
}
/// Is there a `transform` CSS declaration in `svg` (`transform` followed by `:`)?
fn has_css_transform(svg: &str) -> bool {
svg.match_indices("transform").any(|(i, t)| svg.get(i + t.len()..).is_some_and(|s| s.trim_start().starts_with(':')))
}
/// The elements that are objects (what [`hidden_objects`] shows).
const OBJECTS: &[&str] = &["g", "a", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text", "use"];

View File

@ -15,7 +15,8 @@ use usvg::roxmltree;
pub(super) type XNode<'a, 'i> = roxmltree::Node<'a, 'i>;
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag.
/// `name: value` declarations of a block or `style` attribute, with their `!important` flag. The
/// `font` shorthand becomes its longhands, as usvg reads it for the rest of the drawing (#1557).
fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
s.split(';')
.filter_map(|d| {
@ -27,9 +28,28 @@ fn parse_decls(s: &str) -> Vec<(String, String, bool)> {
};
Some((k.trim().to_ascii_lowercase(), v.to_string(), important))
})
.flat_map(|(k, v, important)| match k.as_str() {
"font" => font_longhands(&v).into_iter().map(|(k, v)| (k.to_string(), v, important)).collect(),
_ => vec![(k, v, important)],
})
.collect()
}
/// The longhands of a `font` shorthand: those it leaves out back to their initial values. None
/// when it doesn't parse (it is then ignored, as in CSS).
fn font_longhands(v: &str) -> Vec<(&'static str, String)> {
let Ok(f) = svgtypes::FontShorthand::from_str(v) else { return Vec::new() };
let or = |o: Option<&str>| o.unwrap_or("normal").to_string();
vec![
("font-style", or(f.font_style)),
("font-variant", or(f.font_variant)),
("font-weight", or(f.font_weight)),
("font-stretch", or(f.font_stretch)),
("font-size", f.font_size.to_string()),
("font-family", f.font_family.to_string()),
]
}
/// One compound selector (`text.a.b#c[x=y]`).
#[derive(Debug, Default)]
struct Compound {
@ -343,6 +363,19 @@ mod tests {
);
}
#[test]
fn the_font_shorthand_sets_its_longhands() {
let d = parse_decls("fill: red; font: italic bold 30px 'DejaVu Sans' ; font-weight: 300 !important; font: nonsense");
let get = |k: &str| d.iter().filter(|(n, ..)| n == k).map(|(_, v, i)| (v.as_str(), *i)).collect::<Vec<_>>();
assert_eq!(get("font-style"), [("italic", false)]);
assert_eq!(get("font-weight"), [("bold", false), ("300", true)]);
assert_eq!(get("font-size"), [("30px", false)]);
assert_eq!(get("font-family"), [("'DejaVu Sans'", false)]);
assert_eq!(get("font-variant"), [("normal", false)]);
assert_eq!(get("fill"), [("red", false)]);
assert!(get("font").is_empty(), "the shorthand itself is not kept");
}
#[test]
fn unsupported_selectors_are_dropped() {
for s in ["a + b", "a ~ b", "a:hover", "svg|text", "> a", "a >", ""] {

View File

@ -0,0 +1,151 @@
//! CSS `transform` declarations in SVG syntax, for usvg.
//!
//! usvg takes the `transform` property (from a `style` attribute or a `<style>` rule) as a
//! presentation attribute that overrides the element's `transform` attribute, but reads its value
//! as SVG, without CSS units: `translate(100px, 0)` or `rotate(45deg)` fails, and the element is
//! left with no transform at all (#1557). Before usvg runs, such values are rewritten in SVG
//! syntax: lengths in `px` (or unitless), angles in `deg`, `rad`, `grad` or `turn`, and the CSS
//! functions SVG lacks (`translateX`, `scaleY`, `skew`…) spelled with SVG's. A value that can't be
//! (percentages, `em`, 3D functions) is left as it is.
use std::f64::consts::PI;
use std::ops::Range;
use usvg::roxmltree;
use super::css::XNode;
/// The CSS transform `value` in SVG syntax; `None` when it has nothing to rewrite or can't be.
pub(super) fn to_svg(value: &str) -> Option<String> {
let v = value.trim();
if v.eq_ignore_ascii_case("none") {
return Some("matrix(1 0 0 1 0 0)".into());
}
let mut out = Vec::new();
let mut rest = v;
while !rest.trim_start().is_empty() {
rest = rest.trim_start();
let open = rest.find('(')?;
let close = rest.find(')')?;
let name = rest.get(..open)?.trim().to_ascii_lowercase();
let args: Vec<&str> = rest.get(open + 1..close)?.split(|c: char| c == ',' || c.is_whitespace()).filter(|a| !a.is_empty()).collect();
let len = |i: usize| args.get(i).and_then(|a| length(a));
let ang = |i: usize| args.get(i).and_then(|a| angle(a));
let num = |i: usize| args.get(i).and_then(|a| a.parse::<f64>().ok()).filter(|n| n.is_finite());
let f = match (name.as_str(), args.len()) {
("matrix", 6) => format!("matrix({} {} {} {} {} {})", num(0)?, num(1)?, num(2)?, num(3)?, len(4)?, len(5)?),
("translate", 1) => format!("translate({} 0)", len(0)?),
("translate", 2) => format!("translate({} {})", len(0)?, len(1)?),
("translatex", 1) => format!("translate({} 0)", len(0)?),
("translatey", 1) => format!("translate(0 {})", len(0)?),
("scale", 1) => format!("scale({})", num(0)?),
("scale", 2) => format!("scale({} {})", num(0)?, num(1)?),
("scalex", 1) => format!("scale({} 1)", num(0)?),
("scaley", 1) => format!("scale(1 {})", num(0)?),
("rotate", 1) => format!("rotate({})", ang(0)?),
("skewx", 1) => format!("skewX({})", ang(0)?),
("skewy", 1) => format!("skewY({})", ang(0)?),
("skew", 1) => format!("skewX({})", ang(0)?),
("skew", 2) => format!("matrix(1 {} {} 1 0 0)", ang(1)?.to_radians().tan(), ang(0)?.to_radians().tan()),
_ => return None,
};
out.push(f);
rest = rest.get(close + 1..)?;
}
let svg = out.join(" ");
(!out.is_empty() && svg != v).then_some(svg)
}
/// A length in user units: `px` or unitless.
fn length(a: &str) -> Option<f64> {
let n = a.strip_suffix("px").unwrap_or(a).parse::<f64>().ok()?;
n.is_finite().then_some(n)
}
/// An angle in degrees.
fn angle(a: &str) -> Option<f64> {
let a = a.to_ascii_lowercase();
let (n, k) = if let Some(n) = a.strip_suffix("deg") {
(n, 1.0)
} else if let Some(n) = a.strip_suffix("grad") {
(n, 0.9)
} else if let Some(n) = a.strip_suffix("rad") {
(n, 180.0 / PI)
} else if let Some(n) = a.strip_suffix("turn") {
(n, 360.0)
} else {
(a.as_str(), 1.0)
};
let d = n.parse::<f64>().ok()? * k;
d.is_finite().then_some(d)
}
/// Where CSS can be in `svg`: `style` attribute values and `<style>` contents.
fn css_ranges(xml: &roxmltree::Document) -> Vec<Range<usize>> {
let mut out = Vec::new();
for n in xml.descendants().filter(XNode::is_element) {
if let Some(a) = n.attributes().find(|a| a.name() == "style" && a.namespace().is_none()) {
out.push(a.range_value());
}
if n.tag_name().name() == "style" {
out.extend(n.children().filter(XNode::is_text).map(|t| t.range()));
}
}
out
}
/// The `transform` declarations in CSS in `svg`, each as (value range, value in SVG syntax).
pub(super) fn rewrites(svg: &str, xml: &roxmltree::Document) -> Vec<(Range<usize>, String)> {
let mut out = Vec::new();
for r in css_ranges(xml) {
let Some(css) = svg.get(r.clone()) else { continue };
let mut from = 0;
while let Some(i) = css.get(from..).and_then(|s| s.find("transform")).map(|i| i + from) {
from = i + "transform".len();
let before = css.get(..i).and_then(|s| s.chars().next_back());
if before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
continue;
}
let after = css.get(from..).unwrap_or("");
let Some(colon) = after.find(|c: char| !c.is_whitespace()).filter(|&k| after.get(k..).is_some_and(|s| s.starts_with(':'))) else {
continue;
};
let start = from + colon + 1;
let len = css.get(start..).and_then(|s| s.find([';', '}', '!', '"', '\'', '<'])).unwrap_or(css.len() - start);
let Some(value) = css.get(start..start + len) else { continue };
if let Some(svg_value) = to_svg(value) {
out.push((r.start + start..r.start + start + len, format!(" {svg_value}")));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn css_transforms_become_svg() {
assert_eq!(to_svg("translate(100px, 100px)").as_deref(), Some("translate(100 100)"));
assert_eq!(to_svg(" translateX(5px) rotate(0.25turn) ").as_deref(), Some("translate(5 0) rotate(90)"));
assert_eq!(to_svg("rotate(1.5707963267948966rad) scaleY(2)").as_deref(), Some("rotate(90) scale(1 2)"));
assert_eq!(to_svg("skew(45deg)").as_deref(), Some("skewX(45)"));
assert_eq!(to_svg("matrix(1, 0, 0, 1, 10px, 20px)").as_deref(), Some("matrix(1 0 0 1 10 20)"));
assert_eq!(to_svg("none").as_deref(), Some("matrix(1 0 0 1 0 0)"));
// Already SVG, or not expressible: left alone.
assert_eq!(to_svg("translate(10 20)"), None);
assert_eq!(to_svg("translate(50%, 0)"), None);
assert_eq!(to_svg("rotate3d(1, 0, 0, 45deg)"), None);
assert_eq!(to_svg("translate(1px"), None);
}
#[test]
fn only_css_is_rewritten() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><style>.a { fill: red; transform: rotate(45deg) } .b{-webkit-transform:rotate(1deg)}</style><rect class="a" transform="translate(1 2)" style="transform:translate(3px,4px);fill:blue"/><text>transform: rotate(9deg)</text></svg>"#;
let xml = roxmltree::Document::parse(svg).unwrap();
let found = rewrites(svg, &xml);
let r: Vec<(&str, &str)> = found.iter().map(|(r, v)| (&svg[r.clone()], v.as_str())).collect();
assert_eq!(r, vec![(" rotate(45deg) ", " rotate(45)"), ("translate(3px,4px)", " translate(3 4)")]);
}
}

View File

@ -181,3 +181,24 @@ fn reflect_and_repeat_match_resvg() {
assert_similar(&render_artboard(&d), &resvg_render(&svg(&body), 200, 120), 24.0, 0.01);
}
}
/// #1557: `transform` as a CSS property, in CSS syntax, moves the object (and wins over the
/// attribute, as in CSS), from a `style` attribute or a `<style>` rule.
#[test]
fn css_transforms_move_objects() {
let d = import_with(
&svg(r#"<style>.r { transform: rotate(90deg) }</style>
<rect id="a" x="0" y="0" width="50" height="50" fill="red" style="transform: translate(100px, 100px)"/>
<rect id="b" x="0" y="0" width="50" height="50" fill="blue" transform="translate(0 100)" style="transform: translateX(100px)"/>
<rect id="c" class="r" x="0" y="0" width="50" height="20" fill="green"/>"#),
&ImportOptions::default(),
)
.unwrap()
.0;
let b: Vec<Rect> = art(&d).iter().map(|n| n.geometric_bounds().unwrap()).collect();
let near =
|r: Rect, x0: f64, y0: f64, x1: f64, y1: f64| [(r.x0, x0), (r.y0, y0), (r.x1, x1), (r.y1, y1)].iter().all(|(a, b)| (a - b).abs() < 1e-6);
assert!(near(b[0], 100.0, 100.0, 150.0, 150.0), "{:?}", b[0]);
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]);
}

View File

@ -583,3 +583,14 @@ fn text_path_to_an_empty_shape_still_falls_back_to_point_type() {
assert!(matches!(only_text(&d).kind, TextKind::Point));
assert!(w.iter().any(|w| w.contains("textPath")), "{w:?}");
}
/// #1557: the `font` shorthand sets the type's size, weight and style.
#[test]
fn the_font_shorthand_styles_type() {
let d = open(r#"<text x="10" y="40" style="font: italic bold 30px sans-serif">Deco</text>"#);
let t = only_text(&d);
let st = &t.runs[0].style;
assert!(close(st.size * t.xf.determinant().abs().sqrt(), 30.0, 1e-6), "{}", st.size);
let style = st.font_style.to_ascii_lowercase();
assert!(style.contains("bold") && style.contains("italic"), "{style}");
}