Repository navigation
Support more CSS selectors and apply CSS rules by specificity #520
Description
Tracks the work to make mPDF's selectors, cascade and inheritance behave like a browser's. That covers rules that reach the wrong element today, selectors that are silently dropped, an opt-in cascade ordered by specificity and source order, values that are lost or wrong on their way from an element to its descendants, and the CSS values mPDF reads wrongly or not at all.
Progress
Updated 2026-10-06, at f365f734.
-
Steps 1–25 and 27: done. Every step issue except Inherit from the parent element's computed values under the opt-in cascade #547 is closed, and all their PRs are merged.
-
cssModelegacy: all merged.- Render the new selectors and shorthand resets as before in cssMode legacy #633 turns off the new selectors and the shorthand resets.
- Draw linear gradients in their old directions under cssMode legacy #639 keeps the old gradient directions.
- Read @media queries and line-height: 0 as before under cssMode legacy #643 keeps the old
@mediaandline-height: 0reading.
-
In review:
Step Issue PR 26. Computed values on the element stack Inherit from the parent element's computed values under the opt-in cascade #547 Inherit from the parent element's computed values in the standard CSS mode #683 Found along the way, also in review:
-
Not started: the rest of Many CSS values are ignored or read wrongly, and four common inputs break the document #552, sub-issues A gradient's first colour stop written at a unitless
0is dropped, sorepeating-radial-gradient(red 0, blue 10px)draws solid blue #584–Lay out an element in CSS columns withcolumn-count,column-widthandcolumns#612, after step 26.
Merged so far
| Step | Issue | PR |
|---|---|---|
| 1. Drop a descendant rule with a part mPDF cannot match | #521 | #519 |
2. Normalise every nth-child() on its own |
#522 | #564 |
3. Strip !important from inline styles |
#523 | #561 |
| 4. Stop a nested at-rule swallowing the next rule | #524 | #566 |
5. Descendant :lang() and [lang] |
#525 | #559 |
| 6. Index nth-child keys | #526 | #580 |
| 7. Compound selectors with an id and classes | #527 | #568 |
8. td/th:nth-child counted by cell, td/th:first-child, and tr:nth-child within its row group |
#528 | #572, #582 |
9. :link, :root, :hover and the like |
#529 | #642 |
10. * |
#530 | #644 |
| 11. Presentational attributes | #531 | #641 |
12. !important |
#532 | #638 |
| 13. Track open elements | #533 | #613 |
| 14. Compiled rules and a right-to-left matcher | #538 | #619, #620, #621, #627, #628, #629 |
15. The standard cascade, as the default cssMode |
#535 | #631 |
15a. cssMode legacy renders as mPDF v7 |
#633, #639, #643 | |
| 16. Stylesheet tokenizer | #536 | #635 |
| 17. Look-ahead pseudo-classes | #537 | #634 |
| 18. One list of inherited properties | #539 | #648 |
19. currentColor |
#540 | #651 |
| 20. A block inside an inline element | #541 | #653 |
| 21. Rows pass properties to cells | #542 | #655 |
| 22. Tables inherit from the block around them | #543 | #656 |
| 23. Text decorations propagate | #544 | #654 |
| 24. Values computed from the parent's | #545 | #647 |
25. inherit, initial, unset, revert |
#546 | #652 |
27. line-height on inline elements |
#548 | #646 |
Also merged: #637 fixes #636, an HTML comment drawn as a space.
From #552:
| Change | PR |
|---|---|
font shorthand family read as font-family reads it |
#560 |
@page size names, and the space around a page box counted once |
#562 |
space-syntax rgb()/hsl() with a slash before the alpha |
#563 |
| a declaration mPDF cannot read is dropped | #565 |
line-height: 0, and a negative line-height ignored |
#567 |
break-* read as page-break-* |
#569 |
#rgba/#rrggbbaa alpha, channels clamped |
#570 |
url() with spaces, parentheses, quotes or an SVG data URI |
#573 |
background-size lengths in millimetres |
#574 |
@media and @import matched against print and the page size |
#575 |
gradient to keywords and angles |
#577 |
outline-* no longer read as the text outline |
#578 |
rotate: 90deg on PHP 8 |
#579 |
small-caps in the font shorthand, which also resets what it does not name |
#581 |
border and background shorthands in any order |
#583 |
border and background shorthands reset what they do not name |
#614 |
page-break-before on a table |
#622 |
list-style-image URL case kept |
#623 |
Decisions made
cssMode:standardis the default.legacyis the opt-out, and renders CSS as mPDF v7 did.- Under
legacy, the new behaviour is off: the new selectors, the cascade order, the shorthand resets, Match:root,html,a:linkanda:visited, which have a fixed answer in a PDF #529–Honour!importantin stylesheets and inline styles #532, the inheritance changes and the new Many CSS values are ignored or read wrongly, and four common inputs break the document #552 features. - Fixes for crashes and for misread or corrupted values apply in both modes.
- Class and id names: standard mode matches them case-insensitively, as today.
- Attribute values: they match case-sensitively, unless the selector ends with
i. The attributes HTML treats as case-insensitive match case-insensitively, unless the selector ends withs.idandclassalways match case-insensitively. - Let a table inherit from the block around it, not only from the document defaults #543: under
standard, tables inherit from the block around them. Underlegacy, they start from the document defaults, as before. - A descendant can remove a text decoration it should only add to, because
text-decorationis inherited instead of propagated #544: text decorations propagate, understandard. - Shorthands reset the longhands they do not name, as CSS does, on by default. This covers
font(Draw small-caps in the font shorthand as small capitals and reset what it does not name #581) andbackground/border(Reset the longhands a border or background shorthand does not name #614). - Many CSS values are ignored or read wrongly, and four common inputs break the document #552:
- Fix every value read wrongly, and build every high-priority missing feature except flex and grid.
- Then build the medium-priority list, one PR per property group.
- Flex and grid get an issue only (Flexbox and grid layout: the design constraints in a streaming renderer #600).
- Many CSS values are ignored or read wrongly, and four common inputs break the document #552 comes after the rest of this plan.
Measured on gravitypdf at b6a07921, PHP 8.5.4. The "browser" column is what the Selectors and Cascade specs give.
How it works today
There is no selector matcher and no specificity. CssParser::processCssString() splits the stylesheet with /(.*?)\{(.*?)\}/, uppercases each selector, and splits it on whitespace. Each part must match one of ten regex shapes in SelectorParser: tag, .a.b, #id, tag.class, tag#id, [lang=x], :lang(x), tag[lang=x], tag:lang(x), tag:nth-child(…). Anything else is dropped.
A single-part selector becomes a string key (P, CLASS>>A.B, DIV>>ID>>MAIN). A descendant selector becomes a path in a nested array, whose subtrees are lifted into blk[]['cascadeCSS'] for each block and into CssManager::$tablecascadeCSS for tables. Inline elements never push a level.
CssMerger::merge() applies these groups in a fixed order, and the last one to set a property wins:
- inherited values from the parent block
- HTML presentational attributes
- built-in defaults, which beat the attributes (CSS does the reverse)
cellpadding/cellspacingtag.class, for each class combination, in alphabetical ordertr/td/th:nth-child[lang]#idtag.classtag[lang]tag#id- descendant rules, whatever their weight; the one lifted from the nearest matching ancestor wins
- inline
style=""
!important is stripped from stylesheets, and left in the value of inline styles.
Wrong precedence
| Rules | Element | Browser | mPDF |
|---|---|---|---|
#i{green} p.c{red} |
<p id="i" class="c"> |
green | red |
#i{green} div p{red} |
<div><p id="i"> |
green | red |
.c{green} div p{red} |
<div><p class="c"> |
green | red |
body p{red} .c{green} |
<p class="c"> |
green | red |
p.c{red} .c.d{green} |
<p class="c d"> |
green | red |
.c p{green} div div p{red} |
<div class="c"><div><p> |
green | red |
#o p{green} div div p{red} |
<div id="o"><div><p> |
green | red |
.b{green} .a{red} |
<p class="a b"> or <p class="b a"> |
red | green |
.a{red} .b{green} .a{blue} |
<p class="a b"> |
blue | green |
p{green !important} #i{red} |
<p id="i"> |
green | red |
p{green !important} |
<p style="color:red"> |
green | red |
.Foo{green} |
<p class="foo"> |
not matched in no-quirks mode, matched in quirks mode | matched |
Steps
Each step is its own fix/<issue>-<slug> or feature/<issue>-<slug> branch and adds tests.
Quick fixes
Each is small, and each makes a rule reach the right element or stops a value being corrupted.
- A descendant rule with a part mPDF cannot match is applied to the element its first parts name #521 Drop a descendant rule with a part mPDF cannot match
- A selector with two
nth-child()pseudo-classes is cut down to the first #522 Normalise everynth-child()on its own -
!importantin an inline style stays in the value, which drops a border shorthand and corrupts a padding one #523 Strip!importantfrom inline styles - An
@supports,@keyframes,@layeror@containerblock swallows the rule after it #524 Stop a nested at-rule swallowing the next rule - A descendant rule ending in
:lang()or[lang]never applies #525 Descendant:lang()and[lang] - Look up nth-child rules for table rows and cells directly instead of running a regex over every stylesheet key #526 Index nth-child keys, and count classes per compound
- Match selectors that combine an id with classes, such as
p#i.c#527 Compound selectors with an id and classes -
td:nth-child()counts grid columns, so acolspanorrowspanbefore the cell shifts it #528td/th:nth-childcounted by cell, andtd/th:first-child
Also done with the quick fixes, from #552 (see the table above): space-syntax rgb()/hsl(), @page size names, the font shorthand's family, dropping declarations mPDF cannot parse, break-* read as page-break-*, and the remaining values read wrongly that are quick to fix. The ones that are not quick stay in #552.
Changes to existing output that need a decision
Each changes how existing documents render. Decided: all four apply under cssMode standard, the default, and not under legacy.
- Match
:root,html,a:linkanda:visited, which have a fixed answer in a PDF #529 Pseudo-classes that always or never match in a PDF - Match the universal selector
*#530 The universal selector - Built-in defaults override HTML attributes, so
<hr color>and<hr width>are ignored #531 Presentational attributes after the defaults - Honour
!importantin stylesheets and inline styles #532!important
Structure: a stack of open elements, then one matcher
- Track the stack of open elements while parsing HTML #533 Track open elements
- Match child, sibling, structural, attribute and negation selectors with compiled rules and a right-to-left matcher #538 Compiled rules and a right-to-left matcher
- Add an opt-in cascade that orders rules by specificity and source order #535 The standard cascade (Apply CSS rules by specificity and source order, with the legacy rendering behind the cssMode option #631, the default
cssMode) - Parse stylesheets with a tokenizer instead of a brace regex #536 A stylesheet tokenizer (in review: Parse stylesheets with a tokenizer, so a brace, semicolon or comment marker in a string no longer breaks the rules around it #635)
- Match
:last-child,:nth-last-child(),:only-child,:emptyand the other pseudo-classes that need to see what follows #537 Pseudo-classes that need to see what comes next (Match :last-child, :only-child, :empty and the other pseudo-classes that look at what follows an element #634)
Inheritance
Measured on gravitypdf at d660a410. Most inherited properties pass from blocks to blocks and inline elements, and through lists, cell content and positioned blocks. What goes wrong:
- A table starts from the document defaults, so a block around it passes nothing on.
- Rows and row groups pass nothing to their cells.
- Each channel that carries inherited properties is its own list, and each drops some.
- A block inside an inline element loses the inline's styles.
text-decorationis inherited rather than propagated.inherit,unset,revertandcurrentColorfail.- Parent-relative values (
font-weight: 700,bolder,font-size: larger) are ignored. line-heighton an inline element is ignored.
The full matrix is in #547.
Fixes
Values that are lost or wrong today.
- The lists that carry inherited properties to child blocks, positioned blocks and table cells each drop some #539 Carry every inherited property through every channel
-
currentColorin a border raises warnings and draws black, and a border with no colour is not drawn in the text colour #540currentColor, and a border with no colour - A block inside an inline element inherits nothing from it, and the inline's text after the block loses its styles #541 A block inside an inline element
-
tr,tbody,theadandtfootpass none of their properties to their cells #542 Rows and row groups pass their properties to cells - Support numeric
font-weight,bolderandlighter, andfont-size: largerandsmaller#545 Values computed from the parent's -
line-heighton an inline element does not change the height of its line #548line-heighton inline elements
Changes to existing output that need a decision
- Let a table inherit from the block around it, not only from the document defaults #543 Tables inherit from the block around them (decided: under
standard) - A descendant can remove a text decoration it should only add to, because
text-decorationis inherited instead of propagated #544 Text decorations propagate rather than inherit (decided: understandard)
New support and structure
- Support the CSS-wide keywords
inherit,initial,unsetandrevert#546inherit,initial,unsetandrevert - Inherit from the parent element's computed values under the opt-in cascade #547 Inherit from the parent's computed values, under the option
Property and value support
An audit of which CSS properties, values, units, functions and at-rules mPDF reads, measured at d660a410. Four common inputs break the document, about 35 values are read wrongly rather than ignored, and about 110 of 210 common properties are never read. The findings, their priorities and the order of work are in the issue.
- Many CSS values are ignored or read wrongly, and four common inputs break the document #552 Many CSS values are ignored or read wrongly, and four common inputs break the document
Not planned
:hoverand other interactive states. They have no meaning in a PDF.::beforeand::after. mPDF has no generated content, so Add support for CSS :before and :after pseudo-selectors mpdf/mpdf#770 stays open.- Dropping
allowedCSStags. Whethersup { … }should work is a separate question from matching. Match child, sibling, structural, attribute and negation selectors with compiled rules and a right-to-left matcher #538 can match any tag name, but which tags reachMergeCSSis decided per tag class.
Tests
Probe through a subclass of Mpdf that records $this->TextColor in Cell(), as TextRecordingMpdf records text, rather than reading rg out of the PDF bytes. A row's color never reaches the text of its cells, so row selectors assert fills. The precedence table above becomes one data provider, with a legacy column and a standard column.
Constraints
- PHP 5.6–8.5: no
??, typed properties, arrow functions or trailing commas in calls. Closures, as inpreg_replace_callback(), are fine. - New private methods are named without a leading underscore, and every method, test methods included, gets a docblock.
- The quick fixes correct rules that apply to the wrong element, never apply, or are corrupted, so they may change output. The decision steps change the output of existing documents on purpose, and Match child, sibling, structural, attribute and negation selectors with compiled rules and a right-to-left matcher #538 does for documents with selectors that are ignored today. Say so in each PR, and list the snapshot fixtures that changed and why.
- Look up nth-child rules for table rows and cells directly instead of running a regex over every stylesheet key #526 and Track the stack of open elements while parsing HTML #533 change nothing on the page: the snapshot suite passes with no fixture updated.
- The lists that carry inherited properties to child blocks, positioned blocks and table cells each drop some #539–
tr,tbody,theadandtfootpass none of their properties to their cells #542, Support numericfont-weight,bolderandlighter, andfont-size: largerandsmaller#545 andline-heighton an inline element does not change the height of its line #548 restore values that are lost today, and Support the CSS-wide keywordsinherit,initial,unsetandrevert#546 applies declarations that are ignored today, so they change output wherever a document relied on the loss. Let a table inherit from the block around it, not only from the document defaults #543 and A descendant can remove a text decoration it should only add to, becausetext-decorationis inherited instead of propagated #544 change output on purpose. - Add an opt-in cascade that orders rules by specificity and source order #535 and Inherit from the parent element's computed values under the opt-in cascade #547 are opt-in. With the option off, output stays byte-identical.
- Anything that adds state touched during a measuring pass is either put back by
StateSnapshotor rebuilt on the real pass.
Activity
nth-child()pseudo-classes is cut down to the first #522!importantin an inline style stays in the value, which drops a border shorthand and corrupts a padding one #523@supports,@keyframes,@layeror@containerblock swallows the rule after it #524:lang()or[lang]never applies #525p#i.c#527td:nth-child()counts grid columns, so acolspanorrowspanbefore the cell shifts it #528:root,html,a:linkanda:visited, which have a fixed answer in a PDF #529*#530<hr color>and<hr width>are ignored #531!importantin stylesheets and inline styles #5321 remaining item
:last-child,:nth-last-child(),:only-child,:emptyand the other pseudo-classes that need to see what follows #537currentColorin a border raises warnings and draws black, and a border with no colour is not drawn in the text colour #540tr,tbody,theadandtfootpass none of their properties to their cells #542text-decorationis inherited instead of propagated #544font-weight,bolderandlighter, andfont-size: largerandsmaller#545inherit,initial,unsetandrevert#546line-heighton an inline element does not change the height of its line #548Inherit from the parent element's computed values in the standard CSS…
Inherit from the parent element's computed values in the standard CSS…