Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
97.75% |
87 / 89 |
|
75.00% |
6 / 8 |
CRAP | |
0.00% |
0 / 1 |
| Transform | |
97.75% |
87 / 89 |
|
75.00% |
6 / 8 |
38 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| parse | |
95.65% |
22 / 23 |
|
0.00% |
0 / 1 |
8 | |||
| toMatrix | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| isInvertible | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| toMatrixAbout | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
3 | |||
| makeFunction | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
20 | |||
| parseNumberList | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| multiply | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Phpdftk\Svg\Value; |
| 6 | |
| 7 | use Phpdftk\Svg\Value\Transform\Matrix; |
| 8 | use Phpdftk\Svg\Value\Transform\Rotate; |
| 9 | use Phpdftk\Svg\Value\Transform\Scale; |
| 10 | use Phpdftk\Svg\Value\Transform\SkewX; |
| 11 | use Phpdftk\Svg\Value\Transform\SkewY; |
| 12 | use 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 | */ |
| 24 | final 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 | } |