diff --git a/CHANGELOG.md b/CHANGELOG.md index cb2e52f17..ef72647cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ This changelog also contains important changes in dependencies. This release has an MSRV of 1.87.0 for `usvg` and `resvg` and the C API. +### Fixed +- A `transform` on the outermost `svg` element is now applied in the SVG viewport + coordinate system (i.e. after the viewBox-to-viewport mapping), matching Chromium, + Firefox and Inkscape. (#899) + ## [0.47.0] 2026-02-05 This release has an MSRV of 1.87.0 for `usvg` and `resvg` and the C API. diff --git a/crates/usvg/src/parser/converter.rs b/crates/usvg/src/parser/converter.rs index 38bdbdbff..da210bb2b 100644 --- a/crates/usvg/src/parser/converter.rs +++ b/crates/usvg/src/parser/converter.rs @@ -415,7 +415,14 @@ pub(crate) fn convert_doc(svg_doc: &svgtree::Document, opt: &Options) -> Result< } } - let root_ts = view_box.to_transform(tree.size()); + // The `transform` attribute is allowed on the outermost `svg` element since SVG 2. + // Unlike a transform on a nested element, it's applied in the SVG viewport + // coordinate system, i.e. _after_ the viewBox-to-viewport mapping, just like + // Chromium, Firefox and Inkscape do. Therefore it must wrap the viewBox transform + // and not the other way around. The transform itself is skipped in `convert_group` + // for the root `svg` to avoid applying it twice. + let svg_ts = svg.resolve_transform(AId::Transform, &state); + let root_ts = svg_ts.pre_concat(view_box.to_transform(tree.size())); if root_ts.is_identity() && background_color.is_none() { convert_children(svg_doc.root(), &state, &mut cache, &mut tree.root); } else { @@ -752,7 +759,15 @@ pub(crate) fn convert_group( Opacity::ONE }; - let transform = node.resolve_transform(AId::Transform, state); + // The `transform` on the outermost `svg` element is applied together with the + // viewBox transform in `convert_doc` (in the SVG viewport coordinate system), + // so it must not be applied again here. + let is_root_svg = node.tag_name() == Some(EId::Svg) && node.parent_element().is_none(); + let transform = if is_root_svg { + Transform::default() + } else { + node.resolve_transform(AId::Transform, state) + }; let blend_mode: BlendMode = node.attribute(AId::MixBlendMode).unwrap_or_default(); let isolation: Isolation = node.attribute(AId::Isolation).unwrap_or_default(); let isolate = isolation == Isolation::Isolate; diff --git a/crates/usvg/tests/parser.rs b/crates/usvg/tests/parser.rs index 905bbc0db..5c6cf8c95 100644 --- a/crates/usvg/tests/parser.rs +++ b/crates/usvg/tests/parser.rs @@ -316,6 +316,73 @@ fn path_transform_nested() { ); } +// https://github.com/linebender/resvg/issues/899 +#[test] +fn root_svg_transform_with_viewbox() { + // The `transform` on the outermost `svg` element is applied in the SVG viewport + // coordinate system, i.e. _after_ the viewBox-to-viewport mapping, just like + // Chromium, Firefox and Inkscape do. So for a 100x100 viewport with + // `viewBox='0 0 50 50'` (scale 2) and `transform='scale(0.5)'`, the effective + // root transform must be `scale(0.5) * scale(2) = scale(1)`, and _not_ + // `scale(2) * scale(0.5)` which also yields scale(1) here but differs as soon as + // the viewBox has a non-zero origin (see the translate case below). + let svg = " + + + + "; + + let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); + assert_eq!(tree.root().children().len(), 1); + + let group_node = &tree.root().children()[0]; + assert!(matches!(group_node, usvg::Node::Group(_))); + // viewBox 'scale 2, translate (-20,-20)' wrapped by 'scale(0.5)' (applied last): + // scale(0.5) * (scale(2) * p + (-20,-20)) = p + (-10,-10). + assert_eq!( + group_node.abs_transform(), + usvg::Transform::from_row(1.0, 0.0, 0.0, 1.0, -10.0, -10.0) + ); +} + +#[test] +fn nested_svg_transform_still_applies() { + // Only the _outermost_ `svg` transform is special-cased; a nested `svg` + // element must still apply its `transform` like any other element. + let svg = " + + + + + + "; + + let tree = usvg::Tree::from_str(&svg, &usvg::Options::default()).unwrap(); + + fn find_path<'a>(group: &'a usvg::Group) -> Option<&'a usvg::Path> { + for node in group.children() { + match node { + usvg::Node::Path(p) => return Some(p), + usvg::Node::Group(g) => { + if let Some(p) = find_path(g) { + return Some(p); + } + } + _ => {} + } + } + None + } + + // The nested `svg` transform must still be applied (i.e. the path must be + // translated), unlike the outermost `svg` which is handled specially. + let path = find_path(tree.root()).expect("path not found"); + let ts = path.abs_transform(); + assert!(!ts.is_identity()); + assert!(ts.tx != 0.0 && ts.ty != 0.0); +} + #[test] fn path_transform_in_symbol_no_clip() { let svg = "