Skip to main content

odox_core/
place.rs

1//! `draw:transform`: where a shape sits when a corner and a size cannot say it.
2//!
3//! Most shapes are placed by `svg:x`, `svg:y`, `svg:width` and `svg:height`, and
4//! that is a rectangle with its edges along the page's. A shape that is turned,
5//! or leaned over, cannot be described that way, so ODF gives it a list of
6//! operations instead — `rotate (-3.14159) translate (28cm 15.75cm)` — and
7//! writes no `svg:x` at all.
8//!
9//! A reader that wants the corner and cannot find it drops the shape. Across the
10//! presentation templates `LibreOffice` ships that is 137 shapes in ten
11//! documents, most of them the decoration that gives a template its identity, so
12//! the list has to be read.
13//!
14//! # The two things the specification does not spell out
15//!
16//! The operations apply to a point **left to right**, which is the opposite of
17//! the way SVG composes the same syntax, and the angle is in **radians** rather
18//! than degrees. Both were settled against the templates: `Bottom Bar White`
19//! decorates the bottom edge of its slide with a bar 0.7cm wide and 28cm tall
20//! under `rotate (-1.5707963) translate (28cm 15.05cm)`, and only one reading of
21//! the two puts it along the bottom.
22//!
23//! A rotation is counter-clockwise as a person sees it, and the page's y runs
24//! downwards, so the matrix is the transpose of the one a mathematics text
25//! writes.
26//
27// Author: David M. Anderson
28// Built with AI assistance (Claude, Anthropic)
29
30use crate::value::Length;
31
32/// An affine placement: where a shape's own coordinates land on the page.
33///
34/// The six numbers are the usual two-by-three, in the order `matrix()` writes
35/// them: `x' = a x + c y + e` and `y' = b x + d y + f`. The two offsets are in
36/// points, as every length in this crate is.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct Transform {
39    /// x from x.
40    pub a: f32,
41    /// y from x.
42    pub b: f32,
43    /// x from y.
44    pub c: f32,
45    /// y from y.
46    pub d: f32,
47    /// The x offset, in points.
48    pub e: f32,
49    /// The y offset, in points.
50    pub f: f32,
51}
52
53impl Default for Transform {
54    fn default() -> Self {
55        Self::IDENTITY
56    }
57}
58
59impl Transform {
60    /// The placement that moves nothing.
61    pub const IDENTITY: Self = Self {
62        a: 1.0,
63        b: 0.0,
64        c: 0.0,
65        d: 1.0,
66        e: 0.0,
67        f: 0.0,
68    };
69
70    /// Read a `draw:transform` attribute.
71    ///
72    /// `None` where nothing in it parsed, so that a shape carrying an attribute
73    /// this cannot read is placed by its corner rather than at the page's
74    /// origin. An operation this does not know is skipped and the rest is kept:
75    /// a shape drawn from three of its four operations is closer to right than a
76    /// shape not drawn.
77    #[must_use]
78    pub fn parse(text: &str) -> Option<Self> {
79        let mut placement = Self::IDENTITY;
80        let mut read = 0;
81        let mut rest = text;
82        while let Some(open) = rest.find('(') {
83            let name = rest[..open].trim().trim_start_matches(')').trim();
84            let Some(close) = rest[open..].find(')') else {
85                break;
86            };
87            let arguments = &rest[open + 1..open + close];
88            rest = &rest[open + close + 1..];
89
90            let numbers: Vec<f32> = arguments
91                .split([',', ' ', '\t', '\n'])
92                .filter(|word| !word.is_empty())
93                .filter_map(number)
94                .collect();
95            let Some(step) = step(name, &numbers) else {
96                continue;
97            };
98            // Left to right, so each operation acts on what the ones before it
99            // produced: the new step goes outside.
100            placement = placement.then(step);
101            read += 1;
102        }
103        (read > 0).then_some(placement)
104    }
105
106    /// This placement, and then `outer`.
107    #[must_use]
108    pub fn then(self, outer: Self) -> Self {
109        Self {
110            a: outer.a * self.a + outer.c * self.b,
111            b: outer.b * self.a + outer.d * self.b,
112            c: outer.a * self.c + outer.c * self.d,
113            d: outer.b * self.c + outer.d * self.d,
114            e: outer.a * self.e + outer.c * self.f + outer.e,
115            f: outer.b * self.e + outer.d * self.f + outer.f,
116        }
117    }
118
119    /// Where a point in the shape's own coordinates lands, in points.
120    #[must_use]
121    pub fn apply(self, (x, y): (f32, f32)) -> (f32, f32) {
122        (
123            self.a * x + self.c * y + self.e,
124            self.b * x + self.d * y + self.f,
125        )
126    }
127
128    /// Whether this turns or leans the shape, rather than only moving it.
129    ///
130    /// A placement that does neither can be reduced to a corner and a size,
131    /// which is what most of a renderer wants to be handed.
132    #[must_use]
133    pub fn is_upright(self) -> bool {
134        self.b.abs() < 1e-4 && self.c.abs() < 1e-4 && self.a > 0.0 && self.d > 0.0
135    }
136}
137
138/// One operation of the list, as a placement of its own.
139fn step(name: &str, numbers: &[f32]) -> Option<Transform> {
140    let at = |i: usize| numbers.get(i).copied();
141    Some(match name {
142        "translate" => Transform {
143            e: at(0)?,
144            f: at(1).unwrap_or(0.0),
145            ..Transform::IDENTITY
146        },
147        "scale" => {
148            let x = at(0)?;
149            Transform {
150                a: x,
151                d: at(1).unwrap_or(x),
152                ..Transform::IDENTITY
153            }
154        }
155        // Counter-clockwise on the page, whose y runs downwards.
156        "rotate" => {
157            let (sin, cos) = at(0)?.sin_cos();
158            Transform {
159                a: cos,
160                b: -sin,
161                c: sin,
162                d: cos,
163                ..Transform::IDENTITY
164            }
165        }
166        "skewX" => Transform {
167            c: at(0)?.tan(),
168            ..Transform::IDENTITY
169        },
170        "skewY" => Transform {
171            b: at(0)?.tan(),
172            ..Transform::IDENTITY
173        },
174        "matrix" => Transform {
175            a: at(0)?,
176            b: at(1)?,
177            c: at(2)?,
178            d: at(3)?,
179            e: at(4)?,
180            f: at(5)?,
181        },
182        _ => return None,
183    })
184}
185
186/// One argument. A translation's is a length and everything else's is a bare
187/// number, and a length with no unit is already in points, so both go through
188/// the same reader.
189fn number(word: &str) -> Option<f32> {
190    Length::parse(word).map_or_else(|| word.parse().ok(), |length| Some(length.points()))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::Transform;
196
197    fn near(left: (f32, f32), right: (f32, f32)) -> bool {
198        (left.0 - right.0).abs() < 0.01 && (left.1 - right.1).abs() < 0.01
199    }
200
201    #[test]
202    fn a_half_turn_puts_the_far_corner_where_the_near_one_was() {
203        // Leaf White 2's decoration: 8.834cm by 15.086cm, turned about and moved
204        // to the bottom right corner of a 28cm by 15.75cm slide.
205        let placed = Transform::parse("rotate (-3.14159265358979) translate (28cm 15.75cm)")
206            .expect("a readable transform");
207        let corner = |x: f32, y: f32| {
208            let (x, y) = placed.apply((x * 28.3465, y * 28.3465));
209            (x / 28.3465, y / 28.3465)
210        };
211        assert!(
212            near(corner(0.0, 0.0), (28.0, 15.75)),
213            "{:?}",
214            corner(0.0, 0.0)
215        );
216        assert!(
217            near(corner(8.834, 15.086), (19.166, 0.664)),
218            "{:?}",
219            corner(8.834, 15.086)
220        );
221    }
222
223    #[test]
224    fn a_quarter_turn_lays_a_tall_bar_along_the_bottom() {
225        // Bottom Bar White: 0.7cm wide and 28cm tall, which is only a bottom bar
226        // if the operations apply left to right.
227        let placed = Transform::parse("rotate (-1.5707963267949) translate (28cm 15.05cm)")
228            .expect("a readable transform");
229        let corner = |x: f32, y: f32| {
230            let (x, y) = placed.apply((x * 28.3465, y * 28.3465));
231            (x / 28.3465, y / 28.3465)
232        };
233        assert!(
234            near(corner(0.0, 0.0), (28.0, 15.05)),
235            "{:?}",
236            corner(0.0, 0.0)
237        );
238        assert!(
239            near(corner(0.7, 28.0), (0.0, 15.75)),
240            "{:?}",
241            corner(0.7, 28.0)
242        );
243    }
244
245    #[test]
246    fn a_translation_alone_is_upright_and_a_turn_is_not() {
247        let moved = Transform::parse("translate (2cm 3cm)").expect("a readable transform");
248        assert!(moved.is_upright());
249        assert!(near(moved.apply((0.0, 0.0)), (56.693, 85.039)));
250        let turned = Transform::parse("rotate (0.5) translate (0cm 0cm)").expect("readable");
251        assert!(!turned.is_upright());
252    }
253
254    #[test]
255    fn an_operation_with_no_name_this_knows_leaves_the_others_standing() {
256        let placed = Transform::parse("wobble (3) translate (1cm 0cm)").expect("the translation");
257        assert!(near(placed.apply((0.0, 0.0)), (28.3465, 0.0)));
258        assert!(Transform::parse("wobble (3)").is_none());
259    }
260}