Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.88% covered (success)
96.88%
31 / 32
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
Paint
96.88% covered (success)
96.88%
31 / 32
50.00% covered (danger)
50.00%
1 / 2
20
0.00% covered (danger)
0.00%
0 / 1
 parse
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
12
 urlToken
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Svg\Value;
6
7use Phpdftk\Svg\Value\Paint\ContextPaint;
8use Phpdftk\Svg\Value\Paint\CurrentColor;
9use Phpdftk\Svg\Value\Paint\None_;
10use Phpdftk\Svg\Value\Paint\SolidColor;
11use Phpdftk\Svg\Value\Paint\Url;
12
13/**
14 * Parsed SVG paint value (SVG 2 ยง13.2 grammar):
15 *
16 *     <paint> = none | <color> | <url> [ none | <color> ]?
17 *             | context-fill | context-stroke | currentColor
18 *
19 * The implementation set is closed: `Paint\None_`, `Paint\CurrentColor`,
20 * `Paint\SolidColor`, `Paint\Url`, `Paint\ContextPaint`. The painter
21 * pattern-matches on the concrete type to choose the right PDF emit
22 * path (fill/stroke colour vs gradient/pattern reference).
23 *
24 * Color parsing delegates to `Phpdftk\Svg\Value\Color::parse()` which in
25 * turn produces a `Phpdftk\Color\ColorInterface` instance โ€” the SVG package
26 * depends on `phpdftk/color` for the typed colour model.
27 */
28abstract class Paint
29{
30    /**
31     * Parse an SVG paint attribute value. Returns null on absent / empty /
32     * malformed input โ€” SVG 2's "invalid โ†’ ignored" semantics.
33     */
34    public static function parse(string $raw): ?self
35    {
36        $trimmed = trim($raw);
37        if ($trimmed === '') {
38            return null;
39        }
40        if (strcasecmp($trimmed, 'none') === 0) {
41            return new None_();
42        }
43        if (strcasecmp($trimmed, 'currentColor') === 0) {
44            return new CurrentColor();
45        }
46        // SVG 2 ยง13.2.1 โ€” defer to the element that referenced this
47        // subtree (a `<marker>`'s shape, a `<use>`). Resolved by the
48        // painter, which is the only place that knows the reference
49        // chain.
50        if (strcasecmp($trimmed, 'context-fill') === 0) {
51            return ContextPaint::fill();
52        }
53        if (strcasecmp($trimmed, 'context-stroke') === 0) {
54            return ContextPaint::stroke();
55        }
56        // `url(#id) [fallback]` โ€” the fallback is itself a paint, but
57        // restricted by SVG 2 to `none | <color>`. We re-enter parse()
58        // on the tail and reject anything that comes back as a Url.
59        if (preg_match('/^url\(([^)]*)\)\s*(.*)$/is', $trimmed, $m) === 1) {
60            $reference = self::urlToken($m[1]);
61            if ($reference === null || !str_starts_with($reference, '#')) {
62                // Either malformed, or an EXTERNAL reference. Neither
63                // is a local paint-server id, and reading one as if it
64                // were would resolve it against the wrong document.
65                return null;
66            }
67            $fallback = trim($m[2]);
68            $fallbackPaint = $fallback === '' ? null : self::parse($fallback);
69            if ($fallbackPaint instanceof Url) {
70                // url(#a) url(#b) isn't a legal SVG fallback chain.
71                $fallbackPaint = null;
72            }
73            return new Url(substr($reference, 1), $fallbackPaint);
74        }
75        $color = Color::parse($trimmed);
76        return $color === null ? null : new SolidColor($color);
77    }
78
79    /**
80     * The URL inside a `url(โ€ฆ)`, with its quotes removed.
81     *
82     * CSS Values 4 ยง4.5.1 and SVG 2 ยง16.2: whitespace is allowed on
83     * both sides of the value, and inside a quoted form it is stripped
84     * along with the quotes โ€” `url(' #green ')` references `#green`.
85     * Only a MATCHED pair of quotes is a string; a lone quote is a
86     * parse error, and swallowing it would turn malformed author input
87     * into a silently different reference.
88     *
89     * Whitespace INSIDE the value survives, deliberately. `url(' # red
90     * ')` names the fragment `# red`, which matches no element, so the
91     * paint falls back โ€” exactly what the spec asks for and what
92     * "helpfully" collapsing it to `#red` would break.
93     */
94    private static function urlToken(string $raw): ?string
95    {
96        $value = trim($raw);
97        $quote = $value[0] ?? '';
98        if ($quote === '"' || $quote === "'") {
99            if (strlen($value) < 2 || !str_ends_with($value, $quote)) {
100                return null;
101            }
102            $value = substr($value, 1, -1);
103        } elseif (str_contains($value, '"') || str_contains($value, "'")) {
104            // An unquoted url-token may not contain a quote character.
105            return null;
106        }
107        $value = trim($value);
108        return $value === '' ? null : $value;
109    }
110}