Repository: linebender/resvg (language: rust)

Issue:
Support `transform-origin`
Transform origin is a part of SVG2 https://svgwg.org/svg2-draft/styling.html#PresentationAttributes


Discussion / hints:
This is also important for me. As a workaround, I'm adding an extra `translate` to compensate. Support for `transform-origin` would be much appreciated.

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index f3bb8420a..c5eb12075 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -312,8 +312,7 @@ dependencies = [
 [[package]]
 name = "svgtypes"
 version = "0.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71499ff2d42f59d26edb21369a308ede691421f79ebc0f001e2b1fd3a7c9e52"
+source = "git+https://github.com/RazrFalcon/svgtypes#f485fb0c009e7a69b78a9fe457ceb7466b1e6b53"
 dependencies = [
  "kurbo",
  "siphasher",
diff --git a/crates/resvg/Cargo.toml b/crates/resvg/Cargo.toml
index 1ae9b85de..857153960 100644
--- a/crates/resvg/Cargo.toml
+++ b/crates/resvg/Cargo.toml
@@ -21,7 +21,7 @@ log = "0.4"
 pico-args = { version = "0.5", features = ["eq-separator"] }
 png = { version = "0.17", optional = true }
 rgb = "0.8"
-svgtypes = "0.12"
+svgtypes = { git = "https://github.com/RazrFalcon/svgtypes" }
 tiny-skia = "0.11.2"
 usvg = { path = "../usvg", version = "0.36.0", default-features = false }
 
diff --git a/crates/usvg-parser/Cargo.toml b/crates/usvg-parser/Cargo.toml
index 7f10efa69..104556537 100644
--- a/crates/usvg-parser/Cargo.toml
+++ b/crates/usvg-parser/Cargo.toml
@@ -22,5 +22,5 @@ log = "0.4"
 roxmltree = "0.18"
 simplecss = "0.2"
 siphasher = "0.3" # perfect hash implementation
-svgtypes = "0.12"
+svgtypes = { git = "https://github.com/RazrFalcon/svgtypes" }
 usvg-tree = { path = "../usvg-tree", version = "0.36.0" }
diff --git a/crates/usvg-parser/src/clippath.rs b/crates/usvg-parser/src/clippath.rs
index 45cd1ae66..7c126688a 100644
--- a/crates/usvg-parser/src/clippath.rs
+++ b/crates/usvg-parser/src/clippath.rs
@@ -21,7 +21,7 @@ pub(crate) fn convert(
     }
 
     // The whole clip path should be ignored when a transform is invalid.
-    let transform = resolve_transform(node)?;
+    let transform = resolve_clip_path_transform(node, state)?;
 
     // Check if this element was already converted.
     if let Some(clip) = cache.clip_paths.get(node.element_id()) {
@@ -67,7 +67,7 @@ pub(crate) fn convert(
     }
 }
 
-fn resolve_transform(node: SvgNode) -> Option<Transform> {
+fn resolve_clip_path_transform(node: SvgNode, state: &converter::State) -> Option<Transform> {
     // Do not use Node::attribute::<Transform>, because it will always
     // return a valid transform.
 
@@ -94,7 +94,7 @@ fn resolve_transform(node: SvgNode) -> Option<Transform> {
     );
 
     if ts.is_valid() {
-        Some(ts)
+        Some(node.resolve_transform(AId::Transform, state))
     } else {
         None
     }
diff --git a/crates/usvg-parser/src/converter.rs b/crates/usvg-parser/src/converter.rs
index c121de3ee..608577090 100644
--- a/crates/usvg-parser/src/converter.rs
+++ b/crates/usvg-parser/src/converter.rs
@@ -7,11 +7,11 @@ use std::hash::{Hash, Hasher};
 use std::rc::Rc;
 use std::str::FromStr;
 
-use svgtypes::{Length, LengthUnit as Unit, PaintOrderKind};
+use svgtypes::{Length, LengthUnit as Unit, TransformOrigin, PaintOrderKind};
 use usvg_tree::*;
 
 use crate::svgtree::{self, AId, EId, FromValue, SvgNode};
-use crate::units;
+use crate::units::{self, convert_length};
 use crate::{Error, Options};
 
 #[derive(Clone)]
@@ -560,7 +560,7 @@ pub(crate) fn convert_group(
         filters
     };
 
-    let transform: Transform = node.attribute(AId::Transform).unwrap_or_default();
+    let transform = 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;
@@ -754,3 +754,33 @@ pub fn svg_paint_order_to_usvg(order: svgtypes::PaintOrder) -> PaintOrder {
         _ => PaintOrder::FillAndStroke,
     }
 }
+
+impl SvgNode<'_, '_> {
+    pub(crate) fn resolve_transform(&self, transform_aid: AId, state: &State) -> Transform {
+        let mut transform: Transform = self.attribute(transform_aid).unwrap_or_default();
+        let transform_origin: Option<TransformOrigin> = self.attribute(AId::TransformOrigin);
+
+        if let Some(transform_origin) = transform_origin {
+            let dx = convert_length(
+                transform_origin.x_offset,
+                *self,
+                AId::Width,
+                Units::UserSpaceOnUse,
+                state,
+            );
+            let dy = convert_length(
+                transform_origin.y_offset,
+                *self,
+                AId::Height,
+                Units::UserSpaceOnUse,
+                state,
+            );
+            transform = Transform::default()
+                .pre_translate(dx, dy)
+                .pre_concat(transform)
+                .pre_translate(-dx, -dy);
+        }
+
+        transform
+    }
+}
diff --git a/crates/usvg-parser/src/filter.rs b/crates/usvg-parser/src/filter.rs
index e73637ead..680483e2e 100644
--- a/crates/usvg-parser/src/filter.rs
+++ b/crates/usvg-parser/src/filter.rs
@@ -16,10 +16,10 @@ use usvg_tree::{
     Units,
 };
 
-use crate::converter::SvgColorExt;
+use crate::converter::{self, SvgColorExt};
 use crate::paint_server::{convert_units, resolve_number};
 use crate::svgtree::{AId, EId, FromValue, SvgNode};
-use crate::{converter, OptionLog};
+use crate::OptionLog;
 
 impl<'a, 'input: 'a> FromValue<'a, 'input> for usvg_tree::filter::ColorInterpolation {
     fn parse(_: SvgNode, _: AId, value: &str) -> Option<Self> {
diff --git a/crates/usvg-parser/src/paint_server.rs b/crates/usvg-parser/src/paint_server.rs
index c09411593..2a6f4915d 100644
--- a/crates/usvg-parser/src/paint_server.rs
+++ b/crates/usvg-parser/src/paint_server.rs
@@ -9,9 +9,9 @@ use strict_num::PositiveF32;
 use svgtypes::{Length, LengthUnit as Unit};
 use usvg_tree::*;
 
-use crate::converter::SvgColorExt;
+use crate::converter::{self, SvgColorExt};
 use crate::svgtree::{AId, EId, SvgNode};
-use crate::{converter, OptionLog};
+use crate::OptionLog;
 
 pub(crate) enum ServerOrColor {
     Server(Paint),
@@ -53,9 +53,7 @@ fn convert_linear(node: SvgNode, state: &converter::State) -> Option<ServerOrCol
     }
 
     let units = convert_units(node, AId::GradientUnits, Units::ObjectBoundingBox);
-    let transform = resolve_attr(node, AId::GradientTransform)
-        .attribute(AId::GradientTransform)
-        .unwrap_or_default();
+    let transform = node.resolve_transform(AId::GradientTransform, state);
 
     let gradient = LinearGradient {
         id: node.element_id().to_string(),
@@ -121,9 +119,7 @@ fn convert_radial(node: SvgNode, state: &converter::State) -> Option<ServerOrCol
     );
     let fx = resolve_number(node, AId::Fx, units, state, Length::new_number(cx as f64));
     let fy = resolve_number(node, AId::Fy, units, state, Length::new_number(cy as f64));
-    let transform = resolve_attr(node, AId::GradientTransform)
-        .attribute(AId::GradientTransform)
-        .unwrap_or_default();
+    let transform = node.resolve_transform(AId::GradientTransform, state);
 
     let gradient = RadialGradient {
         id: node.element_id().to_string(),
@@ -165,9 +161,7 @@ fn convert_pattern(
     let units = convert_units(node, AId::PatternUnits, Units::ObjectBoundingBox);
     let content_units = convert_units(node, AId::PatternContentUnits, Units::UserSpaceOnUse);
 
-    let transform = resolve_attr(node, AId::PatternTransform)
-        .attribute(AId::PatternTransform)
-        .unwrap_or_default();
+    let transform = node.resolve_transform(AId::PatternTransform, state);
 
     let rect = NonZeroRect::from_xywh(
         resolve_number(node, AId::X, units, state, Length::zero()),
diff --git a/crates/usvg-parser/src/style.rs b/crates/usvg-parser/src/style.rs
index ccea18f0c..47c341d45 100644
--- a/crates/usvg-parser/src/style.rs
+++ b/crates/usvg-parser/src/style.rs
@@ -4,9 +4,9 @@
 
 use usvg_tree::{ApproxEqUlps, Color, Fill, Opacity, Paint, Stroke, StrokeMiterlimit, Units};
 
-use crate::converter::SvgColorExt;
+use crate::converter::{self, SvgColorExt};
+use crate::paint_server;
 use crate::svgtree::{AId, FromValue, SvgNode};
-use crate::{converter, paint_server};
 
 impl<'a, 'input: 'a> FromValue<'a, 'input> for usvg_tree::LineCap {
     fn parse(_: SvgNode, _: AId, value: &str) -> Option<Self> {
@@ -187,6 +187,8 @@ fn convert_paint(
                 from_fallback(node, fallback, opacity)
             }
         }
+        // Ignore `context-fill` and `context-stroke for now
+        _ => None,
     }
 }
 
diff --git a/crates/usvg-parser/src/svgtree/mod.rs b/crates/usvg-parser/src/svgtree/mod.rs
index 71205a07b..3c6fa3fc3 100644
--- a/crates/usvg-parser/src/svgtree/mod.rs
+++ b/crates/usvg-parser/src/svgtree/mod.rs
@@ -709,6 +709,7 @@ impl AId {
                 | AId::TextOverflow
                 | AId::TextRendering
                 | AId::Transform
+                | AId::TransformOrigin
                 | AId::UnicodeBidi
                 | AId::VectorEffect
                 | AId::Visibility
@@ -801,6 +802,7 @@ fn is_non_inheritable(id: AId) -> bool {
             | AId::StopOpacity
             | AId::TextDecoration
             | AId::Transform
+            | AId::TransformOrigin
     )
 }
 
@@ -871,6 +873,12 @@ impl<'a, 'input: 'a> FromValue<'a, 'input> for usvg_tree::Transform {
     }
 }
 
+impl<'a, 'input: 'a> FromValue<'a, 'input> for svgtypes::TransformOrigin {
+    fn parse(_: SvgNode, _: AId, value: &str) -> Option<Self> {
+        Self::from_str(value).ok()
+    }
+}
+
 impl<'a, 'input: 'a> FromValue<'a, 'input> for svgtypes::ViewBox {
     fn parse(_: SvgNode, _: AId, value: &str) -> Option<Self> {
         Self::from_str(value).ok()
diff --git a/crates/usvg-parser/src/text.rs b/crates/usvg-parser/src/text.rs
index 11dad101f..02c5f0e20 100644
--- a/crates/usvg-parser/src/text.rs
+++ b/crates/usvg-parser/src/text.rs
@@ -139,20 +139,12 @@ fn collect_text_chunks(
         chunks: Vec::new(),
     };
 
-    collect_text_chunks_impl(
-        text_node,
-        text_node,
-        pos_list,
-        state,
-        cache,
-        &mut iter_state,
-    );
+    collect_text_chunks_impl(text_node, pos_list, state, cache, &mut iter_state);
 
     iter_state.chunks
 }
 
 fn collect_text_chunks_impl(
-    text_node: SvgNode,
     parent: SvgNode,
     pos_list: &[CharacterPosition],
     state: &converter::State,
@@ -184,7 +176,7 @@ fn collect_text_chunks_impl(
                 iter_state.split_chunk = true;
             }
 
-            collect_text_chunks_impl(text_node, child, pos_list, state, cache, iter_state);
+            collect_text_chunks_impl(child, pos_list, state, cache, iter_state);
 
             iter_state.text_flow = TextFlow::Linear;
 
@@ -337,9 +329,10 @@ fn resolve_text_flow(node: SvgNode, state: &converter::State) -> Option<TextFlow
     let path = crate::shapes::convert(linked_node, state)?;
 
     // The reference path's transform needs to be applied
-    let path = if let Some(node_transform) = linked_node.attribute::<Transform>(AId::Transform) {
+    let transform = linked_node.resolve_transform(AId::Transform, state);
+    let path = if !transform.is_identity() {
         let mut path_copy = path.as_ref().clone();
-        path_copy = path_copy.transform(node_transform)?;
+        path_copy = path_copy.transform(transform)?;
         Rc::new(path_copy)
     } else {
         path
@@ -632,7 +625,10 @@ fn resolve_decoration(
     // ancestors (i.e. tspans) until we find the text decoration declared. If not, we will
     // stop at latest at the text node, and use its fill/stroke.
     let mut gen_style = |text_decoration: &str| {
-        if !tspan.ancestors().any(|n| find_decoration(n, text_decoration)) {
+        if !tspan
+            .ancestors()
+            .any(|n| find_decoration(n, text_decoration))
+        {
             return None;
         }
 
@@ -641,8 +637,8 @@ fn resolve_decoration(
 
         for node in tspan.ancestors() {
             if find_decoration(node, text_decoration) || node.tag_name() == Some(EId::Text) {
-                fill_node = fill_node.map_or(Some(node), |n| Some(n));
-                stroke_node = stroke_node.map_or(Some(node), |n| Some(n));
+                fill_node = fill_node.map_or(Some(node), Some);
+                stroke_node = stroke_node.map_or(Some(node), Some);
                 break;
             }
         }
diff --git a/crates/usvg-parser/src/use_node.rs b/crates/usvg-parser/src/use_node.rs
index b3bfab2ec..050c988c2 100644
--- a/crates/usvg-parser/src/use_node.rs
+++ b/crates/usvg-parser/src/use_node.rs
@@ -29,7 +29,7 @@ pub(crate) fn convert(
     }
 
     // We require an original transformation to setup 'clipPath'.
-    let mut orig_ts: Transform = node.attribute(AId::Transform).unwrap_or_default();
+    let mut orig_ts = node.resolve_transform(AId::Transform, state);
     let mut new_ts = Transform::default();
 
     {
@@ -133,7 +133,7 @@ pub(crate) fn convert_svg(
     parent: &mut Node,
 ) {
     // We require original transformation to setup 'clipPath'.
-    let mut orig_ts: Transform = node.attribute(AId::Transform).unwrap_or_default();
+    let mut orig_ts = node.resolve_transform(AId::Transform, state);
     let mut new_ts = Transform::default();
 
     {
diff --git a/crates/usvg-tree/Cargo.toml b/crates/usvg-tree/Cargo.toml
index 7ae8b6990..6f0f1d5f4 100644
--- a/crates/usvg-tree/Cargo.toml
+++ b/crates/usvg-tree/Cargo.toml
@@ -15,5 +15,5 @@ workspace = "../.."
 [dependencies]
 rctree = "0.5"
 strict-num = "0.1.1"
-svgtypes = "0.12"
+svgtypes = { git = "https://github.com/RazrFalcon/svgtypes" }
 tiny-skia-path = "0.11.2"
diff --git a/docs/svg2-changelog.md b/docs/svg2-changelog.md
index 6d7385625..a40b03b92 100644
--- a/docs/svg2-changelog.md
+++ b/docs/svg2-changelog.md
@@ -73,7 +73,7 @@ NOTE: This list is not final. This just things I was able to find so far. Patche
 ### Added
 
 - [ ] A [`transform-box`](https://www.w3.org/TR/css-transforms-1/#transform-box) property.
-- [ ] A [`transform-origin`](https://www.w3.org/TR/css-transforms-1/#transform-origin-property) property.
+- [x] A [`transform-origin`](https://www.w3.org/TR/css-transforms-1/#transform-origin-property) property.
 - [ ] A [`vector-effect`](https://www.w3.org/TR/SVG2/coords.html#VectorEffects) property.
 
 ### Changed


Test patch:
diff --git a/crates/resvg/tests/integration/render.rs b/crates/resvg/tests/integration/render.rs
index e997528ba..5c03fe8a5 100644
--- a/crates/resvg/tests/integration/render.rs
+++ b/crates/resvg/tests/integration/render.rs
@@ -1229,6 +1229,29 @@ use crate::render;
 #[test] fn structure_transform_translate_without_Y() { assert_eq!(render("tests/structure/transform/translate-without-Y"), 0); }
 #[test] fn structure_transform_translate() { assert_eq!(render("tests/structure/transform/translate"), 0); }
 #[test] fn structure_transform_zeroed_matrix() { assert_eq!(render("tests/structure/transform/zeroed-matrix"), 0); }
+#[test] fn structure_transform_origin_bottom() { assert_eq!(render("tests/structure/transform-origin/bottom"), 0); }
+#[test] fn structure_transform_origin_center() { assert_eq!(render("tests/structure/transform-origin/center"), 0); }
+#[test] fn structure_transform_origin_keyword_length() { assert_eq!(render("tests/structure/transform-origin/keyword-length"), 0); }
+#[test] fn structure_transform_origin_left() { assert_eq!(render("tests/structure/transform-origin/left"), 0); }
+#[test] fn structure_transform_origin_length_percent() { assert_eq!(render("tests/structure/transform-origin/length-percent"), 0); }
+#[test] fn structure_transform_origin_length_px() { assert_eq!(render("tests/structure/transform-origin/length-px"), 0); }
+#[test] fn structure_transform_origin_no_transform() { assert_eq!(render("tests/structure/transform-origin/no-transform"), 0); }
+#[test] fn structure_transform_origin_on_clippath_objectBoundingBox() { assert_eq!(render("tests/structure/transform-origin/on-clippath-objectBoundingBox"), 0); }
+#[test] fn structure_transform_origin_on_clippath() { assert_eq!(render("tests/structure/transform-origin/on-clippath"), 0); }
+#[test] fn structure_transform_origin_on_gradient_object_bounding_box() { assert_eq!(render("tests/structure/transform-origin/on-gradient-object-bounding-box"), 0); }
+#[test] fn structure_transform_origin_on_gradient_user_space_on_use() { assert_eq!(render("tests/structure/transform-origin/on-gradient-user-space-on-use"), 0); }
+#[test] fn structure_transform_origin_on_group() { assert_eq!(render("tests/structure/transform-origin/on-group"), 0); }
+#[test] fn structure_transform_origin_on_image() { assert_eq!(render("tests/structure/transform-origin/on-image"), 0); }
+#[test] fn structure_transform_origin_on_pattern_object_bounding_box() { assert_eq!(render("tests/structure/transform-origin/on-pattern-object-bounding-box"), 0); }
+#[test] fn structure_transform_origin_on_pattern_user_space_on_use() { assert_eq!(render("tests/structure/transform-origin/on-pattern-user-space-on-use"), 0); }
+#[test] fn structure_transform_origin_on_shape() { assert_eq!(render("tests/structure/transform-origin/on-shape"), 0); }
+#[test] fn structure_transform_origin_on_text_path() { assert_eq!(render("tests/structure/transform-origin/on-text-path"), 0); }
+#[test] fn structure_transform_origin_on_text() { assert_eq!(render("tests/structure/transform-origin/on-text"), 0); }
+#[test] fn structure_transform_origin_right_bottom() { assert_eq!(render("tests/structure/transform-origin/right-bottom"), 0); }
+#[test] fn structure_transform_origin_right() { assert_eq!(render("tests/structure/transform-origin/right"), 0); }
+#[test] fn structure_transform_origin_top_left() { assert_eq!(render("tests/structure/transform-origin/top-left"), 0); }
+#[test] fn structure_transform_origin_top() { assert_eq!(render("tests/structure/transform-origin/top"), 0); }
+#[test] fn structure_transform_origin_transform_on_parent() { assert_eq!(render("tests/structure/transform-origin/transform-on-parent"), 0); }
 #[test] fn structure_use_cSS_rules() { assert_eq!(render("tests/structure/use/cSS-rules"), 0); }
 #[test] fn structure_use_complex_style_resolving_order() { assert_eq!(render("tests/structure/use/complex-style-resolving-order"), 0); }
 #[test] fn structure_use_display_inheritance() { assert_eq!(render("tests/structure/use/display-inheritance"), 0); }
diff --git a/crates/resvg/tests/tests/structure/transform-origin/bottom.png b/crates/resvg/tests/tests/structure/transform-origin/bottom.png
new file mode 100644
index 000000000..264605c47
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/bottom.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/bottom.svg b/crates/resvg/tests/tests/structure/transform-origin/bottom.svg
new file mode 100644
index 000000000..b0e46de2a
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/bottom.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>`bottom`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(100 200) rotate(45) translate(-100 -200)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="bottom" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/center.png b/crates/resvg/tests/tests/structure/transform-origin/center.png
new file mode 100644
index 000000000..6dd83e651
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/center.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/center.svg b/crates/resvg/tests/tests/structure/transform-origin/center.svg
new file mode 100644
index 000000000..aa6df9692
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/center.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>`center`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(100 100) rotate(45) translate(-100 -100)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="center" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/keyword-length.png b/crates/resvg/tests/tests/structure/transform-origin/keyword-length.png
new file mode 100644
index 000000000..90bdfb8e9
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/keyword-length.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/keyword-length.svg b/crates/resvg/tests/tests/structure/transform-origin/keyword-length.svg
new file mode 100644
index 000000000..6b7a8e276
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/keyword-length.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>keyword + length</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(50 0) rotate(45) translate(-50 0)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="25% top" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/left.png b/crates/resvg/tests/tests/structure/transform-origin/left.png
new file mode 100644
index 000000000..aa213b80f
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/left.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/left.svg b/crates/resvg/tests/tests/structure/transform-origin/left.svg
new file mode 100644
index 000000000..9a3e70d79
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/left.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>`left`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(0 100) rotate(45) translate(0 -100)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="left" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/length-percent.png b/crates/resvg/tests/tests/structure/transform-origin/length-percent.png
new file mode 100644
index 000000000..6dd83e651
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/length-percent.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/length-percent.svg b/crates/resvg/tests/tests/structure/transform-origin/length-percent.svg
new file mode 100644
index 000000000..debaf6f86
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/length-percent.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>length `percent`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(100 100) rotate(45) translate(-100 -100)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="50% 50%" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/length-px.png b/crates/resvg/tests/tests/structure/transform-origin/length-px.png
new file mode 100644
index 000000000..6dd83e651
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/length-px.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/length-px.svg b/crates/resvg/tests/tests/structure/transform-origin/length-px.svg
new file mode 100644
index 000000000..385d26d72
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/length-px.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>length `px`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(100 100) rotate(45) translate(-100 -100)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="100px 100px" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/no-transform.png b/crates/resvg/tests/tests/structure/transform-origin/no-transform.png
new file mode 100644
index 000000000..f4eec7c22
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/no-transform.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/no-transform.svg b/crates/resvg/tests/tests/structure/transform-origin/no-transform.svg
new file mode 100644
index 000000000..cc8b5da3d
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/no-transform.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>no transform</title>
+
+    <!-- should be covered -->
+    <path id="path1"  d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform-origin="top left" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-clippath-objectBoundingBox.png b/crates/resvg/tests/tests/structure/transform-origin/on-clippath-objectBoundingBox.png
new file mode 100644
index 000000000..c572c8d37
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-clippath-objectBoundingBox.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-clippath-objectBoundingBox.svg b/crates/resvg/tests/tests/structure/transform-origin/on-clippath-objectBoundingBox.svg
new file mode 100644
index 000000000..59c38efaa
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-clippath-objectBoundingBox.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `clipPath` with `clipPathUnits` `objectBoundingBox`</title>
+
+    <clipPath id="clip1" clipPathUnits="objectBoundingBox" transform="scale(2)" transform-origin="center">
+        <rect id="rect1" x="0.3" y="0.3" width="0.4" height="0.4" rx="0.1" ry="0.1"/>
+    </clipPath>
+    <rect id="rect2" x="0" y="0" width="200" height="200" fill="green" clip-path="url(#clip1)"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-clippath.png b/crates/resvg/tests/tests/structure/transform-origin/on-clippath.png
new file mode 100644
index 000000000..f31e00c9e
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-clippath.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-clippath.svg b/crates/resvg/tests/tests/structure/transform-origin/on-clippath.svg
new file mode 100644
index 000000000..c864f8369
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-clippath.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `clipPath`</title>
+
+    <clipPath id="clip1" transform="rotate(36)" transform-origin="center">
+        <path id="path1" d="M 100 15 l 50 160 l -130 -100 l 160 0 l -130 100 z"/>
+    </clipPath>
+    <rect id="rect1" x="0" y="0" width="200" height="200" fill="green" clip-path="url(#clip1)"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
\ No newline at end of file
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-gradient-object-bounding-box.png b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-object-bounding-box.png
new file mode 100644
index 000000000..4795fee32
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-object-bounding-box.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-gradient-object-bounding-box.svg b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-object-bounding-box.svg
new file mode 100644
index 000000000..c5badf10d
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-object-bounding-box.svg
@@ -0,0 +1,13 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `gradient` with `gradientUnits` `objectBoundingBox`</title>
+
+    <radialGradient id="rg1" gradientTransform="scale(2)" transform-origin="0.5 0.5">
+        <stop offset="0" stop-color="black"/>
+        <stop offset="0.375" stop-color="green"/>
+    </radialGradient>
+
+    <rect id="rect1" x="20" y="20" width="160" height="160" fill="url(#rg1)"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-gradient-user-space-on-use.png b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-user-space-on-use.png
new file mode 100644
index 000000000..4795fee32
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-user-space-on-use.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-gradient-user-space-on-use.svg b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-user-space-on-use.svg
new file mode 100644
index 000000000..4cbc013d1
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-gradient-user-space-on-use.svg
@@ -0,0 +1,13 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `gradient` with `gradientUnits` `userSpaceOnUse`</title>
+
+    <radialGradient id="rg1" gradientUnits="userSpaceOnUse" gradientTransform="scale(2)" transform-origin="center">
+        <stop offset="0" stop-color="black"/>
+        <stop offset="0.3" stop-color="green"/>
+    </radialGradient>
+
+    <rect id="rect1" x="20" y="20" width="160" height="160" fill="url(#rg1)"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-group.png b/crates/resvg/tests/tests/structure/transform-origin/on-group.png
new file mode 100644
index 000000000..cbc77a5c3
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-group.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-group.svg b/crates/resvg/tests/tests/structure/transform-origin/on-group.svg
new file mode 100644
index 000000000..fe88fedbe
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-group.svg
@@ -0,0 +1,10 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `group`</title>
+
+    <g transform="rotate(45)" transform-origin="center" >
+        <rect id="rect1" x="60" y="60" width="80" height="80" fill="green"/>
+    </g>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-image.png b/crates/resvg/tests/tests/structure/transform-origin/on-image.png
new file mode 100644
index 000000000..c38ad3be9
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-image.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-image.svg b/crates/resvg/tests/tests/structure/transform-origin/on-image.svg
new file mode 100644
index 000000000..60ae5fa69
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-image.svg
@@ -0,0 +1,62 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>on `image`</title>
+
+    <image id="image1" x="60" y="60" width="80" transform="rotate(90)" transform-origin="center" height="80" xlink:href="data:image/png;base64,
+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
+QmCC"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-pattern-object-bounding-box.png b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-object-bounding-box.png
new file mode 100644
index 000000000..32c531e6c
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-object-bounding-box.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-pattern-object-bounding-box.svg b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-object-bounding-box.svg
new file mode 100644
index 000000000..2c1e17a79
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-object-bounding-box.svg
@@ -0,0 +1,13 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `pattern` with `patternUnits` and `patternContentUnits` `objectBoundingBox`</title>
+
+    <pattern id="patt1" patternUnits="objectBoundingBox" patternContentUnits="objectBoundingBox" width="1" height="1" patternTransform="scale(2)" transform-origin="center">
+        <rect id="rect1" x="0" y="0" width="0.5" height="0.5" fill="grey"/>
+        <rect id="rect2" x="0.5" y="0.5" width="0.5" height="0.5" fill="green"/>
+    </pattern>
+    <rect id="rect3" x="20" y="20" width="160" height="160"
+          fill="url(#patt1)" stroke="darkblue"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-pattern-user-space-on-use.png b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-user-space-on-use.png
new file mode 100644
index 000000000..32c531e6c
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-user-space-on-use.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-pattern-user-space-on-use.svg b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-user-space-on-use.svg
new file mode 100644
index 000000000..91c673393
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-pattern-user-space-on-use.svg
@@ -0,0 +1,13 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `pattern` with `userSpaceOnUse`</title>
+
+    <pattern id="patt1" patternUnits="userSpaceOnUse" width="200" height="200" patternTransform="scale(2)" transform-origin="center">
+        <rect id="rect1" x="50" y="50" width="50" height="50" fill="grey"/>
+        <rect id="rect2" x="100" y="100" width="50" height="50" fill="green"/>
+    </pattern>
+    <rect id="rect3" x="20" y="20" width="160" height="160"
+          fill="url(#patt1)" stroke="darkblue"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-shape.png b/crates/resvg/tests/tests/structure/transform-origin/on-shape.png
new file mode 100644
index 000000000..cbc77a5c3
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-shape.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-shape.svg b/crates/resvg/tests/tests/structure/transform-origin/on-shape.svg
new file mode 100644
index 000000000..a3bcdaecd
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-shape.svg
@@ -0,0 +1,8 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>on `shape`</title>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="center" x="60" y="60" width="80" height="80" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-text-path.png b/crates/resvg/tests/tests/structure/transform-origin/on-text-path.png
new file mode 100644
index 000000000..b40c1f299
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-text-path.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-text-path.svg b/crates/resvg/tests/tests/structure/transform-origin/on-text-path.svg
new file mode 100644
index 000000000..db4dca30c
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-text-path.svg
@@ -0,0 +1,16 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"
+     xmlns:xlink="http://www.w3.org/1999/xlink">
+    <title>on `text-path`</title>
+
+    <path id="pathForText1" d="M 30 100 A 40 30 20 1 1 160 100"
+          fill="none" stroke="gray" transform="rotate(90)" transform-origin="center"/>
+
+    <text id="text1" font-family="Noto Sans" font-size="24">
+        <textPath id="textPath1" xlink:href="#pathForText1" >
+            abcdefghijklmnopqrstuvwxyz
+        </textPath>
+    </text>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-text.png b/crates/resvg/tests/tests/structure/transform-origin/on-text.png
new file mode 100644
index 000000000..6df1fa985
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/on-text.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/on-text.svg b/crates/resvg/tests/tests/structure/transform-origin/on-text.svg
new file mode 100644
index 000000000..a8b929b20
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/on-text.svg
@@ -0,0 +1,12 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"
+     font-family="Noto Sans" font-size="64">
+    <title>on `text`</title>
+
+    <path id="crosshair" d="M 20 100 L 180 100 M 100 20 L 100 180"
+          stroke="gray" stroke-width="0.5"/>
+
+    <text id="text1" x="32" y="100" transform="rotate(90)" transform-origin="center">Text</text>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/right-bottom.png b/crates/resvg/tests/tests/structure/transform-origin/right-bottom.png
new file mode 100644
index 000000000..b061b7dc7
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/right-bottom.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/right-bottom.svg b/crates/resvg/tests/tests/structure/transform-origin/right-bottom.svg
new file mode 100644
index 000000000..c8ca71152
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/right-bottom.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>`right` + `bottom`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(200 200) rotate(45) translate(-200 -200)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="right bottom" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/right.png b/crates/resvg/tests/tests/structure/transform-origin/right.png
new file mode 100644
index 000000000..aa475c7bc
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/right.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/right.svg b/crates/resvg/tests/tests/structure/transform-origin/right.svg
new file mode 100644
index 000000000..39bd1ef4d
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/right.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>`right`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(200 100) rotate(45) translate(-200 -100)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="right" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/top-left.png b/crates/resvg/tests/tests/structure/transform-origin/top-left.png
new file mode 100644
index 000000000..a80726cdd
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/top-left.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/top-left.svg b/crates/resvg/tests/tests/structure/transform-origin/top-left.svg
new file mode 100644
index 000000000..d7e31b8ea
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/top-left.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>`top` + `left`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(0 0) rotate(45) translate(0 0)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="top left" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/top.png b/crates/resvg/tests/tests/structure/transform-origin/top.png
new file mode 100644
index 000000000..337452d0f
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/top.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/top.svg b/crates/resvg/tests/tests/structure/transform-origin/top.svg
new file mode 100644
index 000000000..0996f02ee
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/top.svg
@@ -0,0 +1,11 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>`top`</title>
+
+    <!-- should be covered -->
+    <path id="path1" transform="translate(100 0) rotate(45) translate(-100 0)" d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <rect id="rect1" transform="rotate(45)" transform-origin="top" x="80" y="80" width="40" height="40" fill="green"/>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>
diff --git a/crates/resvg/tests/tests/structure/transform-origin/transform-on-parent.png b/crates/resvg/tests/tests/structure/transform-origin/transform-on-parent.png
new file mode 100644
index 000000000..f4eec7c22
Binary files /dev/null and b/crates/resvg/tests/tests/structure/transform-origin/transform-on-parent.png differ
diff --git a/crates/resvg/tests/tests/structure/transform-origin/transform-on-parent.svg b/crates/resvg/tests/tests/structure/transform-origin/transform-on-parent.svg
new file mode 100644
index 000000000..cc19ca32a
--- /dev/null
+++ b/crates/resvg/tests/tests/structure/transform-origin/transform-on-parent.svg
@@ -0,0 +1,13 @@
+<svg id="svg1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
+    <title>no transform</title>
+
+    <!-- should be covered -->
+    <path id="path1"  d="M 80 80 L 120 80 L 120 120 L 80 120" fill="red"/>
+
+    <g transform="scale(2 2)">
+        <rect id="rect1" transform-origin="bottom right" x="40" y="40" width="20" height="20" fill="green"/>
+    </g>
+
+    <!-- image frame -->
+    <rect id="frame" x="1" y="1" width="198" height="198" fill="none" stroke="black"/>
+</svg>