Merge pull request #1565 from switch87/fix/1557-css-transform-font
SVG import: CSS transforms in CSS syntax move objects; the font shorthand styles type
This commit is contained in:
commit
9d72686d95
|
|
@ -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"];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 >", ""] {
|
||||
|
|
|
|||
|
|
@ -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)")]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue