Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.02% covered (success)
95.02%
248 / 261
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
95.02% covered (success)
95.02%
248 / 261
66.67% covered (warning)
66.67%
10 / 15
143
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
 parseStylesheet
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseInlineStyle
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 consumeListOfRules
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
 consumeAtRule
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
14.10
 consumeQualifiedRule
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
11.07
 consumeBlock
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
6
 consumeListOfDeclarations
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
14
 parseDeclarationFromTokens
91.84% covered (success)
91.84%
45 / 49
0.00% covered (danger)
0.00%
0 / 1
23.29
 parseAtRuleBlockContents
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 consumeDeclarationsAndAtRules
90.62% covered (success)
90.62%
29 / 32
0.00% covered (danger)
0.00%
0 / 1
19.30
 serializePrelude
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 tokenToText
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
27.48
 trimWhitespace
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare(strict_types=1);
4
5namespace Phpdftk\Css;
6
7use Phpdftk\Css\Selector\SelectorList;
8use Phpdftk\Css\Sheet\AtRule;
9use Phpdftk\Css\Sheet\AtRuleBlock;
10use Phpdftk\Css\Sheet\Declaration;
11use Phpdftk\Css\Sheet\Origin;
12use Phpdftk\Css\Sheet\Rule;
13use Phpdftk\Css\Sheet\Stylesheet;
14use Phpdftk\Css\Sheet\StyleRule;
15use Phpdftk\Css\Token\AtKeywordToken;
16use Phpdftk\Css\Token\CdcToken;
17use Phpdftk\Css\Token\CdoToken;
18use Phpdftk\Css\Token\ColonToken;
19use Phpdftk\Css\Token\DelimToken;
20use Phpdftk\Css\Token\EofToken;
21use Phpdftk\Css\Token\FunctionToken;
22use Phpdftk\Css\Token\IdentToken;
23use Phpdftk\Css\Token\LeftBraceToken;
24use Phpdftk\Css\Token\LeftBracketToken;
25use Phpdftk\Css\Token\LeftParenToken;
26use Phpdftk\Css\Token\RightBraceToken;
27use Phpdftk\Css\Token\RightBracketToken;
28use Phpdftk\Css\Token\RightParenToken;
29use Phpdftk\Css\Token\SemicolonToken;
30use Phpdftk\Css\Token\Token;
31use Phpdftk\Css\Token\WhitespaceToken;
32use Phpdftk\Css\Value\Value;
33
34/**
35 * Stylesheet-level parser per CSS Syntax Module 3 §5 ("Parsing").
36 *
37 * Tokenizes the input, then runs the spec's "consume a list of rules" /
38 * "consume an at-rule" / "consume a qualified rule" / "consume a list of
39 * declarations" sub-algorithms. Output is a typed {@see Stylesheet} tree.
40 *
41 * Value parsing inside declarations delegates to {@see ValueParser}.
42 * Selector parsing is deferred to Phase 1D — for now {@see StyleRule}'s
43 * `SelectorList` carries the raw selector text.
44 */
45final class Parser
46{
47    private readonly ValueParser $valueParser;
48
49    public function __construct(?ValueParser $valueParser = null)
50    {
51        $this->valueParser = $valueParser ?? new ValueParser();
52    }
53
54    public function parseStylesheet(string $css, Origin $origin = Origin::Author): Stylesheet
55    {
56        $tokens = (new Tokenizer($css))->tokenize();
57        return new Stylesheet($this->consumeListOfRules($tokens, topLevel: true), $origin);
58    }
59
60    /**
61     * Parse an HTML `style="…"` attribute (or any free-form declaration list)
62     * into a StyleRule with an empty selector.
63     */
64    public function parseInlineStyle(string $css): StyleRule
65    {
66        $tokens = (new Tokenizer($css))->tokenize();
67        return new StyleRule(new SelectorList(''), $this->consumeListOfDeclarations($tokens));
68    }
69
70    public function parseValue(string $css, string $propertyHint = ''): Value
71    {
72        return $this->valueParser->parseFromString($css);
73    }
74
75    /**
76     * @param list<Token> $tokens
77     * @return list<Rule>
78     */
79    private function consumeListOfRules(array $tokens, bool $topLevel): array
80    {
81        $rules = [];
82        $i = 0;
83        $n = count($tokens);
84        while ($i < $n) {
85            $t = $tokens[$i];
86            if ($t instanceof WhitespaceToken) {
87                $i++;
88                continue;
89            }
90            if ($t instanceof EofToken) {
91                break;
92            }
93            if (($t instanceof CdoToken || $t instanceof CdcToken) && $topLevel) {
94                $i++;
95                continue;
96            }
97            if ($t instanceof AtKeywordToken) {
98                $rules[] = $this->consumeAtRule($tokens, $i);
99                continue;
100            }
101            // Qualified rule (style rule).
102            $rule = $this->consumeQualifiedRule($tokens, $i);
103            if ($rule !== null) {
104                $rules[] = $rule;
105            }
106        }
107        return $rules;
108    }
109
110    /**
111     * Consume an at-rule. Advances $i past the rule.
112     *
113     * @param list<Token> $tokens
114     */
115    private function consumeAtRule(array $tokens, int &$i): AtRule
116    {
117        $name = $tokens[$i] instanceof AtKeywordToken ? $tokens[$i]->value : '';
118        $i++;
119        $prelude = [];
120        $depth = 0;
121        $n = count($tokens);
122        while ($i < $n) {
123            $t = $tokens[$i];
124            if ($depth === 0 && $t instanceof SemicolonToken) {
125                $i++;
126                return new AtRule($name, self::serializePrelude($prelude), null);
127            }
128            if ($t instanceof EofToken) {
129                return new AtRule($name, self::serializePrelude($prelude), null);
130            }
131            if ($depth === 0 && $t instanceof LeftBraceToken) {
132                $i++;
133                $blockTokens = $this->consumeBlock($tokens, $i);
134                $block = new AtRuleBlock($this->parseAtRuleBlockContents($name, $blockTokens));
135                return new AtRule($name, self::serializePrelude($prelude), $block);
136            }
137            if ($t instanceof LeftParenToken || $t instanceof LeftBracketToken || $t instanceof FunctionToken) {
138                $depth++;
139            } elseif ($t instanceof RightParenToken || $t instanceof RightBracketToken) {
140                if ($depth > 0) {
141                    $depth--;
142                }
143            }
144            $prelude[] = $t;
145            $i++;
146        }
147        return new AtRule($name, self::serializePrelude($prelude), null);
148    }
149
150    /**
151     * Consume a qualified rule (selector + declaration block). Advances $i.
152     *
153     * @param list<Token> $tokens
154     */
155    private function consumeQualifiedRule(array $tokens, int &$i): ?StyleRule
156    {
157        $prelude = [];
158        $depth = 0;
159        $n = count($tokens);
160        while ($i < $n) {
161            $t = $tokens[$i];
162            if ($t instanceof EofToken) {
163                return null; // parse error: missing block
164            }
165            if ($depth === 0 && $t instanceof LeftBraceToken) {
166                $i++;
167                $blockTokens = $this->consumeBlock($tokens, $i);
168                $preludeText = trim(self::serializePrelude($prelude));
169                $selectors = \Phpdftk\Css\Selector\SelectorParser::parseTokens($prelude, $preludeText);
170                return new StyleRule(
171                    $selectors,
172                    $this->consumeListOfDeclarations($blockTokens),
173                );
174            }
175            if ($t instanceof LeftParenToken || $t instanceof LeftBracketToken || $t instanceof FunctionToken) {
176                $depth++;
177            } elseif ($t instanceof RightParenToken || $t instanceof RightBracketToken) {
178                if ($depth > 0) {
179                    $depth--;
180                }
181            }
182            $prelude[] = $t;
183            $i++;
184        }
185        return null;
186    }
187
188    /**
189     * Consume the inside of a `{ ... }` block. Assumes $i is just past the
190     * opening brace; advances past the closing brace.
191     *
192     * @param list<Token> $tokens
193     * @return list<Token>
194     */
195    private function consumeBlock(array $tokens, int &$i): array
196    {
197        $contents = [];
198        $depth = 1;
199        $n = count($tokens);
200        // Loop terminates from inside on EOF or matching close brace; the
201        // depth check at the top is always true here, so we use just $i < $n.
202        while ($i < $n) {
203            $t = $tokens[$i];
204            if ($t instanceof EofToken) {
205                break;
206            }
207            if ($t instanceof LeftBraceToken) {
208                $depth++;
209                $contents[] = $t;
210                $i++;
211                continue;
212            }
213            if ($t instanceof RightBraceToken) {
214                $depth--;
215                if ($depth === 0) {
216                    $i++;
217                    break;
218                }
219                $contents[] = $t;
220                $i++;
221                continue;
222            }
223            $contents[] = $t;
224            $i++;
225        }
226        return $contents;
227    }
228
229    /**
230     * Parse a list of declarations from a block-content token list. Per CSS
231     * Syntax 3 §5.4.4. Semicolon-separated; each non-empty section is one
232     * declaration (or a parse error to drop).
233     *
234     * @param list<Token> $tokens
235     * @return list<Declaration>
236     */
237    private function consumeListOfDeclarations(array $tokens): array
238    {
239        $declarations = [];
240        $current = [];
241        $depth = 0;
242        $i = 0;
243        $n = count($tokens);
244        while ($i < $n) {
245            $t = $tokens[$i];
246            if ($depth === 0 && $t instanceof SemicolonToken) {
247                $decl = $this->parseDeclarationFromTokens($current);
248                if ($decl !== null) {
249                    $declarations[] = $decl;
250                }
251                $current = [];
252                $i++;
253                continue;
254            }
255            if ($t instanceof LeftParenToken || $t instanceof LeftBracketToken
256                || $t instanceof LeftBraceToken || $t instanceof FunctionToken
257            ) {
258                $depth++;
259            } elseif ($t instanceof RightParenToken || $t instanceof RightBracketToken
260                || $t instanceof RightBraceToken
261            ) {
262                if ($depth > 0) {
263                    $depth--;
264                }
265            }
266            $current[] = $t;
267            $i++;
268        }
269        $decl = $this->parseDeclarationFromTokens($current);
270        if ($decl !== null) {
271            $declarations[] = $decl;
272        }
273        return $declarations;
274    }
275
276    /**
277     * Turn a "section" of tokens (the part between two `;` boundaries) into
278     * a Declaration, or return null if it doesn't shape as one.
279     *
280     * @param list<Token> $tokens
281     */
282    private function parseDeclarationFromTokens(array $tokens): ?Declaration
283    {
284        $tokens = self::trimWhitespace($tokens);
285        if ($tokens === []) {
286            return null;
287        }
288        $head = $tokens[0];
289        if (!$head instanceof IdentToken) {
290            return null;
291        }
292        // CSS Custom Properties §2 — custom property names (those
293        // starting with `--`) ARE case-sensitive; standard
294        // properties are case-insensitive. Only lowercase the
295        // standard form so `--FooBar` and `--foobar` remain
296        // distinct declarations.
297        $rawName = $head->value;
298        $property = str_starts_with($rawName, '--') ? $rawName : strtolower($rawName);
299        // Find the colon.
300        $colonIdx = null;
301        for ($i = 1; $i < count($tokens); $i++) {
302            if ($tokens[$i] instanceof ColonToken) {
303                $colonIdx = $i;
304                break;
305            }
306            if (!$tokens[$i] instanceof WhitespaceToken) {
307                return null; // unexpected token before colon
308            }
309        }
310        if ($colonIdx === null) {
311            return null;
312        }
313        $valueTokens = array_slice($tokens, $colonIdx + 1);
314        $valueTokens = self::trimWhitespace($valueTokens);
315        // Check for !important suffix.
316        $important = false;
317        if (count($valueTokens) >= 2) {
318            $lastIdx = count($valueTokens) - 1;
319            $tail = $valueTokens[$lastIdx];
320            $beforeTail = null;
321            $bangIdx = null;
322            for ($j = $lastIdx; $j >= 0; $j--) {
323                $tt = $valueTokens[$j];
324                if ($tt instanceof DelimToken && $tt->value === '!') {
325                    $bangIdx = $j;
326                    break;
327                }
328                if (!$tt instanceof IdentToken && !$tt instanceof WhitespaceToken) {
329                    break;
330                }
331            }
332            if ($bangIdx !== null) {
333                // Whatever's after the `!` must be `important` (case-insensitive).
334                $after = self::trimWhitespace(array_slice($valueTokens, $bangIdx + 1));
335                if (count($after) === 1
336                    && $after[0] instanceof IdentToken
337                    && strcasecmp($after[0]->value, 'important') === 0
338                ) {
339                    $important = true;
340                    $valueTokens = self::trimWhitespace(array_slice($valueTokens, 0, $bangIdx));
341                }
342            }
343        }
344        $value = $this->valueParser->parse($valueTokens);
345        // CSS Transforms 2 §6: the `transform` property's value is a
346        // list of transform-functions. Post-process the generic
347        // `CssFunction`/`ValueList` into a typed `Transform` so the
348        // painter can consume it directly without re-parsing.
349        if ($property === 'transform') {
350            $value = $this->valueParser->postProcessTransform($value);
351        } elseif ($property === 'filter' || $property === 'backdrop-filter') {
352            // CSS Filter Effects 1 §6.1 — both properties accept
353            // the same `<filter-function-list>`. Lift the generic
354            // CssFunction list into a typed Filter so the painter
355            // can dispatch by FilterKind.
356            $value = $this->valueParser->postProcessFilter($value);
357        } elseif ($property === 'font-feature-settings') {
358            // CSS Fonts 4 §6.4 — lift the `<feature-tag-value>#`
359            // list into a typed FontFeatureSettings so the shaper
360            // can dispatch by OpenType tag without re-parsing.
361            $value = $this->valueParser->postProcessFontFeatureSettings($value);
362        } elseif ($property === 'font-variation-settings') {
363            // CSS Fonts 4 §6.5 — same shape but with float axis
364            // values for variable fonts.
365            $value = $this->valueParser->postProcessFontVariationSettings($value);
366        }
367        return new Declaration($property, $value, $important);
368    }
369
370    /**
371     * Parse the body of an at-rule block: try each comma-or-semicolon-free
372     * section as a declaration first, then as a rule. For declaration-only
373     * at-rules (`@font-face`, `@page`, `@property`, `@counter-style`) the
374     * decl path wins; for nested-rule at-rules (`@media`, `@supports`,
375     * `@keyframes`'s blocks) the rule path wins.
376     *
377     * @param list<Token> $tokens
378     * @return list<Rule|Declaration>
379     */
380    private function parseAtRuleBlockContents(string $atRuleName, array $tokens): array
381    {
382        $lcName = strtolower($atRuleName);
383        $declOnly = ['font-face', 'property', 'counter-style', 'font-feature-values'];
384        // CSS Paged Media 3 §3.6 margin-box at-rules (the 16 positions);
385        // each one contains declarations only (`content`, `font-size`,
386        // `color`, etc.). Treat them as declaration-only.
387        $marginBoxRules = [
388            'top-left-corner', 'top-left', 'top-center', 'top-right', 'top-right-corner',
389            'right-top', 'right-middle', 'right-bottom',
390            'bottom-right-corner', 'bottom-right', 'bottom-center', 'bottom-left', 'bottom-left-corner',
391            'left-bottom', 'left-middle', 'left-top',
392        ];
393        if (in_array($lcName, $declOnly, true) || in_array($lcName, $marginBoxRules, true)) {
394            return $this->consumeListOfDeclarations($tokens);
395        }
396        // CSS Paged Media 3 §3: `@page` blocks can contain BOTH
397        // declarations (the page box's own props like margin/size) AND
398        // nested at-rules (the 16 margin-box at-rules). Use the
399        // mixed-content parser for it.
400        if ($lcName === 'page') {
401            return $this->consumeDeclarationsAndAtRules($tokens);
402        }
403        // Otherwise parse as a rule list.
404        return $this->consumeListOfRules($tokens, topLevel: false);
405    }
406
407    /**
408     * Parse a block that may contain either declarations (`prop: value;`)
409     * or nested at-rules (`@name { ... }`). Used for `@page` per CSS
410     * Paged Media 3 §3 — the page box's own properties live alongside
411     * its margin-box at-rules. Section boundaries are `;` (closes a
412     * declaration) or a `{...}` block (closes an at-rule).
413     *
414     * @param list<Token> $tokens
415     * @return list<Rule|Declaration>
416     */
417    private function consumeDeclarationsAndAtRules(array $tokens): array
418    {
419        $out = [];
420        $i = 0;
421        $n = count($tokens);
422        while ($i < $n) {
423            $t = $tokens[$i];
424            if ($t instanceof WhitespaceToken || $t instanceof SemicolonToken) {
425                $i++;
426                continue;
427            }
428            if ($t instanceof AtKeywordToken) {
429                $out[] = $this->consumeAtRule($tokens, $i);
430                continue;
431            }
432            // Collect a declaration: tokens up to next top-level `;` or
433            // end of input. Don't break inside braces (a value can carry
434            // a function call with parens; we don't expect braces inside
435            // a declaration, but stay defensive).
436            $start = $i;
437            $depth = 0;
438            while ($i < $n) {
439                $u = $tokens[$i];
440                if ($depth === 0 && $u instanceof SemicolonToken) {
441                    break;
442                }
443                if ($u instanceof LeftParenToken || $u instanceof LeftBracketToken
444                    || $u instanceof LeftBraceToken || $u instanceof FunctionToken
445                ) {
446                    $depth++;
447                } elseif ($u instanceof RightParenToken || $u instanceof RightBracketToken
448                    || $u instanceof RightBraceToken
449                ) {
450                    if ($depth > 0) {
451                        $depth--;
452                    }
453                }
454                $i++;
455            }
456            $section = array_slice($tokens, $start, $i - $start);
457            $decl = $this->parseDeclarationFromTokens($section);
458            if ($decl !== null) {
459                $out[] = $decl;
460            }
461            // Skip the `;` if present.
462            if ($i < $n && $tokens[$i] instanceof SemicolonToken) {
463                $i++;
464            }
465        }
466        return $out;
467    }
468
469    /**
470     * Render a prelude (or selector) token list back to a normalised string:
471     * collapse runs of whitespace, trim ends, preserve the rest verbatim.
472     *
473     * @param list<Token> $tokens
474     */
475    private static function serializePrelude(array $tokens): string
476    {
477        $out = '';
478        $lastWasSpace = false;
479        foreach ($tokens as $t) {
480            $piece = self::tokenToText($t);
481            if ($t instanceof WhitespaceToken) {
482                if (!$lastWasSpace && $out !== '') {
483                    $out .= ' ';
484                    $lastWasSpace = true;
485                }
486                continue;
487            }
488            $out .= $piece;
489            $lastWasSpace = false;
490        }
491        return trim($out);
492    }
493
494    private static function tokenToText(Token $t): string
495    {
496        return match (true) {
497            $t instanceof IdentToken => $t->value,
498            $t instanceof AtKeywordToken => '@' . $t->value,
499            $t instanceof FunctionToken => $t->name . '(',
500            $t instanceof \Phpdftk\Css\Token\HashToken => '#' . $t->value,
501            $t instanceof \Phpdftk\Css\Token\StringToken => '"' . str_replace('"', '\\"', $t->value) . '"',
502            $t instanceof \Phpdftk\Css\Token\UrlToken => 'url(' . $t->value . ')',
503            $t instanceof \Phpdftk\Css\Token\NumberToken => (string) (fmod($t->value, 1.0) === 0.0 ? (int) $t->value : $t->value),
504            $t instanceof \Phpdftk\Css\Token\PercentageToken => (string) (fmod($t->value, 1.0) === 0.0 ? (int) $t->value : $t->value) . '%',
505            $t instanceof \Phpdftk\Css\Token\DimensionToken => (string) (fmod($t->value, 1.0) === 0.0 ? (int) $t->value : $t->value) . $t->unit,
506            $t instanceof DelimToken => $t->value,
507            $t instanceof ColonToken => ':',
508            $t instanceof SemicolonToken => ';',
509            $t instanceof \Phpdftk\Css\Token\CommaToken => ',',
510            $t instanceof LeftParenToken => '(',
511            $t instanceof RightParenToken => ')',
512            $t instanceof LeftBracketToken => '[',
513            $t instanceof RightBracketToken => ']',
514            $t instanceof LeftBraceToken => '{',
515            $t instanceof RightBraceToken => '}',
516            $t instanceof WhitespaceToken => ' ',
517            $t instanceof CdoToken => '<!--',
518            $t instanceof CdcToken => '-->',
519            default => '',
520        };
521    }
522
523    /**
524     * @param list<Token> $tokens
525     * @return list<Token>
526     */
527    private static function trimWhitespace(array $tokens): array
528    {
529        $start = 0;
530        $end = count($tokens) - 1;
531        while ($start <= $end && ($tokens[$start] instanceof WhitespaceToken || $tokens[$start] instanceof EofToken)) {
532            $start++;
533        }
534        while ($end >= $start && ($tokens[$end] instanceof WhitespaceToken || $tokens[$end] instanceof EofToken)) {
535            $end--;
536        }
537        return array_slice($tokens, $start, $end - $start + 1);
538    }
539}