Skip to main content

oxml_layout/
transform.rs

1//! Format-neutral two-dimensional affine transforms.
2
3use crate::{Point, Rect};
4
5/// A 2x3 affine transform using the PDF matrix coefficient order.
6///
7/// Points are transformed as `x' = a*x + c*y + e` and
8/// `y' = b*x + d*y + f`.
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct Transform {
11    pub a: f64,
12    pub b: f64,
13    pub c: f64,
14    pub d: f64,
15    pub e: f64,
16    pub f: f64,
17}
18
19impl Transform {
20    /// The affine identity transform.
21    pub const IDENTITY: Transform = Transform {
22        a: 1.0,
23        b: 0.0,
24        c: 0.0,
25        d: 1.0,
26        e: 0.0,
27        f: 0.0,
28    };
29
30    /// Return a rotation in degrees about `(cx, cy)`.
31    pub fn rotate_about(degrees: f64, cx: f64, cy: f64) -> Transform {
32        let (sin, cos) = degrees.to_radians().sin_cos();
33        Transform {
34            a: cos,
35            b: sin,
36            c: -sin,
37            d: cos,
38            e: cx - cos * cx + sin * cy,
39            f: cy - sin * cx - cos * cy,
40        }
41    }
42
43    /// Compose two transforms, applying `self` first and `next` second.
44    pub fn then(self, next: Transform) -> Transform {
45        Transform {
46            a: next.a * self.a + next.c * self.b,
47            b: next.b * self.a + next.d * self.b,
48            c: next.a * self.c + next.c * self.d,
49            d: next.b * self.c + next.d * self.d,
50            e: next.a * self.e + next.c * self.f + next.e,
51            f: next.b * self.e + next.d * self.f + next.f,
52        }
53    }
54
55    /// Apply this transform to a point.
56    pub fn apply(self, point: Point) -> Point {
57        Point {
58            x: self.a * point.x + self.c * point.y + self.e,
59            y: self.b * point.x + self.d * point.y + self.f,
60        }
61    }
62
63    /// Return whether all six coefficients exactly equal the identity.
64    pub fn is_identity(self) -> bool {
65        self == Self::IDENTITY
66    }
67
68    /// Return the axis-aligned bounds of the four transformed corners.
69    pub fn transform_rect_bbox(self, rect: Rect) -> Rect {
70        let corners = [
71            self.apply(Point {
72                x: rect.x,
73                y: rect.y,
74            }),
75            self.apply(Point {
76                x: rect.x + rect.width,
77                y: rect.y,
78            }),
79            self.apply(Point {
80                x: rect.x,
81                y: rect.y + rect.height,
82            }),
83            self.apply(Point {
84                x: rect.x + rect.width,
85                y: rect.y + rect.height,
86            }),
87        ];
88
89        let mut min_x = corners[0].x;
90        let mut min_y = corners[0].y;
91        let mut max_x = corners[0].x;
92        let mut max_y = corners[0].y;
93        for corner in &corners[1..] {
94            min_x = min_x.min(corner.x);
95            min_y = min_y.min(corner.y);
96            max_x = max_x.max(corner.x);
97            max_y = max_y.max(corner.y);
98        }
99
100        Rect {
101            x: min_x,
102            y: min_y,
103            width: max_x - min_x,
104            height: max_y - min_y,
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::Transform;
112    use crate::{Point, Rect};
113
114    const EPSILON: f64 = 1.0e-10;
115
116    fn assert_close(actual: f64, expected: f64) {
117        assert!(
118            (actual - expected).abs() < EPSILON,
119            "expected {expected}, got {actual}"
120        );
121    }
122
123    fn assert_point_close(actual: Point, expected: Point) {
124        assert_close(actual.x, expected.x);
125        assert_close(actual.y, expected.y);
126    }
127
128    #[test]
129    fn identity_is_neutral_for_points_and_composition() {
130        let transform = Transform {
131            a: 2.0,
132            b: 3.0,
133            c: 5.0,
134            d: 7.0,
135            e: 11.0,
136            f: 13.0,
137        };
138        let point = Point { x: 17.0, y: 19.0 };
139
140        assert_eq!(Transform::IDENTITY.apply(point), point);
141        assert_eq!(Transform::IDENTITY.then(transform), transform);
142        assert_eq!(transform.then(Transform::IDENTITY), transform);
143    }
144
145    #[test]
146    fn rotate_about_keeps_the_pivot_fixed() {
147        let fractional = Transform::rotate_about(33.5, 10.0, 20.0);
148        assert_point_close(
149            fractional.apply(Point { x: 10.0, y: 20.0 }),
150            Point { x: 10.0, y: 20.0 },
151        );
152
153        let quarter_turn = Transform::rotate_about(90.0, 10.0, 20.0);
154        assert_point_close(
155            quarter_turn.apply(Point { x: 11.0, y: 20.0 }),
156            Point { x: 10.0, y: 21.0 },
157        );
158    }
159
160    #[test]
161    fn then_matches_the_pdf_cm_composition_order() {
162        let first = Transform {
163            a: 2.0,
164            b: 3.0,
165            c: 5.0,
166            d: 7.0,
167            e: 11.0,
168            f: 13.0,
169        };
170        let next = Transform {
171            a: 17.0,
172            b: 19.0,
173            c: 23.0,
174            d: 29.0,
175            e: 31.0,
176            f: 37.0,
177        };
178
179        // PDF `cm` composition for first then next is next * first:
180        // [103 125 246 298 517 623].
181        let combined = first.then(next);
182        assert_eq!(
183            combined,
184            Transform {
185                a: 103.0,
186                b: 125.0,
187                c: 246.0,
188                d: 298.0,
189                e: 517.0,
190                f: 623.0,
191            }
192        );
193        assert_eq!(
194            combined.apply(Point { x: 4.0, y: 3.0 }),
195            Point {
196                x: 1667.0,
197                y: 2017.0
198            }
199        );
200        assert_eq!(
201            combined.apply(Point { x: 4.0, y: 3.0 }),
202            next.apply(first.apply(Point { x: 4.0, y: 3.0 }))
203        );
204    }
205
206    #[test]
207    fn transform_rect_bbox_contains_all_four_transformed_corners() {
208        let transform = Transform::rotate_about(-30.0, 0.0, 0.0);
209        let rect = Rect {
210            x: 1.0,
211            y: 2.0,
212            width: 3.0,
213            height: 4.0,
214        };
215        let bounds = transform.transform_rect_bbox(rect);
216        let cos = 3.0_f64.sqrt() / 2.0;
217
218        // At -30 degrees, the top-left supplies min x, the top-right supplies
219        // min y, the bottom-left supplies max y, and the bottom-right max x.
220        assert_close(bounds.x, cos + 1.0);
221        assert_close(bounds.y, -2.0 + 2.0 * cos);
222        assert_close(bounds.width, 3.0 * cos + 2.0);
223        assert_close(bounds.height, 1.5 + 4.0 * cos);
224
225        for corner in [
226            Point {
227                x: rect.x,
228                y: rect.y,
229            },
230            Point {
231                x: rect.x + rect.width,
232                y: rect.y,
233            },
234            Point {
235                x: rect.x,
236                y: rect.y + rect.height,
237            },
238            Point {
239                x: rect.x + rect.width,
240                y: rect.y + rect.height,
241            },
242        ] {
243            let point = transform.apply(corner);
244            assert!(point.x >= bounds.x - EPSILON);
245            assert!(point.x <= bounds.x + bounds.width + EPSILON);
246            assert!(point.y >= bounds.y - EPSILON);
247            assert!(point.y <= bounds.y + bounds.height + EPSILON);
248        }
249    }
250
251    #[test]
252    fn is_identity_is_exact() {
253        assert!(Transform::IDENTITY.is_identity());
254        for near_identity in [
255            Transform {
256                a: 1.0 + f64::EPSILON,
257                ..Transform::IDENTITY
258            },
259            Transform {
260                b: f64::EPSILON,
261                ..Transform::IDENTITY
262            },
263            Transform {
264                c: f64::EPSILON,
265                ..Transform::IDENTITY
266            },
267            Transform {
268                d: 1.0 + f64::EPSILON,
269                ..Transform::IDENTITY
270            },
271            Transform {
272                e: f64::EPSILON,
273                ..Transform::IDENTITY
274            },
275            Transform {
276                f: f64::EPSILON,
277                ..Transform::IDENTITY
278            },
279        ] {
280            assert!(!near_identity.is_identity());
281        }
282    }
283}