1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum LengthUnit {
12 Px,
13 Pct,
14}
15
16pub type LengthComp = (f32, LengthUnit);
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
19pub enum TransformOp {
20 Matrix([f32; 6]),
21 Matrix3d([f32; 16]),
22 Perspective(f32),
23 Rotate(f32),
24 RotateX(f32),
25 RotateY(f32),
26 RotateZ(f32),
27 Rotate3d(f32, f32, f32, f32),
28 Translate(LengthComp, LengthComp),
29 Translate3d(LengthComp, LengthComp, LengthComp),
30 Scale(f32, f32),
31 Scale3d(f32, f32, f32),
32 Skew(f32, f32),
33}
34
35const GLOBAL_KEYWORDS: &[&str] = &["inherit", "initial", "revert", "revert-layer", "unset"];
36
37pub fn parse_transform(value: &str) -> Option<Vec<TransformOp>> {
43 let s = value.trim();
44 if s.is_empty() {
45 return None;
46 }
47 let low = s.to_ascii_lowercase();
48 if low == "none" || GLOBAL_KEYWORDS.contains(&low.as_str()) {
49 return Some(Vec::new());
50 }
51 if low.starts_with("none") {
52 return None;
53 }
54
55 let bytes = s.as_bytes();
56 let n = bytes.len();
57 let mut ops: Vec<TransformOp> = Vec::new();
58 let mut i = 0usize;
59 while i < n {
60 while i < n && bytes[i].is_ascii_whitespace() {
61 i += 1;
62 }
63 if i >= n {
64 break;
65 }
66 let start = i;
67 while i < n && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-') {
68 i += 1;
69 }
70 let name = s[start..i].to_ascii_lowercase();
71 while i < n && bytes[i].is_ascii_whitespace() {
72 i += 1;
73 }
74 if i >= n || bytes[i] != b'(' {
75 return None;
76 }
77 let mut depth = 1i32;
78 let mut j = i + 1;
79 while j < n && depth > 0 {
80 if bytes[j] == b'(' {
81 depth += 1;
82 } else if bytes[j] == b')' {
83 depth -= 1;
84 }
85 j += 1;
86 }
87 if depth != 0 {
88 return None;
89 }
90 let inner = &s[i + 1..j - 1];
91 let args = split_args(inner);
92 ops.push(build_op(&name, &args)?);
93 i = j;
94 }
95 Some(ops)
96}
97
98fn split_args(inner: &str) -> Vec<String> {
99 if inner.contains(',') {
100 inner.split(',').map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
101 } else {
102 inner.split_whitespace().map(str::to_string).collect()
103 }
104}
105
106fn angle_to_deg(token: &str) -> Option<f32> {
107 let s = token.trim().to_ascii_lowercase();
108 if let Some(rest) = s.strip_suffix("deg") {
109 return rest.trim().parse().ok();
110 }
111 if let Some(rest) = s.strip_suffix("grad") {
113 return rest.trim().parse().ok().map(|v: f32| v * 0.9);
114 }
115 if let Some(rest) = s.strip_suffix("rad") {
116 return rest.trim().parse().ok().map(|v: f32| v.to_degrees());
117 }
118 if let Some(rest) = s.strip_suffix("turn") {
119 return rest.trim().parse().ok().map(|v: f32| v * 360.0);
120 }
121 s.trim().parse().ok()
122}
123
124fn length_to_component(token: &str) -> Option<LengthComp> {
125 let s = token.trim().to_ascii_lowercase();
126 if let Some(rest) = s.strip_suffix('%') {
127 return rest.trim().parse().ok().map(|v| (v, LengthUnit::Pct));
128 }
129 if let Some(rest) = s.strip_suffix("px") {
130 return rest.trim().parse().ok().map(|v| (v, LengthUnit::Px));
131 }
132 s.parse().ok().map(|v| (v, LengthUnit::Px))
133}
134
135fn build_op(name: &str, args: &[String]) -> Option<TransformOp> {
136 let one = |i: usize| -> Option<f32> {
137 let v = args.get(i)?;
138 v.trim().parse().ok()
139 };
140 match name {
141 "matrix" => {
142 if args.len() != 6 {
143 return None;
144 }
145 let mut m = [0.0f32; 6];
146 for (i, slot) in m.iter_mut().enumerate() {
147 *slot = one(i)?;
148 }
149 Some(TransformOp::Matrix(m))
150 }
151 "matrix3d" => {
152 if args.len() != 16 {
153 return None;
154 }
155 let mut m = [0.0f32; 16];
156 for (i, slot) in m.iter_mut().enumerate() {
157 *slot = one(i)?;
158 }
159 Some(TransformOp::Matrix3d(m))
160 }
161 "perspective" => {
162 if args.len() != 1 {
163 return None;
164 }
165 let comp = length_to_component(&args[0])?;
166 if comp.1 == LengthUnit::Pct {
167 return None;
168 }
169 Some(TransformOp::Perspective(comp.0))
170 }
171 "rotate" => angle_to_deg(&args[0]).map(TransformOp::Rotate),
172 "rotatex" => angle_to_deg(&args[0]).map(TransformOp::RotateX),
173 "rotatey" => angle_to_deg(&args[0]).map(TransformOp::RotateY),
174 "rotatez" => angle_to_deg(&args[0]).map(TransformOp::RotateZ),
175 "rotate3d" => {
176 if args.len() != 4 {
177 return None;
178 }
179 let x = one(0)?;
180 let y = one(1)?;
181 let z = one(2)?;
182 let deg = angle_to_deg(&args[3])?;
183 Some(TransformOp::Rotate3d(x, y, z, deg))
184 }
185 "translate" => {
186 if args.is_empty() || args.len() > 2 {
187 return None;
188 }
189 let tx = length_to_component(&args[0])?;
190 let ty = if args.len() == 2 {
191 length_to_component(&args[1])?
192 } else {
193 (0.0, LengthUnit::Px)
194 };
195 Some(TransformOp::Translate(tx, ty))
196 }
197 "translatex" => {
198 if args.len() != 1 {
199 return None;
200 }
201 let tx = length_to_component(&args[0])?;
202 Some(TransformOp::Translate(tx, (0.0, LengthUnit::Px)))
203 }
204 "translatey" => {
205 if args.len() != 1 {
206 return None;
207 }
208 let ty = length_to_component(&args[0])?;
209 Some(TransformOp::Translate((0.0, LengthUnit::Px), ty))
210 }
211 "translate3d" => {
212 if args.len() != 3 {
213 return None;
214 }
215 let tx = length_to_component(&args[0])?;
216 let ty = length_to_component(&args[1])?;
217 let tz = length_to_component(&args[2])?;
218 if tz.1 == LengthUnit::Pct {
219 return None;
220 }
221 Some(TransformOp::Translate3d(tx, ty, tz))
222 }
223 "translatez" => {
224 if args.len() != 1 {
225 return None;
226 }
227 let tz = length_to_component(&args[0])?;
228 if tz.1 == LengthUnit::Pct {
229 return None;
230 }
231 Some(TransformOp::Translate3d((0.0, LengthUnit::Px), (0.0, LengthUnit::Px), tz))
232 }
233 "scale" => {
234 if args.is_empty() || args.len() > 2 {
235 return None;
236 }
237 let sx = one(0)?;
238 let sy = if args.len() == 2 { one(1)? } else { sx };
239 Some(TransformOp::Scale(sx, sy))
240 }
241 "scalex" => {
242 if args.len() != 1 {
243 return None;
244 }
245 Some(TransformOp::Scale(one(0)?, 1.0))
246 }
247 "scaley" => {
248 if args.len() != 1 {
249 return None;
250 }
251 Some(TransformOp::Scale(1.0, one(0)?))
252 }
253 "scale3d" => {
254 if args.len() != 3 {
255 return None;
256 }
257 Some(TransformOp::Scale3d(one(0)?, one(1)?, one(2)?))
258 }
259 "scalez" => {
260 if args.len() != 1 {
261 return None;
262 }
263 Some(TransformOp::Scale3d(1.0, 1.0, one(0)?))
264 }
265 "skew" => {
266 if args.is_empty() || args.len() > 2 {
267 return None;
268 }
269 let ax = angle_to_deg(&args[0])?;
270 let ay = if args.len() == 2 { angle_to_deg(&args[1])? } else { 0.0 };
271 Some(TransformOp::Skew(ax, ay))
272 }
273 "skewx" => {
274 if args.len() != 1 {
275 return None;
276 }
277 Some(TransformOp::Skew(angle_to_deg(&args[0])?, 0.0))
278 }
279 "skewy" => {
280 if args.len() != 1 {
281 return None;
282 }
283 Some(TransformOp::Skew(0.0, angle_to_deg(&args[0])?))
284 }
285 _ => None,
286 }
287}
288
289fn identity() -> [f32; 16] {
292 [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
293}
294
295fn multiply(a: [f32; 16], b: [f32; 16]) -> [f32; 16] {
296 let mut out = [0.0f32; 16];
297 for col in 0..4 {
298 for row in 0..4 {
299 let mut acc = 0.0f32;
300 for k in 0..4 {
301 acc += a[k * 4 + row] * b[col * 4 + k];
302 }
303 out[col * 4 + row] = acc;
304 }
305 }
306 out
307}
308
309fn translate(x: f32, y: f32, z: f32) -> [f32; 16] {
310 let mut m = identity();
311 m[12] = x;
312 m[13] = y;
313 m[14] = z;
314 m
315}
316
317fn scale(x: f32, y: f32, z: f32) -> [f32; 16] {
318 let mut m = identity();
319 m[0] = x;
320 m[5] = y;
321 m[10] = z;
322 m
323}
324
325fn rotate_x(deg: f32) -> [f32; 16] {
326 let a = deg.to_radians();
327 let (c, s) = (a.cos(), a.sin());
328 [1.0, 0.0, 0.0, 0.0, 0.0, c, s, 0.0, 0.0, -s, c, 0.0, 0.0, 0.0, 0.0, 1.0]
329}
330
331fn rotate_y(deg: f32) -> [f32; 16] {
332 let a = deg.to_radians();
333 let (c, s) = (a.cos(), a.sin());
334 [c, 0.0, -s, 0.0, 0.0, 1.0, 0.0, 0.0, s, 0.0, c, 0.0, 0.0, 0.0, 0.0, 1.0]
335}
336
337fn rotate_z(deg: f32) -> [f32; 16] {
338 let a = deg.to_radians();
339 let (c, s) = (a.cos(), a.sin());
340 [c, s, 0.0, 0.0, -s, c, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]
341}
342
343fn rotate_axis(x: f32, y: f32, z: f32, deg: f32) -> [f32; 16] {
344 let length = (x * x + y * y + z * z).sqrt();
345 if length < 1e-12 {
346 return identity();
347 }
348 let (x, y, z) = (x / length, y / length, z / length);
349 let a = deg.to_radians();
350 let (c, s) = (a.cos(), a.sin());
351 let t = 1.0 - c;
352 [
353 t * x * x + c,
354 t * x * y + s * z,
355 t * x * z - s * y,
356 0.0,
357 t * x * y - s * z,
358 t * y * y + c,
359 t * y * z + s * x,
360 0.0,
361 t * x * z + s * y,
362 t * y * z - s * x,
363 t * z * z + c,
364 0.0,
365 0.0,
366 0.0,
367 0.0,
368 1.0,
369 ]
370}
371
372fn skew_x(deg: f32) -> [f32; 16] {
373 let mut m = identity();
374 m[4] = deg.to_radians().tan();
375 m
376}
377
378fn skew_y(deg: f32) -> [f32; 16] {
379 let mut m = identity();
380 m[1] = deg.to_radians().tan();
381 m
382}
383
384fn perspective(d: f32) -> [f32; 16] {
385 if d <= 0.0 {
386 return identity();
387 }
388 let mut m = identity();
389 m[11] = -1.0 / d;
390 m
391}
392
393fn matrix6(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> [f32; 16] {
394 [a, b, 0.0, 0.0, c, d, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, e, f, 0.0, 1.0]
395}
396
397fn op_composer(op: &TransformOp) -> [f32; 16] {
398 match *op {
399 TransformOp::Matrix([a, b, c, d, e, f]) => matrix6(a, b, c, d, e, f),
400 TransformOp::Matrix3d(m) => m,
401 TransformOp::Perspective(d) => perspective(d),
402 TransformOp::Rotate(deg) => rotate_z(deg),
403 TransformOp::RotateX(deg) => rotate_x(deg),
404 TransformOp::RotateY(deg) => rotate_y(deg),
405 TransformOp::RotateZ(deg) => rotate_z(deg),
406 TransformOp::Rotate3d(x, y, z, deg) => rotate_axis(x, y, z, deg),
407 TransformOp::Scale(x, y) => scale(x, y, 1.0),
408 TransformOp::Scale3d(x, y, z) => scale(x, y, z),
409 TransformOp::Skew(ax, ay) => multiply(skew_x(ax), skew_y(ay)),
410 TransformOp::Translate(_, _) | TransformOp::Translate3d(_, _, _) => identity(),
411 }
412}
413
414fn resolve_length(comp: LengthComp, own: f32) -> f32 {
415 let (value, unit) = comp;
416 match unit {
417 LengthUnit::Pct => value / 100.0 * own,
418 LengthUnit::Px => value,
419 }
420}
421
422pub fn compose_transform(ops: &[TransformOp], own_w: f32, own_h: f32) -> [f32; 16] {
427 let mut m = identity();
428 for op in ops {
429 let op_m = match op {
430 TransformOp::Translate(tx, ty) => {
431 translate(resolve_length(*tx, own_w), resolve_length(*ty, own_h), 0.0)
432 }
433 TransformOp::Translate3d(tx, ty, tz) => translate(
434 resolve_length(*tx, own_w),
435 resolve_length(*ty, own_h),
436 resolve_length(*tz, own_h),
437 ),
438 other => op_composer(other),
439 };
440 m = multiply(m, op_m);
441 }
442 m
443}
444
445pub fn parse_transform_origin(
454 value: &str,
455) -> Option<(((f32, bool), (f32, bool)), Option<(f32, f32)>)> {
456 let parts: Vec<&str> = value.split_whitespace().collect();
457 if parts.is_empty() || parts.len() > 2 {
458 return None;
459 }
460
461 fn axis(token: &str) -> Option<((f32, bool), Option<f32>)> {
462 let k = token.trim().to_ascii_lowercase();
463 match k.as_str() {
464 "left" | "top" => Some(((0.0, false), Some(0.0))),
465 "center" => Some(((0.5, false), Some(0.5))),
466 "right" | "bottom" => Some(((1.0, false), Some(1.0))),
467 _ => {
468 if let Some(pct) = k.strip_suffix('%') {
469 let v: f32 = pct.trim().parse().ok()?;
470 return Some(((v, true), Some(v / 100.0)));
471 }
472 let num = k.strip_suffix("px").unwrap_or(&k);
473 let v: f32 = num.trim().parse().ok()?;
474 Some(((v, false), None))
475 }
476 }
477 }
478
479 let (x_raw, x_fx) = axis(parts[0])?;
480 let (y_raw, y_fx) = if parts.len() == 2 { axis(parts[1])? } else { ((0.5, false), Some(0.5)) };
481 let resolved = match (x_fx, y_fx) {
482 (Some(x), Some(y)) => Some((x, y)),
483 _ => None,
484 };
485 Some(((x_raw, y_raw), resolved))
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 fn assert_close(a: f32, b: f32, eps: f32) {
493 assert!((a - b).abs() < eps, "{a} != {b}");
494 }
495
496 #[test]
497 fn parse_transform_none_and_keywords() {
498 assert_eq!(parse_transform("none"), Some(vec![]));
499 assert_eq!(parse_transform("inherit"), Some(vec![]));
500 assert_eq!(parse_transform(" "), None);
501 assert_eq!(parse_transform("none garbage"), None);
502 }
503
504 #[test]
505 fn parse_transform_translate_px() {
506 let ops = parse_transform("translateY(-1px)").unwrap();
507 assert_eq!(
508 ops,
509 vec![TransformOp::Translate((0.0, LengthUnit::Px), (-1.0, LengthUnit::Px))]
510 );
511 }
512
513 #[test]
514 fn parse_transform_comma_and_space_args() {
515 let a = parse_transform("translate(10px, 20px)").unwrap();
516 let b = parse_transform("translate(10px 20px)").unwrap();
517 assert_eq!(a, b);
518 }
519
520 #[test]
521 fn parse_transform_angles_units() {
522 let deg = parse_transform("rotate(90deg)").unwrap();
523 let rad = parse_transform("rotate(1.5707963rad)").unwrap();
524 let turn = parse_transform("rotate(0.25turn)").unwrap();
525 assert_eq!(deg, vec![TransformOp::Rotate(90.0)]);
526 match (&rad[0], &turn[0]) {
527 (TransformOp::Rotate(r), TransformOp::Rotate(t)) => {
528 assert_close(*r, 90.0, 0.01);
529 assert_close(*t, 90.0, 0.01);
530 }
531 _ => panic!("expected Rotate ops"),
532 }
533 }
534
535 #[test]
536 fn parse_transform_invalid() {
537 assert!(parse_transform("translateY(-1px").is_none(), "unbalanced parens must be rejected");
538 assert!(parse_transform("scale()").is_none());
539 assert!(parse_transform("foo(1px)").is_none(), "unknown function");
540 }
541
542 #[test]
543 fn compose_translate_px() {
544 let ops = parse_transform("translate(10px, 20px)").unwrap();
545 let m = compose_transform(&ops, 0.0, 0.0);
546 assert_close(m[12], 10.0, 1e-5);
547 assert_close(m[13], 20.0, 1e-5);
548 assert_close(m[0], 1.0, 1e-5);
549 }
550
551 #[test]
552 fn compose_translate_pct_uses_own_box() {
553 let ops = parse_transform("translate(50%, 25%)").unwrap();
554 let m = compose_transform(&ops, 200.0, 100.0);
555 assert_close(m[12], 100.0, 1e-4);
556 assert_close(m[13], 25.0, 1e-4);
557 }
558
559 #[test]
560 fn compose_rotate_matches_math() {
561 let ops = parse_transform("rotate(90deg)").unwrap();
562 let m = compose_transform(&ops, 0.0, 0.0);
563 assert_close(m[0], 0.0, 1e-5);
564 assert_close(m[1], 1.0, 1e-5);
565 assert_close(m[4], -1.0, 1e-5);
566 assert_close(m[5], 0.0, 1e-5);
567 }
568
569 #[test]
570 fn compose_chain_order() {
571 let ops = parse_transform("translateX(10px) rotate(90deg)").unwrap();
574 let m = compose_transform(&ops, 0.0, 0.0);
575 let x = m[0] * 10.0 + m[12];
576 let y = m[1] * 10.0 + m[13];
577 assert_close(x, 10.0, 1e-4);
578 assert_close(y, 10.0, 1e-4);
579 }
580
581 #[test]
582 fn compose_matches_python_output() {
583 let expected: &[(&str, [f32; 16])] = &[
585 (
586 "translate(10px, -20px) rotate(30deg) scale(1.5, 2) skewX(15deg)",
587 [
588 1.299038, 0.75, 0.0, 0.0, -0.651924, 1.933013, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0,
589 10.0, -20.0, 0.0, 1.0,
590 ],
591 ),
592 (
593 "matrix(1, 0.2, 0.3, 1, 5, 6)",
594 [1.0, 0.2, 0.0, 0.0, 0.3, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 5.0, 6.0, 0.0, 1.0],
595 ),
596 (
597 "rotate3d(0, 0, 1, 45deg) translateX(-5px)",
598 [
599 0.707107, 0.707107, 0.0, 0.0, -0.707107, 0.707107, 0.0, 0.0, 0.0, 0.0, 1.0,
600 0.0, -3.535534, -3.535534, 0.0, 1.0,
601 ],
602 ),
603 ];
604 for (css, want) in expected {
605 let ops = parse_transform(css).unwrap();
606 let got = compose_transform(&ops, 0.0, 0.0);
607 for i in 0..16 {
608 assert_close(got[i], want[i], 1e-4);
609 }
610 }
611 }
612
613 #[test]
614 fn compose_scale_and_skew() {
615 let ops = parse_transform("scale(2, 3)").unwrap();
616 let m = compose_transform(&ops, 0.0, 0.0);
617 assert_close(m[0], 2.0, 1e-5);
618 assert_close(m[5], 3.0, 1e-5);
619
620 let ops = parse_transform("skewY(45deg)").unwrap();
621 let m = compose_transform(&ops, 0.0, 0.0);
622 assert_close(m[1], 1.0, 1e-5);
623 }
624
625 #[test]
626 fn parse_origin_keywords_and_pct() {
627 let (raw, resolved) = parse_transform_origin("left top").unwrap();
628 assert_eq!(raw, ((0.0, false), (0.0, false)));
629 assert_eq!(resolved, Some((0.0, 0.0)));
630
631 let (_, resolved) = parse_transform_origin("center").unwrap();
632 assert_eq!(resolved, Some((0.5, 0.5)));
633
634 let (raw, resolved) = parse_transform_origin("25% 75%").unwrap();
635 assert_eq!(raw, ((25.0, true), (75.0, true)));
636 assert_eq!(resolved, Some((0.25, 0.75)));
637
638 let (_, resolved) = parse_transform_origin("10px 20px").unwrap();
639 assert_eq!(resolved, None, "px origins need the element box");
640 }
641}