152 lines
7.0 KiB
Rust
152 lines
7.0 KiB
Rust
//! 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)")]);
|
|
}
|
|
}
|