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 = "