Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.75% covered (success)
97.75%
87 / 89
75.00% covered (warning)
75.00%
6 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Transform
97.75% covered (success)
97.75%
87 / 89
75.00% covered (warning)
75.00%
6 / 8
38
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
 isInvertible
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 toMatrixAbout
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 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     * Whether the composed matrix can be inverted, i.e. whether its
85     * determinant is non-zero.
86     *
87     * CSS Transforms 1 §11: a non-invertible transform function list
88     * makes the element it applies to unrenderable. On a paint server
89     * (`gradientTransform`, `patternTransform`) that makes the SERVER
90     * invalid, so the referencing element uses its `<paint>` fallback.
91     *
92     * The threshold is relative to the matrix's own scale rather than
93     * absolute, so a transform expressed in tiny units isn't mistaken
94     * for a degenerate one.
95     */
96    public function isInvertible(): bool
97    {
98        [$a, $b, $c, $d] = $this->toMatrix();
99        $determinant = $a * $d - $b * $c;
100        $scale = max(abs($a), abs($b), abs($c), abs($d), 1.0);
101        return abs($determinant) > 1.0e-12 * $scale * $scale;
102    }
103
104    /**
105     * Compose the function list about a pivot point, i.e.
106     * `translate(ox, oy) · M · translate(-ox, -oy)`. This is how
107     * `transform-origin` applies to a `transform` (CSS Transforms 1 §6).
108     *
109     * @return array{float, float, float, float, float, float}
110     */
111    public function toMatrixAbout(float $ox, float $oy): array
112    {
113        $m = $this->toMatrix();
114        if ($ox === 0.0 && $oy === 0.0) {
115            return $m;
116        }
117        return self::multiply(
118            self::multiply([1.0, 0.0, 0.0, 1.0, $ox, $oy], $m),
119            [1.0, 0.0, 0.0, 1.0, -$ox, -$oy],
120        );
121    }
122
123    /**
124     * @param list<float> $args
125     */
126    private static function makeFunction(string $name, array $args): TransformFunction
127    {
128        return match ($name) {
129            'matrix' => count($args) === 6
130                ? new Matrix($args[0], $args[1], $args[2], $args[3], $args[4], $args[5])
131                : throw new \InvalidArgumentException(
132                    sprintf('matrix() requires 6 arguments, got %d', count($args)),
133                ),
134            'translate' => match (count($args)) {
135                1 => new Translate($args[0]),
136                2 => new Translate($args[0], $args[1]),
137                default => throw new \InvalidArgumentException(
138                    sprintf('translate() requires 1 or 2 arguments, got %d', count($args)),
139                ),
140            },
141            'scale' => match (count($args)) {
142                1 => new Scale($args[0]),
143                2 => new Scale($args[0], $args[1]),
144                default => throw new \InvalidArgumentException(
145                    sprintf('scale() requires 1 or 2 arguments, got %d', count($args)),
146                ),
147            },
148            'rotate' => match (count($args)) {
149                1 => new Rotate($args[0]),
150                3 => new Rotate($args[0], $args[1], $args[2]),
151                default => throw new \InvalidArgumentException(
152                    sprintf('rotate() requires 1 or 3 arguments, got %d', count($args)),
153                ),
154            },
155            'skewX' => count($args) === 1
156                ? new SkewX($args[0])
157                : throw new \InvalidArgumentException(
158                    sprintf('skewX() requires 1 argument, got %d', count($args)),
159                ),
160            'skewY' => count($args) === 1
161                ? new SkewY($args[0])
162                : throw new \InvalidArgumentException(
163                    sprintf('skewY() requires 1 argument, got %d', count($args)),
164                ),
165            default => throw new \InvalidArgumentException(
166                sprintf('Unknown transform function `%s`.', $name),
167            ),
168        };
169    }
170
171    /**
172     * @return list<float>
173     */
174    private static function parseNumberList(string $raw): array
175    {
176        if (preg_match_all('/[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/', $raw, $m) === false) {
177            return [];
178        }
179        return array_values(array_map(static fn(string $v): float => (float) $v, $m[0]));
180    }
181
182    /**
183     * Multiply two 3×2 affine matrices in SVG `[a,b,c,d,e,f]` order.
184     *
185     * @param array{float, float, float, float, float, float} $m1
186     * @param array{float, float, float, float, float, float} $m2
187     * @return array{float, float, float, float, float, float}
188     */
189    private static function multiply(array $m1, array $m2): array
190    {
191        [$a1, $b1, $c1, $d1, $e1, $f1] = $m1;
192        [$a2, $b2, $c2, $d2, $e2, $f2] = $m2;
193        return [
194            $a1 * $a2 + $c1 * $b2,
195            $b1 * $a2 + $d1 * $b2,
196            $a1 * $c2 + $c1 * $d2,
197            $b1 * $c2 + $d1 * $d2,
198            $a1 * $e2 + $c1 * $f2 + $e1,
199            $b1 * $e2 + $d1 * $f2 + $f1,
200        ];
201    }
202}