Skip to main content

morph_ir/
transforms.rs

1//! CSS `transform` / `transform-origin` parsing and matrix composition.
2//!
3//! Mirrors `morph/style/transforms.py` (`parse_transform`,
4//! `compose_transform`) and the builder's `_parse_transform_origin` so the
5//! Rust pipeline resolves transforms at build time instead of shipping CSS
6//! strings to the runtime.
7
8use 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
37/// Parse a CSS `transform` value into ops.
38///
39/// Returns `Some(vec![])` for `none` / global keywords (no transform),
40/// `Some(ops)` for a valid function list, and `None` for invalid values
41/// (the property must be ignored), mirroring Python's `parse_transform`.
42pub 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    // grad BEFORE rad — "100grad" ends with "rad".
112    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
289// ── 4x4 Matrix math (column-major, 16 floats) ──────────────────
290
291fn 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
422/// Compose parsed ops into a single column-major 4x4 matrix.
423///
424/// `own_w` / `own_h` are the element's border-box size, used to resolve `%`
425/// lengths in translate functions (0.0 when the box is unknown at build time).
426pub 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
445// ── transform-origin ───────────────────────────────────────────
446
447/// Parse a CSS `transform-origin` value into a raw `((x, is_pct), (y, is_pct))`
448/// pair plus the fraction already resolvable without the element box.
449///
450/// Resolved is `Some` only when both axes are keywords or percentages;
451/// plain lengths need the element box (unknown at build time) and stay
452/// `None`, leaving the runtime's default center origin.
453pub 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        // Mirrors Python's compose_transform for `translateX(10px) rotate(90deg)`
572        // (column-major): m = [0,1,0,0,-1,0,0,0,0,0,1,0,10,0,0,1].
573        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        // Expected matrices captured from morph/style/transforms.py.
584        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}