Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.88% covered (success)
94.88%
241 / 254
66.67% covered (warning)
66.67%
10 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Parser
94.88% covered (success)
94.88%
241 / 254
66.67% covered (warning)
66.67%
10 / 15
140.55
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
90.48% covered (success)
90.48%
38 / 42
0.00% covered (danger)
0.00%
0 / 1
18.28
 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        $property = strtolower($head->value);
293        // Find the colon.
294        $colonIdx = null;
295        for ($i = 1; $i < count($tokens); $i++) {
296            if ($tokens[$i] instanceof ColonToken) {
297                $colonIdx = $i;
298                break;
299            }
300            if (!$tokens[$i] instanceof WhitespaceToken) {
301                return null; // unexpected token before colon
302            }
303        }
304        if ($colonIdx === null) {
305            return null;
306        }
307        $valueTokens = array_slice($tokens, $colonIdx + 1);
308        $valueTokens = self::trimWhitespace($valueTokens);
309        // Check for !important suffix.
310        $important = false;
311        if (count($valueTokens) >= 2) {
312            $lastIdx = count($valueTokens) - 1;
313            $tail = $valueTokens[$lastIdx];
314            $beforeTail = null;
315            $bangIdx = null;
316            for ($j = $lastIdx; $j >= 0; $j--) {
317                $tt = $valueTokens[$j];
318                if ($tt instanceof DelimToken && $tt->value === '!') {
319                    $bangIdx = $j;
320                    break;
321                }
322                if (!$tt instanceof IdentToken && !$tt instanceof WhitespaceToken) {
323                    break;
324                }
325            }
326            if ($bangIdx !== null) {
327                // Whatever's after the `!` must be `important` (case-insensitive).
328                $after = self::trimWhitespace(array_slice($valueTokens, $bangIdx + 1));
329                if (count($after) === 1
330                    && $after[0] instanceof IdentToken
331                    && strcasecmp($after[0]->value, 'important') === 0
332                ) {
333                    $important = true;
334                    $valueTokens = self::trimWhitespace(array_slice($valueTokens, 0, $bangIdx));
335                }
336            }
337        }
338        $value = $this->valueParser->parse($valueTokens);
339        // CSS Transforms 2 §6: the `transform` property's value is a
340        // list of transform-functions. Post-process the generic
341        // `CssFunction`/`ValueList` into a typed `Transform` so the
342        // painter can consume it directly without re-parsing.
343        if ($property === 'transform') {
344            $value = $this->valueParser->postProcessTransform($value);
345        }
346        return new Declaration($property, $value, $important);
347    }
348
349    /**
350     * Parse the body of an at-rule block: try each comma-or-semicolon-free
351     * section as a declaration first, then as a rule. For declaration-only
352     * at-rules (`@font-face`, `@page`, `@property`, `@counter-style`) the
353     * decl path wins; for nested-rule at-rules (`@media`, `@supports`,
354     * `@keyframes`'s blocks) the rule path wins.
355     *
356     * @param list<Token> $tokens
357     * @return list<Rule|Declaration>
358     */
359    private function parseAtRuleBlockContents(string $atRuleName, array $tokens): array
360    {
361        $lcName = strtolower($atRuleName);
362        $declOnly = ['font-face', 'property', 'counter-style', 'font-feature-values'];
363        // CSS Paged Media 3 §3.6 margin-box at-rules (the 16 positions);
364        // each one contains declarations only (`content`, `font-size`,
365        // `color`, etc.). Treat them as declaration-only.
366        $marginBoxRules = [
367            'top-left-corner', 'top-left', 'top-center', 'top-right', 'top-right-corner',
368            'right-top', 'right-middle', 'right-bottom',
369            'bottom-right-corner', 'bottom-right', 'bottom-center', 'bottom-left', 'bottom-left-corner',
370            'left-bottom', 'left-middle', 'left-top',
371        ];
372        if (in_array($lcName, $declOnly, true) || in_array($lcName, $marginBoxRules, true)) {
373            return $this->consumeListOfDeclarations($tokens);
374        }
375        // CSS Paged Media 3 §3: `@page` blocks can contain BOTH
376        // declarations (the page box's own props like margin/size) AND
377        // nested at-rules (the 16 margin-box at-rules). Use the
378        // mixed-content parser for it.
379        if ($lcName === 'page') {
380            return $this->consumeDeclarationsAndAtRules($tokens);
381        }
382        // Otherwise parse as a rule list.
383        return $this->consumeListOfRules($tokens, topLevel: false);
384    }
385
386    /**
387     * Parse a block that may contain either declarations (`prop: value;`)
388     * or nested at-rules (`@name { ... }`). Used for `@page` per CSS
389     * Paged Media 3 §3 — the page box's own properties live alongside
390     * its margin-box at-rules. Section boundaries are `;` (closes a
391     * declaration) or a `{...}` block (closes an at-rule).
392     *
393     * @param list<Token> $tokens
394     * @return list<Rule|Declaration>
395     */
396    private function consumeDeclarationsAndAtRules(array $tokens): array
397    {
398        $out = [];
399        $i = 0;
400        $n = count($tokens);
401        while ($i < $n) {
402            $t = $tokens[$i];
403            if ($t instanceof WhitespaceToken || $t instanceof SemicolonToken) {
404                $i++;
405                continue;
406            }
407            if ($t instanceof AtKeywordToken) {
408                $out[] = $this->consumeAtRule($tokens, $i);
409                continue;
410            }
411            // Collect a declaration: tokens up to next top-level `;` or
412            // end of input. Don't break inside braces (a value can carry
413            // a function call with parens; we don't expect braces inside
414            // a declaration, but stay defensive).
415            $start = $i;
416            $depth = 0;
417            while ($i < $n) {
418                $u = $tokens[$i];
419                if ($depth === 0 && $u instanceof SemicolonToken) {
420                    break;
421                }
422                if ($u instanceof LeftParenToken || $u instanceof LeftBracketToken
423                    || $u instanceof LeftBraceToken || $u instanceof FunctionToken
424                ) {
425                    $depth++;
426                } elseif ($u instanceof RightParenToken || $u instanceof RightBracketToken
427                    || $u instanceof RightBraceToken
428                ) {
429                    if ($depth > 0) {
430                        $depth--;
431                    }
432                }
433                $i++;
434            }
435            $section = array_slice($tokens, $start, $i - $start);
436            $decl = $this->parseDeclarationFromTokens($section);
437            if ($decl !== null) {
438                $out[] = $decl;
439            }
440            // Skip the `;` if present.
441            if ($i < $n && $tokens[$i] instanceof SemicolonToken) {
442                $i++;
443            }
444        }
445        return $out;
446    }
447
448    /**
449     * Render a prelude (or selector) token list back to a normalised string:
450     * collapse runs of whitespace, trim ends, preserve the rest verbatim.
451     *
452     * @param list<Token> $tokens
453     */
454    private static function serializePrelude(array $tokens): string
455    {
456        $out = '';
457        $lastWasSpace = false;
458        foreach ($tokens as $t) {
459            $piece = self::tokenToText($t);
460            if ($t instanceof WhitespaceToken) {
461                if (!$lastWasSpace && $out !== '') {
462                    $out .= ' ';
463                    $lastWasSpace = true;
464                }
465                continue;
466            }
467            $out .= $piece;
468            $lastWasSpace = false;
469        }
470        return trim($out);
471    }
472
473    private static function tokenToText(Token $t): string
474    {
475        return match (true) {
476            $t instanceof IdentToken => $t->value,
477            $t instanceof AtKeywordToken => '@' . $t->value,
478            $t instanceof FunctionToken => $t->name . '(',
479            $t instanceof \Phpdftk\Css\Token\HashToken => '#' . $t->value,
480            $t instanceof \Phpdftk\Css\Token\StringToken => '"' . str_replace('"', '\\"', $t->value) . '"',
481            $t instanceof \Phpdftk\Css\Token\UrlToken => 'url(' . $t->value . ')',
482            $t instanceof \Phpdftk\Css\Token\NumberToken => (string) (fmod($t->value, 1.0) === 0.0 ? (int) $t->value : $t->value),
483            $t instanceof \Phpdftk\Css\Token\PercentageToken => (string) (fmod($t->value, 1.0) === 0.0 ? (int) $t->value : $t->value) . '%',
484            $t instanceof \Phpdftk\Css\Token\DimensionToken => (string) (fmod($t->value, 1.0) === 0.0 ? (int) $t->value : $t->value) . $t->unit,
485            $t instanceof DelimToken => $t->value,
486            $t instanceof ColonToken => ':',
487            $t instanceof SemicolonToken => ';',
488            $t instanceof \Phpdftk\Css\Token\CommaToken => ',',
489            $t instanceof LeftParenToken => '(',
490            $t instanceof RightParenToken => ')',
491            $t instanceof LeftBracketToken => '[',
492            $t instanceof RightBracketToken => ']',
493            $t instanceof LeftBraceToken => '{',
494            $t instanceof RightBraceToken => '}',
495            $t instanceof WhitespaceToken => ' ',
496            $t instanceof CdoToken => '<!--',
497            $t instanceof CdcToken => '-->',
498            default => '',
499        };
500    }
501
502    /**
503     * @param list<Token> $tokens
504     * @return list<Token>
505     */
506    private static function trimWhitespace(array $tokens): array
507    {
508        $start = 0;
509        $end = count($tokens) - 1;
510        while ($start <= $end && ($tokens[$start] instanceof WhitespaceToken || $tokens[$start] instanceof EofToken)) {
511            $start++;
512        }
513        while ($end >= $start && ($tokens[$end] instanceof WhitespaceToken || $tokens[$end] instanceof EofToken)) {
514            $end--;
515        }
516        return array_slice($tokens, $start, $end - $start + 1);
517    }
518}