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}