Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.44% covered (success)
97.44%
76 / 78
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Transform
97.44% covered (success)
97.44%
76 / 78
66.67% covered (warning)
66.67%
4 / 6
34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
8
 toMatrix
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 makeFunction
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
20
 parseNumberList
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 multiply
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Svg\Value;
6
7use Phpdftk\Svg\Value\Transform\Matrix;
8use Phpdftk\Svg\Value\Transform\Rotate;
9use Phpdftk\Svg\Value\Transform\Scale;
10use Phpdftk\Svg\Value\Transform\SkewX;
11use Phpdftk\Svg\Value\Transform\SkewY;
12use Phpdftk\Svg\Value\Transform\Translate;
13
14/**
15 * The parsed value of an SVG 2 `transform` attribute (SVG 2 §8.4) — an ordered
16 * list of `TransformFunction` operations. Compose them via `toMatrix()` to get
17 * the flat 3×2 affine matrix the painter feeds to the PDF `cm` operator.
18 *
19 * Composition is left-to-right per SVG 2: a list `[A, B, C]` produces matrix
20 * `A · B · C`, so `C` is applied to a point first, then `B`, then `A`. From
21 * the coordinate-system's perspective, the system is transformed in
22 * left-to-right order.
23 */
24final class Transform
25{
26    /** @param list<TransformFunction> $functions */
27    public function __construct(public readonly array $functions) {}
28
29    /**
30     * Parse an SVG 2 transform-attribute string. Throws on malformed input —
31     * callers (typically `Element::transform()`) that want SVG's
32     * "invalid → ignored" semantics should catch and substitute null.
33     *
34     * @throws \InvalidArgumentException when the grammar isn't satisfied
35     */
36    public static function parse(string $raw): self
37    {
38        $functions = [];
39        $offset = 0;
40        $len = strlen($raw);
41        while ($offset < $len) {
42            while ($offset < $len && (ctype_space($raw[$offset]) || $raw[$offset] === ',')) {
43                $offset++;
44            }
45            if ($offset >= $len) {
46                break;
47            }
48            if (preg_match('/\G([a-zA-Z]+)\s*\(/', $raw, $m, 0, $offset) !== 1) {
49                throw new \InvalidArgumentException(
50                    sprintf('Malformed transform at offset %d: expected function name.', $offset),
51                );
52            }
53            $name = $m[1];
54            $offset += strlen($m[0]);
55            $close = strpos($raw, ')', $offset);
56            if ($close === false) {
57                throw new \InvalidArgumentException(
58                    sprintf('Malformed transform: unterminated `%s(`.', $name),
59                );
60            }
61            $args = self::parseNumberList(substr($raw, $offset, $close - $offset));
62            $offset = $close + 1;
63            $functions[] = self::makeFunction($name, $args);
64        }
65        return new self($functions);
66    }
67
68    /**
69     * Compose the function list and return the resulting affine matrix.
70     * Identity for an empty list.
71     *
72     * @return array{float, float, float, float, float, float}
73     */
74    public function toMatrix(): array
75    {
76        $m = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
77        foreach ($this->functions as $fn) {
78            $m = self::multiply($m, $fn->toMatrix());
79        }
80        return $m;
81    }
82
83    /**
84     * @param list<float> $args
85     */
86    private static function makeFunction(string $name, array $args): TransformFunction
87    {
88        return match ($name) {
89            'matrix' => count($args) === 6
90                ? new Matrix($args[0], $args[1], $args[2], $args[3], $args[4], $args[5])
91                : throw new \InvalidArgumentException(
92                    sprintf('matrix() requires 6 arguments, got %d', count($args)),
93                ),
94            'translate' => match (count($args)) {
95                1 => new Translate($args[0]),
96                2 => new Translate($args[0], $args[1]),
97                default => throw new \InvalidArgumentException(
98                    sprintf('translate() requires 1 or 2 arguments, got %d', count($args)),
99                ),
100            },
101            'scale' => match (count($args)) {
102                1 => new Scale($args[0]),
103                2 => new Scale($args[0], $args[1]),
104                default => throw new \InvalidArgumentException(
105                    sprintf('scale() requires 1 or 2 arguments, got %d', count($args)),
106                ),
107            },
108            'rotate' => match (count($args)) {
109                1 => new Rotate($args[0]),
110                3 => new Rotate($args[0], $args[1], $args[2]),
111                default => throw new \InvalidArgumentException(
112                    sprintf('rotate() requires 1 or 3 arguments, got %d', count($args)),
113                ),
114            },
115            'skewX' => count($args) === 1
116                ? new SkewX($args[0])
117                : throw new \InvalidArgumentException(
118                    sprintf('skewX() requires 1 argument, got %d', count($args)),
119                ),
120            'skewY' => count($args) === 1
121                ? new SkewY($args[0])
122                : throw new \InvalidArgumentException(
123                    sprintf('skewY() requires 1 argument, got %d', count($args)),
124                ),
125            default => throw new \InvalidArgumentException(
126                sprintf('Unknown transform function `%s`.', $name),
127            ),
128        };
129    }
130
131    /**
132     * @return list<float>
133     */
134    private static function parseNumberList(string $raw): array
135    {
136        if (preg_match_all('/[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/', $raw, $m) === false) {
137            return [];
138        }
139        return array_values(array_map(static fn(string $v): float => (float) $v, $m[0]));
140    }
141
142    /**
143     * Multiply two 3×2 affine matrices in SVG `[a,b,c,d,e,f]` order.
144     *
145     * @param array{float, float, float, float, float, float} $m1
146     * @param array{float, float, float, float, float, float} $m2
147     * @return array{float, float, float, float, float, float}
148     */
149    private static function multiply(array $m1, array $m2): array
150    {
151        [$a1, $b1, $c1, $d1, $e1, $f1] = $m1;
152        [$a2, $b2, $c2, $d2, $e2, $f2] = $m2;
153        return [
154            $a1 * $a2 + $c1 * $b2,
155            $b1 * $a2 + $d1 * $b2,
156            $a1 * $c2 + $c1 * $d2,
157            $b1 * $c2 + $d1 * $d2,
158            $a1 * $e2 + $c1 * $f2 + $e1,
159            $b1 * $e2 + $d1 * $f2 + $f1,
160        ];
161    }
162}