Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.95% covered (success)
95.95%
71 / 74
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
TransformOrigin
95.95% covered (success)
95.95%
71 / 74
88.89% covered (warning)
88.89%
8 / 9
49
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
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
12
 axesAreAssignable
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 isXKeyword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 isYKeyword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 parseComponent
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 resolve
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
10
 component
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 unitScale
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
11.38
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Svg\Value;
6
7/**
8 * The `transform-origin` presentation attribute / CSS property for SVG
9 * elements (CSS Transforms 1 §6).
10 *
11 * SVG elements have no associated CSS layout box, so their used initial
12 * `transform-origin` is `0 0` rather than the `50% 50%` that applies to
13 * CSS boxes — an element with no `transform-origin` pivots on its
14 * reference box's origin instead of its centre.
15 *
16 * Everything here is an offset from that reference box: percentages
17 * resolve against its size, lengths are a plain offset from its origin,
18 * and position keywords name its edges. WHICH box that is comes from
19 * `transform-box` (CSS Transforms 1 §7) and is the caller's business —
20 * this class only resolves against the rectangle it is handed.
21 *
22 * Only the first two components are meaningful; a third (Z) component
23 * is validated and dropped, as this renderer is 2D.
24 */
25final class TransformOrigin
26{
27    /**
28     * @param list<array{keyword: ?string, value: float, percent: bool}> $components
29     */
30    private function __construct(private readonly array $components) {}
31
32    /**
33     * Parse the `transform-origin` grammar (CSS Transforms 1 §6):
34     *
35     *   [ left | center | right | top | bottom | <length-percentage> ]
36     * | [ left | center | right | <length-percentage> ]
37     *   [ top | center | bottom | <length-percentage> ] <length>?
38     * | [ [ center | left | right ] && [ center | top | bottom ] ] <length>?
39     *
40     * The two-value form is ORDERED (X then Y) unless both components
41     * are position keywords, in which case they may appear in either
42     * order. So `top left` is valid but `top 100%` is not, and `left
43     * left` / `top bottom` name the same axis twice and are not either.
44     *
45     * Returns null for anything that does not match, so the caller falls
46     * back to the `0 0` initial rather than a partial reading — the
47     * `svg-origin-relative-length-invalid-*` tests turn on exactly that.
48     */
49    public static function parse(string $raw): ?self
50    {
51        $parts = preg_split('/[\s,]+/', trim($raw)) ?: [];
52        $components = [];
53        foreach ($parts as $part) {
54            if ($part === '') {
55                continue;
56            }
57            $component = self::parseComponent($part);
58            if ($component === null) {
59                return null;
60            }
61            $components[] = $component;
62        }
63        if ($components === [] || count($components) > 3) {
64            return null;
65        }
66        if (count($components) > 1 && !self::axesAreAssignable($components)) {
67            return null;
68        }
69        // A third component is the Z offset, which must be a plain
70        // <length>. This renderer is 2D, so validate it and drop it.
71        if (count($components) === 3
72            && ($components[2]['keyword'] !== null || $components[2]['percent'])
73        ) {
74            return null;
75        }
76        return new self(array_slice($components, 0, 2));
77    }
78
79    /**
80     * @param list<array{keyword: ?string, value: float, percent: bool}> $components
81     */
82    private static function axesAreAssignable(array $components): bool
83    {
84        $first = $components[0];
85        $second = $components[1];
86        $bothKeywords = $first['keyword'] !== null && $second['keyword'] !== null;
87        if ($bothKeywords) {
88            // Unordered form: valid as long as the pair does not name the
89            // same axis twice. `center` is compatible with either.
90            return !(self::isXKeyword($first) && self::isXKeyword($second))
91                && !(self::isYKeyword($first) && self::isYKeyword($second));
92        }
93        // Ordered form: X component first, Y component second.
94        return !self::isYKeyword($first) && !self::isXKeyword($second);
95    }
96
97    /**
98     * @param array{keyword: ?string, value: float, percent: bool} $c
99     */
100    private static function isXKeyword(array $c): bool
101    {
102        return $c['keyword'] === 'left' || $c['keyword'] === 'right';
103    }
104
105    /**
106     * @param array{keyword: ?string, value: float, percent: bool} $c
107     */
108    private static function isYKeyword(array $c): bool
109    {
110        return $c['keyword'] === 'top' || $c['keyword'] === 'bottom';
111    }
112
113    /**
114     * @return array{keyword: ?string, value: float, percent: bool}|null
115     */
116    private static function parseComponent(string $part): ?array
117    {
118        $lower = strtolower($part);
119        if (in_array($lower, ['left', 'right', 'top', 'bottom', 'center'], true)) {
120            return ['keyword' => $lower, 'value' => 0.0, 'percent' => false];
121        }
122        if (preg_match(
123            '/^([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s*(%|[a-zA-Z]*)$/',
124            $part,
125            $m,
126        ) !== 1) {
127            return null;
128        }
129        if ($m[2] === '%') {
130            return ['keyword' => null, 'value' => (float) $m[1], 'percent' => true];
131        }
132        return [
133            'keyword' => null,
134            'value' => (float) $m[1] * self::unitScale($m[2]),
135            'percent' => false,
136        ];
137    }
138
139    /**
140     * Resolve to a user-space pivot point against the element's object
141     * bounding box.
142     *
143     * Keywords bind by AXIS IDENTITY, not by order (the `<position>`
144     * grammar): `left`/`right` name X and `top`/`bottom` name Y in
145     * either order, while `center`, lengths and percentages fill the
146     * remaining axes positionally, X first. A single specified
147     * component leaves the other axis at `center`.
148     *
149     * @return array{float, float}
150     */
151    public function resolve(float $bx, float $by, float $bw, float $bh): array
152    {
153        $x = null;
154        $y = null;
155        $positional = [];
156        foreach ($this->components as $c) {
157            switch ($c['keyword']) {
158                case 'left':   $x = $bx;
159                    continue 2;
160                case 'right':  $x = $bx + $bw;
161                    continue 2;
162                case 'top':    $y = $by;
163                    continue 2;
164                case 'bottom': $y = $by + $bh;
165                    continue 2;
166                default:
167                    $positional[] = $c;
168            }
169        }
170        foreach ($positional as $c) {
171            if ($x === null) {
172                $x = self::component($c, $bx, $bw);
173            } elseif ($y === null) {
174                $y = self::component($c, $by, $bh);
175            }
176        }
177        return [$x ?? $bx + $bw / 2.0, $y ?? $by + $bh / 2.0];
178    }
179
180    /**
181     * @param array{keyword: ?string, value: float, percent: bool} $c
182     */
183    private static function component(array $c, float $origin, float $extent): float
184    {
185        if ($c['keyword'] === 'center') {
186            return $origin + $extent / 2.0;
187        }
188        return $origin + ($c['percent'] ? $c['value'] / 100.0 * $extent : $c['value']);
189    }
190
191    /**
192     * Absolute CSS unit → user units (CSS Values 4 §6.2, 96dpi). A bare
193     * number is a user-unit length in the presentation-attribute form.
194     */
195    private static function unitScale(string $unit): float
196    {
197        return match (strtolower($unit)) {
198            'cm' => 96.0 / 2.54,
199            'mm' => 96.0 / 25.4,
200            'q' => 96.0 / 101.6,
201            'in' => 96.0,
202            'pt' => 96.0 / 72.0,
203            'pc' => 16.0,
204            default => 1.0,
205        };
206    }
207}