Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG-GRAVITYPDF.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ New features
* **Descendant rules through inline elements.** A descendant rule whose ancestor is an inline element, a block inside a table cell, or the `tbody` a table leaves out, such as `span em`, or `.note b` for a `<span class="note">`, applies. [mpdf/mpdf#830] [#538] [#621]
* **Attribute selectors and `:lang()`.** `[a]`, `[a=v]`, `[a~=v]`, `[a|=v]`, `[a^=v]`, `[a$=v]` and `[a*=v]`, with the `i` and `s` flags, match as in HTML: case-insensitively for the attributes HTML lists as such, and case-sensitively for the rest. `:lang()` matches the language an element inherits, from an ancestor or from the `<html>` or `<body>` tag. [mpdf/mpdf#134] [mpdf/mpdf#1838] [#538] [#627]
* **`:not()`, `:is()` and `:where()`.** Each takes a selector list whose selectors may have combinators, such as `p:not(.note, div > p)` or `:is(h2, h3) + p`. `:is()` and `:where()` leave out a selector they cannot read, as browsers do. `:not()` and `:is()` weigh as their most specific argument, and `:where()` as nothing. Rules using them were dropped before. [#538] [#628]
* **Pseudo-classes that look at what follows an element.** `:last-child`, `:nth-last-child()`, `:only-child`, `:last-of-type`, `:nth-last-of-type()`, `:only-of-type` and `:empty` match in the flow, in tables, headers and footers, and positioned blocks. Rules using them were dropped before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep dropping them. [#537] [#634]
* `WriteHTML()` reads ahead through the HTML it is given to count each element's children. An element still open at the end of a call that leaves it open is not known in full, so these do not match its children in that call, and nor does `:not()` of them.
* `:empty` matches as in browsers: an element holding white space is not empty. In the default mode mPDF's CSS reader turns each HTML comment into a space, so an element holding only a comment is not empty either. A table cell holding only white space is empty, as mPDF strips it.

Performance
-----------
Expand Down Expand Up @@ -193,6 +196,7 @@ Bugfixes
* `background-size` lengths such as `60mm` or `100px` drew the image 2.83 times too small, because they were used as points. [#574]
* A stylesheet `url()` did not load its image when the path had spaces or parentheses, when there was whitespace inside the parentheses, or when it was an SVG data URI that is not base64, such as Bootstrap's `form-select` arrow. [#573]
* `linear-gradient(to bottom, …)` and `to top` were drawn upside down, and so was the vertical half of `to bottom right` and the other corners. Angles ran counter-clockwise from pointing right, so `90deg` drew bottom to top. A gradient now runs as CSS Images gives: `0deg` points up and angles turn clockwise, `turn` is read, and a corner keyword leaves the other two corners halfway along. `-moz-`, `-webkit-` and `-o-` gradients keep their legacy angles, and their side keyword, such as `left`, names the side the gradient starts from. [#577]
* Characters mPDF moved into a substitute font (`useSubstitutions`, and the fonts for supplementary planes) were wrapped in a `<span>` that stylesheet rules reached, so `span { border: 1px solid }` drew a box around them, and the span counted among its siblings for `:nth-child()`. In the standard CSS mode no rule reaches that span and it is not counted, as a browser has no such element. The legacy mode styles it as before. [#634]

### Images and SVG

Expand Down Expand Up @@ -785,6 +789,7 @@ These changes do not change output.
[#528]: https://github.com/GravityPDF/mpdf/issues/528
[#533]: https://github.com/GravityPDF/mpdf/issues/533
[#535]: https://github.com/GravityPDF/mpdf/issues/535
[#537]: https://github.com/GravityPDF/mpdf/issues/537
[#538]: https://github.com/GravityPDF/mpdf/issues/538
[#549]: https://github.com/GravityPDF/mpdf/pull/549
[#552]: https://github.com/GravityPDF/mpdf/issues/552
Expand Down Expand Up @@ -831,3 +836,4 @@ These changes do not change output.
[#631]: https://github.com/GravityPDF/mpdf/pull/631
[#632]: https://github.com/GravityPDF/mpdf/issues/632
[#633]: https://github.com/GravityPDF/mpdf/pull/633
[#634]: https://github.com/GravityPDF/mpdf/pull/634
32 changes: 22 additions & 10 deletions src/Css/SelectorCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
* names attributes, and the operator empty for [name]. Values are compared case-insensitively, and held lowercased,
* for the attributes HTML lists as such, for id and class, whose values the tokenizer uppercases, and with the i flag.
*
* Pseudo-classes are held as [name, arguments...]: ['nth-child', a, b] and ['nth-of-type', a, b] for an+b, which
* :first-child and :first-of-type are written as, ['lang', ranges] with each language range lowercased, and
* ['not', selectors] and ['is', selectors] with each selector compiled. :where() is held as :is(), its specificity
* being the only difference.
* Pseudo-classes are held as [name, arguments...]: ['nth-child', a, b], ['nth-of-type', a, b], ['nth-last-child', a, b]
* and ['nth-last-of-type', a, b] for an+b, which :first-child, :first-of-type, :last-child and :last-of-type are
* written as, ['only-child'], ['only-of-type'] and ['empty'], ['lang', ranges] with each language range lowercased,
* and ['not', selectors] and ['is', selectors] with each selector compiled. :where() is held as :is(), its
* specificity being the only difference.
*/
class SelectorCompiler
{
Expand All @@ -44,6 +45,20 @@ class SelectorCompiler
'TARGET' => true, 'TEXT' => true, 'TYPE' => true, 'VALIGN' => true, 'VALUETYPE' => true, 'VLINK' => true,
];

/**
* @var array[] The pseudo-classes written without an argument, compiled. Those that count from the first or last
* sibling are held as the nth formula they stand for
*/
private static $pseudoClassesWithoutArguments = [
'first-child' => ['nth-child', 0, 1],
'first-of-type' => ['nth-of-type', 0, 1],
'last-child' => ['nth-last-child', 0, 1],
'last-of-type' => ['nth-last-of-type', 0, 1],
'only-child' => ['only-child'],
'only-of-type' => ['only-of-type'],
'empty' => ['empty'],
];

/**
* @var Mpdf
*/
Expand Down Expand Up @@ -242,11 +257,8 @@ private function parsePseudoClass($text, &$pos, array &$specificity, &$universal
$specificity[1]++;
}

if ($name === 'first-child') {
return ['nth-child', 0, 1];
}
if ($name === 'first-of-type') {
return ['nth-of-type', 0, 1];
if (isset(self::$pseudoClassesWithoutArguments[$name])) {
return self::$pseudoClassesWithoutArguments[$name];
}

if (!$this->isAt($text, $pos, '(')) {
Expand All @@ -269,7 +281,7 @@ private function parsePseudoClass($text, &$pos, array &$specificity, &$universal
return $ranges === null ? null : ['lang', $ranges];
}

if ($name !== 'nth-child' && $name !== 'nth-of-type') {
if (!in_array($name, ['nth-child', 'nth-of-type', 'nth-last-child', 'nth-last-of-type'], true)) {
return null;
}

Expand Down
59 changes: 50 additions & 9 deletions src/Css/SelectorMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ class SelectorMatcher
/** It fails for this element and for any other whose ancestors are all among this one's */
const FAILS_FOR_ANCESTORS = 2;

/**
* @var bool Whether a pseudo-class that needs what follows an element was matched against one for which that is
* not known, and so did not match. :not() then does not match either
*/
private $undecided = false;

/**
* Whether a selector matches the last element of a path
*
Expand Down Expand Up @@ -158,12 +164,14 @@ private function matchesCompound(array $compound, array $path, $parent, $index)

/**
* Whether a pseudo-class matches an element. :nth-child() counts the element among all its element siblings, and
* :nth-of-type() among those with its tag; the document has no siblings, so it matches neither. :lang() matches the
* language the element has or inherits. :is() matches when any selector in its list matches from the element,
* combinators and all, and :not() when none does
* :nth-of-type() among those with its tag. :nth-last-child(), :nth-last-of-type(), :only-child and :only-of-type
* count from the parent's last child, and :empty reads whether the element holds anything, as
* TracksOpenElements::readAhead() found; where it could not, they do not match. The document has no siblings, so
* it matches none of these. :lang() matches the language the element has or inherits. :is() matches when any
* selector in its list matches from the element, combinators and all, and :not() when none does and none was left
* undecided
*
* @param array $pseudo A compiled pseudo-class: ['nth-child', a, b], ['nth-of-type', a, b], ['lang', ranges],
* or ['is', selectors] or ['not', selectors]
* @param array $pseudo A compiled pseudo-class, as SelectorCompiler describes them
* @param array $element The element's frame or record, as element() gives it
* @param array[] $path As matchesFrom() takes it
* @param int $parent The depth of the element's parent on $path, as matchesFrom() takes it
Expand All @@ -174,13 +182,21 @@ private function matchesCompound(array $compound, array $path, $parent, $index)
private function matchesPseudoClass(array $pseudo, array $element, array $path, $parent, $index)
{
if ($pseudo[0] === 'is' || $pseudo[0] === 'not') {
$outer = $this->undecided;
$this->undecided = false;
$matched = false;
foreach ($pseudo[1] as $selector) {
if ($this->matchesFrom($selector, count($selector['compounds']) - 1, $path, $parent, $index) === self::MATCHES) {
return $pseudo[0] === 'is';
$matched = true;
break;
}
}

return $pseudo[0] === 'not';
// :not() of what could not be decided cannot be either
$undecided = !$matched && $this->undecided;
$this->undecided = $outer || $undecided;

return $pseudo[0] === 'is' ? $matched : !$matched && !$undecided;
}

if ($pseudo[0] === 'lang') {
Expand All @@ -202,7 +218,31 @@ private function matchesPseudoClass(array $pseudo, array $element, array $path,
return $this->isNth($pseudo[1], $pseudo[2], $index + 1);
}

return $this->isNth($pseudo[1], $pseudo[2], $element['nthOfType']);
if ($pseudo[0] === 'nth-of-type') {
return $this->isNth($pseudo[1], $pseudo[2], $element['nthOfType']);
}

// The rest need what follows the element, which is not always known
$siblings = $path[$parent]['childTotal'];
$ofType = isset($path[$parent]['childTypeTotals'][$element['tag']]) ? $path[$parent]['childTypeTotals'][$element['tag']] : null;
if (($pseudo[0] === 'empty' ? $element['empty'] : $ofType) === null) {
$this->undecided = true;

return false;
}

switch ($pseudo[0]) {
case 'nth-last-child':
return $this->isNth($pseudo[1], $pseudo[2], $siblings - $index);
case 'nth-last-of-type':
return $this->isNth($pseudo[1], $pseudo[2], $ofType - $element['nthOfType'] + 1);
case 'only-child':
return $siblings === 1;
case 'only-of-type':
return $ofType === 1;
default:
return $element['empty'];
}
}

/**
Expand Down Expand Up @@ -296,7 +336,8 @@ private function isNth($a, $b, $position)
* @param int $parent The depth of the element's parent on $path, as matchesFrom() takes it
* @param int $index The element's index among its parent's children, as matchesFrom() takes it
*
* @return array Its tag, id, classes, attr and nthOfType, and for an open element or the document its lang
* @return array Its tag, id, classes, attr and nthOfType, for an element its empty, and for an open element or
* the document its lang
*/
private function element(array $path, $parent, $index)
{
Expand Down
14 changes: 11 additions & 3 deletions src/Mpdf.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ class Mpdf implements \Psr\Log\LoggerAwareInterface
const OBJECT_IDENTIFIER = "\xbb\xa4\xac";

/**
* The attribute, as the tokenizer names it, that marks a span markScriptToLang() wraps a run of another script in
* The attribute, as the tokenizer names it, that marks a span markScriptToLang() wraps a run of another script in,
* or one the character substitution wraps a run it moves into another font in
*/
const SCRIPT_RUN_ATTRIBUTE = 'DATA-MPDF-SCRIPT-RUN';

Expand Down Expand Up @@ -14698,6 +14699,12 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c
$this->openElements = $this->newOpenElementStack();
}
$floor = $standIn ? count($this->openElements) : 1; // the frames this HTML cannot close
// What is read ahead is for this call's tokens only. A header or index written in the middle of the flow puts
// the flow's back when it is done. The legacy mode reads nothing ahead, as it drops the selectors that need it
$outerLookAhead = $this->lookAhead;
if ($this->cssMode === CssMode::STANDARD) {
$this->readAhead($a, $floor, $standIn, $close);
}

$pbc = 0;
$this->subPos = -1;
Expand Down Expand Up @@ -15239,6 +15246,7 @@ function WriteHTML($html, $mode = HTMLParserMode::DEFAULT_MODE, $init = true, $c
if ($flowElements !== null) {
$this->openElements = $flowElements;
}
$this->lookAhead = $outerLookAhead;
}

/* -- CSS-POSITION -- */
Expand Down Expand Up @@ -26740,7 +26748,7 @@ function SubstituteCharsSIP(&$writehtml_a, &$writehtml_i, &$writehtml_e)
return 0;
}
$writehtml_a[$writehtml_i] = $writehtml_e = $m[1];
array_splice($writehtml_a, $writehtml_i + 1, 0, ['span style="font-family: ' . $font . '"', $m[2], '/span', $m[3]]);
array_splice($writehtml_a, $writehtml_i + 1, 0, ['span data-mpdf-script-run style="font-family: ' . $font . '"', $m[2], '/span', $m[3]]);
$this->subPos = $writehtml_i;
return 4;
}
Expand Down Expand Up @@ -27181,7 +27189,7 @@ private function substitutionStep($text, $from, $start, $l, $font, $replacement
return [
'before' => mb_substr($text, 0, $offset, 'UTF-8'),
'insert' => $replacement === null
? ['span style="font-family: ' . $font . '"', mb_substr($text, $offset, $l, 'UTF-8'), '/span']
? ['span data-mpdf-script-run style="font-family: ' . $font . '"', mb_substr($text, $offset, $l, 'UTF-8'), '/span']
: [$font, $replacement, '/' . $font],
'rest' => mb_substr($text, $offset + $l, null, 'UTF-8'),
'from' => $start + $l,
Expand Down
Loading
Loading