diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index 1957334e4..fe65c0d76 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -199,6 +199,7 @@ Bugfixes * A descendant rule with a part mPDF cannot parse was cut short at that part and applied to the element before it. Such rules are now dropped. [#519] * An inline `style` with `!important` drew no border for a `border` shorthand, dropped the bottom padding of a two-value `padding`, drew text at 0pt for a `font-size`, and ignored an image's `height` with a warning. The flag is now removed, as it is from a stylesheet. [mpdf/mpdf#1010] [mpdf/mpdf#1707] [#523] [#561] * A declaration whose flag had a space after the bang, such as `margin: 5mm ! important`, kept the flag in its value: that margin set the right and left margins to `!` and the bottom one to `important`, a `font-size` was lost, and a `border` drew nothing. The flag is now found whatever its spacing and case, in stylesheets and inline styles, in either `cssMode`. [#532] [#638] +* With `cssMode` standard, an `!important` declaration in a plain `@page` rule lost to a plain one in a `@page :first`, `:left`, `:right` or named page rule, so `@page { margin-top: 30mm !important } @page :first { margin-top: 10mm }` gave the first page a 10mm top margin. The important declaration now wins, as a named page's does over its own `:first`, `:left` and `:right` pages. Of two important declarations, the one in the rule applied later wins, as before, and the default stylesheet's (`defaultCssFile`) beats the document's. A plain `@page` side margin is still mirrored, so on a left page an important `margin-left` is the right margin, and beats the `margin-right` of a `:left` rule. Legacy mode is unchanged. [#532] [#640] [#679] * Text styled with the `font` shorthand or `` came out as empty boxes when the family was not installed or had a space in its name, such as `font: 12pt Roboto` or `font: 16px "DejaVu Sans Mono"`. It was drawn in the first registered font, which is the emoji font in a full install. The family is now read as `font-family` reads it: a name is kept whole, the list is tried in order, and when mPDF knows none of it the text keeps the family it inherits. `font-family` itself also reads an unquoted name with spaces whole. [#552] [#560] * A descendant rule ending in `:lang()` or `[lang]`, such as `div :lang(fr)`, `div p:lang(fr)` or `table [lang=fr]`, never applied. It now applies in block content and in tables, and a regional language such as `fr-CA` falls back to the rule for `fr`, as the simple lang rules do. [#525] [#559] * A rule such as `td:nth-child(2):not(.x)` or `td:nth-child(2 of .x)` was applied to the second cell of every row, as if it were `td:nth-child(2)`. A rule with anything after an nth-child argument, or with an argument that is not a formula, is now dropped. [mpdf/mpdf#83] [#522] [#564] @@ -944,3 +945,5 @@ These changes do not change output. [#678]: https://github.com/GravityPDF/mpdf/pull/678 [#547]: https://github.com/GravityPDF/mpdf/issues/547 [#683]: https://github.com/GravityPDF/mpdf/pull/683 +[#640]: https://github.com/GravityPDF/mpdf/issues/640 +[#679]: https://github.com/GravityPDF/mpdf/pull/679 diff --git a/src/CssManager.php b/src/CssManager.php index 0427799b2..79b392ea7 100644 --- a/src/CssManager.php +++ b/src/CssManager.php @@ -157,8 +157,9 @@ public function readDefaultCss($css) * Parse the CSS in HTML content into the stores readCss() describes, and file its compiled rules in a rule set * * The !important declarations of the rules stored by key are kept apart as well, and laid again over $CSS after - * each read, those of the default stylesheet last, so that what reads $CSS directly (@page, BODY, SVG's classes) - * finds them over any declaration that is not important. + * each read, those of the default stylesheet last, so that what reads one key of $CSS directly, such as SVG's + * classes, finds them over any declaration that is not important. What merges several keys lays the stores over + * the merged rules itself: pageProperties() for the @page rules, the merger for BODY. * * @param string $html * @param RuleSet $rules @@ -381,14 +382,9 @@ public function getBorderDominance($side) */ public function pseudoPagesSetSideMargins($name) { - $prefixes = ['@PAGE>>']; - if ($name) { - $prefixes[] = '@PAGE>>NAMED>>' . strtoupper($name) . '>>'; - } - - foreach ($prefixes as $prefix) { + foreach ($this->pageRuleKeys($name) as $key) { foreach (['FIRST', 'LEFT', 'RIGHT'] as $pseudo) { - $rule = $prefix . 'PSEUDO>>' . $pseudo; + $rule = $key . '>>PSEUDO>>' . $pseudo; if (isset($this->CSS[$rule]['MARGIN-LEFT']) || isset($this->CSS[$rule]['MARGIN-RIGHT'])) { return true; } @@ -398,6 +394,64 @@ public function pseudoPagesSetSideMargins($name) return false; } + /** + * The properties the @page rules set on a page: the plain rule's, then the named page's, each followed by those + * of its :right, :left and :first pages that apply + * + * The rules are merged one over the other whatever their importance, so the !important declarations of each are + * then laid over them in the same order, and the default stylesheet's last. Legacy mode reads a declaration marked + * !important as any other, so there those stores are empty. + * + * A rule's odd-header-name or even-header-name, whichever the page's parity takes, becomes its header before the + * next rule is merged, so that a later rule's header beats it, and the footer names likewise. + * + * @param string $name The named page, or '' for none + * @param string $side 'R' or 'L', or '' for the defaults of every page + * @param bool $first Whether this is the first page + * @param string $oddEven 'E' for an even page when margins are mirrored, 'O' for any other page, or '' for the + * defaults of every page + * @return array + */ + public function pageProperties($name, $side, $first, $oddEven) + { + $parity = $oddEven == 'E' ? 'EVEN-' : 'ODD-'; + $properties = []; + foreach ([$this->CSS, $this->importantCss, $this->defaultImportantCss] as $css) { + foreach ($this->pageRuleKeys($name) as $key) { + if (!empty($css[$key])) { + $properties = array_merge($properties, $css[$key]); + } + + foreach (['HEADER', 'FOOTER'] as $part) { + if (isset($properties[$parity . $part . '-NAME'])) { + $properties[$part] = $properties[$parity . $part . '-NAME']; + unset($properties[$parity . $part . '-NAME']); + } + } + + $properties = array_merge($properties, $this->pseudoPagePropertiesIn($css, $key . '>>', $side, $first, $oddEven)); + } + } + + return $properties; + } + + /** + * The keys of the plain @page rule and, when a page is named, of the named page's rule + * + * @param string|null $name The named page, or '' or null for none + * @return string[] + */ + private function pageRuleKeys($name) + { + $keys = ['@PAGE']; + if ($name) { + $keys[] = '@PAGE>>NAMED>>' . strtoupper($name); + } + + return $keys; + } + /** * Whether the style sheet has a plain @page rule, or a :first, :left or :right one, that sets anything * @@ -428,14 +482,29 @@ public function hasUnnamedPageRules() * @return array */ public function pseudoPageProperties($prefix, $side, $first, $oddEven) + { + return $this->pseudoPagePropertiesIn($this->CSS, $prefix, $side, $first, $oddEven); + } + + /** + * The properties pseudoPageProperties() reads, from the rules given rather than $CSS + * + * @param array[] $css Rules under the keys of $CSS, such as those of getImportantCss() + * @param string $prefix + * @param string $side + * @param bool $first + * @param string $oddEven + * @return array + */ + private function pseudoPagePropertiesIn(array $css, $prefix, $side, $first, $oddEven) { $properties = []; foreach (['RIGHT' => $side == 'R', 'LEFT' => $side == 'L', 'FIRST' => $first] as $pseudo => $applies) { - if (!$applies || !isset($this->CSS[$prefix . 'PSEUDO>>' . $pseudo])) { + if (!$applies || !isset($css[$prefix . 'PSEUDO>>' . $pseudo])) { continue; } - $rule = $this->CSS[$prefix . 'PSEUDO>>' . $pseudo]; + $rule = $css[$prefix . 'PSEUDO>>' . $pseudo]; unset($rule['SIZE'], $rule['SHEET-SIZE']); if ($oddEven === 'E') { diff --git a/src/Mpdf.php b/src/Mpdf.php index 9da8da180..bc5ed85a2 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -16333,65 +16333,7 @@ function SetPagedMediaCSS($name, $first, $oddEven) $p['MARGIN-HEADER'] = strval($this->orig_hMargin - $outerTB) . 'mm'; $p['MARGIN-FOOTER'] = strval($this->orig_fMargin - $outerTB) . 'mm'; - // Basic page + selector - if (isset($this->cssManager->CSS['@PAGE'])) { - $zp = $this->cssManager->CSS['@PAGE']; - } else { - $zp = []; - } - if (is_array($zp) && !empty($zp)) { - $p = array_merge($p, $zp); - } - - if (isset($p['EVEN-HEADER-NAME']) && $oddEven == 'E') { - $p['HEADER'] = $p['EVEN-HEADER-NAME']; - unset($p['EVEN-HEADER-NAME']); - } - if (isset($p['ODD-HEADER-NAME']) && $oddEven != 'E') { - $p['HEADER'] = $p['ODD-HEADER-NAME']; - unset($p['ODD-HEADER-NAME']); - } - if (isset($p['EVEN-FOOTER-NAME']) && $oddEven == 'E') { - $p['FOOTER'] = $p['EVEN-FOOTER-NAME']; - unset($p['EVEN-FOOTER-NAME']); - } - if (isset($p['ODD-FOOTER-NAME']) && $oddEven != 'E') { - $p['FOOTER'] = $p['ODD-FOOTER-NAME']; - unset($p['ODD-FOOTER-NAME']); - } - - $p = array_merge($p, $this->cssManager->pseudoPageProperties('@PAGE>>', $side, $first, $oddEven)); - - // If named page - if ($name) { - if (isset($this->cssManager->CSS['@PAGE>>NAMED>>' . $name])) { - $zp = $this->cssManager->CSS['@PAGE>>NAMED>>' . $name]; - } else { - $zp = []; - } - if (is_array($zp) && !empty($zp)) { - $p = array_merge($p, $zp); - } - - if (isset($p['EVEN-HEADER-NAME']) && $oddEven == 'E') { - $p['HEADER'] = $p['EVEN-HEADER-NAME']; - unset($p['EVEN-HEADER-NAME']); - } - if (isset($p['ODD-HEADER-NAME']) && $oddEven != 'E') { - $p['HEADER'] = $p['ODD-HEADER-NAME']; - unset($p['ODD-HEADER-NAME']); - } - if (isset($p['EVEN-FOOTER-NAME']) && $oddEven == 'E') { - $p['FOOTER'] = $p['EVEN-FOOTER-NAME']; - unset($p['EVEN-FOOTER-NAME']); - } - if (isset($p['ODD-FOOTER-NAME']) && $oddEven != 'E') { - $p['FOOTER'] = $p['ODD-FOOTER-NAME']; - unset($p['ODD-FOOTER-NAME']); - } - - $p = array_merge($p, $this->cssManager->pseudoPageProperties('@PAGE>>NAMED>>' . $name . '>>', $side, $first, $oddEven)); - } + $p = array_merge($p, $this->cssManager->pageProperties($name, $side, $first, $oddEven)); $orientation = $mgl = $mgr = $mgt = $mgb = $mgh = $mgf = ''; $header = $footer = ''; diff --git a/tests/Mpdf/DrawnStyles.php b/tests/Mpdf/DrawnStyles.php index 14e8079a1..d77fae6c9 100644 --- a/tests/Mpdf/DrawnStyles.php +++ b/tests/Mpdf/DrawnStyles.php @@ -3,8 +3,8 @@ namespace Mpdf; /** - * Writes a document through TextRecordingMpdf, to read the colour and style each piece of text is drawn in, or - * matches a selector against the elements a document leaves open + * Writes a document through TextRecordingMpdf, to read the colour and style each piece of text is drawn in, or where + * on the page it lies, or matches a selector against the elements a document leaves open */ trait DrawnStyles { @@ -210,4 +210,55 @@ private function matchesLastOpenElement($html, $selector) return $matcher->matches($compiler->compile($selector), $mpdf->getOpenElements()); } + + /** + * Justified paragraphs of text + * + * @param int $paragraphs + * + * @return string + */ + private function story($paragraphs) + { + return str_repeat('

' . str_repeat('Lorem ipsum dolor sit amet, consectetur adipiscing elit. ', 8) . '

', $paragraphs); + } + + /** + * The narrowest left edge and widest right edge of the boxes under each key, rounded to a tenth of a millimetre + * + * @param array[] $boxes Each a key, such as the page, then a left and right edge, as TextRecordingMpdf records them + * + * @return array[] By key, in the order the keys first appear + */ + private function spans(array $boxes) + { + $spans = []; + foreach ($boxes as $box) { + list($key, $left, $right) = $box; + $spans[$key] = isset($spans[$key]) ? [min($spans[$key][0], $left), max($spans[$key][1], $right)] : [$left, $right]; + } + + return array_map(static function ($span) { + return [round($span[0], 1), round($span[1], 1)]; + }, $spans); + } + + /** + * The top of the first line drawn on each page, rounded to a tenth of a millimetre + * + * @param array[] $boxes As TextRecordingMpdf records them + * + * @return float[] By page number, in the order the pages were drawn + */ + private function firstLineTops(array $boxes) + { + $tops = []; + foreach ($boxes as $box) { + if (!isset($tops[$box[0]])) { + $tops[$box[0]] = round($box[3], 1); + } + } + + return $tops; + } } diff --git a/tests/Mpdf/ImportantPageRuleTest.php b/tests/Mpdf/ImportantPageRuleTest.php new file mode 100644 index 000000000..34c0a85d5 --- /dev/null +++ b/tests/Mpdf/ImportantPageRuleTest.php @@ -0,0 +1,150 @@ +write($css, $this->story(20), ['cssMode' => $cascade] + $config); + + $this->assertSame($tops, array_slice($this->firstLineTops($mpdf->drawnBoxes), 0, 2, true)); + } + + /** + * Rules giving the first page a top margin of its own, with what each cascade makes of them + * + * @return array[] + */ + public function firstPageProvider() + { + $important = '@page { margin-top: 30mm !important; } @page :first { margin-top: 10mm; }'; + $firstImportant = '@page { margin-top: 30mm; } @page :first { margin-top: 10mm !important; }'; + $bothImportant = '@page { margin-top: 30mm !important; } @page :first { margin-top: 10mm !important; }'; + $firstWrittenFirst = '@page :first { margin-top: 10mm !important; } @page { margin-top: 30mm !important; }'; + $default = ['defaultCssFile' => __DIR__ . '/../data/css/important-page-default.css']; + $overDefault = '@page :first { margin-top: 10mm !important; } @page { margin-top: 20mm; }'; + + return $this->bothModes([ + 'an important @page beats a plain :first' => [$important, [1 => 30.0, 2 => 30.0], [1 => 10.0, 2 => 30.0]], + 'an important :first beats a plain @page' => [$firstImportant, [1 => 10.0, 2 => 30.0], [1 => 10.0, 2 => 30.0]], + ]) + [ + 'an important :first beats an important @page (standard)' => [CssMode::STANDARD, $bothImportant, [1 => 10.0, 2 => 30.0]], + 'an important :first written first beats an important @page (standard)' => [CssMode::STANDARD, $firstWrittenFirst, [1 => 10.0, 2 => 30.0]], + 'the default stylesheet\'s important @page beats an important :first (standard)' => [CssMode::STANDARD, $overDefault, [1 => 40.0, 2 => 40.0], $default], + 'the default stylesheet\'s important @page beats an important :first (legacy)' => [CssMode::LEGACY, $overDefault, [1 => 10.0, 2 => 20.0], $default], + ]; + } + + /** + * The side margins of a right and a left page, the :left and :right rules turning mirrored margins on. A plain + * @page margin is mirrored, so an important margin-left is the inner margin: the left of a right page and the + * right of a left page, where it beats the side margin a :left or :right rule gives that edge + * + * @dataProvider sidesProvider + * + * @param string $cascade + * @param string $css + * @param array[] $spans The left and right edges of the lines on the first two pages + */ + public function testLeftAndRightPages($cascade, $css, $spans) + { + $mpdf = $this->write($css, $this->story(20), ['cssMode' => $cascade]); + + $this->assertSame($spans, array_slice($this->spans($mpdf->drawnBoxes), 0, 2, true)); + } + + /** + * Rules giving the right and left pages side margins of their own, with what each cascade makes of them + * + * @return array[] + */ + public function sidesProvider() + { + $inner = '@page { margin-left: 30mm !important; margin-right: 10mm; } + @page :right { margin-left: 20mm; margin-right: 20mm; } + @page :left { margin-left: 20mm; margin-right: 20mm; }'; + $outer = '@page { margin-left: 10mm; margin-right: 40mm !important; } + @page :right { margin-right: 20mm; } + @page :left { margin-left: 20mm; }'; + $pseudoImportant = '@page { margin-left: 30mm !important; margin-right: 10mm !important; } + @page :left { margin-right: 25mm !important; }'; + + return $this->bothModes([ + 'an important inner margin' => [$inner, [1 => [30.0, 190.0], 2 => [20.0, 180.0]], [1 => [20.0, 190.0], 2 => [20.0, 190.0]]], + 'an important outer margin' => [$outer, [1 => [10.0, 170.0], 2 => [40.0, 200.0]], [1 => [10.0, 190.0], 2 => [20.0, 200.0]]], + ]) + [ + 'an important :left beats an important @page (standard)' => [CssMode::STANDARD, $pseudoImportant, [1 => [30.0, 200.0], 2 => [10.0, 185.0]]], + ]; + } + + /** + * The side margins of a named page and of the page after it + * + * @dataProvider namedPageProvider + * + * @param string $cascade + * @param string $css + * @param float[] $lefts The left edge of the lines on the first two pages + */ + public function testANamedPage($cascade, $css, $lefts) + { + $mpdf = $this->write($css, '
' . $this->story(20) . '
', ['cssMode' => $cascade]); + + $this->assertSame($lefts, array_map(static function ($span) { + return $span[0]; + }, array_slice($this->spans($mpdf->drawnBoxes), 0, 2, true))); + } + + /** + * Rules giving a named page, and its first page, a left margin of their own, with what each cascade makes of them + * + * @return array[] + */ + public function namedPageProvider() + { + $plain = '@page { margin-left: 50mm !important; } @page story { margin-left: 10mm; }'; + $first = '@page story { margin-left: 50mm !important; } @page story :first { margin-left: 10mm; }'; + $named = '@page { margin-left: 50mm !important; } @page story { margin-left: 10mm !important; }'; + + return $this->bothModes([ + 'an important @page beats a plain named page' => [$plain, [1 => 50.0, 2 => 50.0], [1 => 10.0, 2 => 10.0]], + 'an important named page beats its plain :first' => [$first, [1 => 50.0, 2 => 50.0], [1 => 10.0, 2 => 50.0]], + ]) + [ + 'an important named page beats an important @page (standard)' => [CssMode::STANDARD, $named, [1 => 10.0, 2 => 10.0]], + ]; + } + + /** + * A document with the style sheet and justified paragraphs, whose lines have been recorded as they were drawn + * + * @param string $css + * @param string $body + * @param array $config + * + * @return TextRecordingMpdf + */ + private function write($css, $body, array $config) + { + return $this->drawDocument('' . $body, $config); + } + +} diff --git a/tests/Mpdf/PseudoPageAreaTest.php b/tests/Mpdf/PseudoPageAreaTest.php index 356f54f30..ad549ec1c 100644 --- a/tests/Mpdf/PseudoPageAreaTest.php +++ b/tests/Mpdf/PseudoPageAreaTest.php @@ -10,6 +10,8 @@ class PseudoPageAreaTest extends \Yoast\PHPUnitPolyfills\TestCases\TestCase { + use DrawnStyles; + /** A page area 150mm wide on a right page and 170mm on a left page, for a float to run over. */ const WIDER_LEFT_PAGES = '@page { margin-left: 30mm; margin-right: 30mm; } @page :left { margin-left: 20mm; margin-right: 20mm; }'; @@ -90,14 +92,7 @@ public function testAPseudoPageRuleAppliesToItsOwnPages($html, $css, $tops) { $mpdf = $this->write($css, $html . $this->story(30)); - $firstLines = []; - foreach ($mpdf->drawnBoxes as $box) { - if (!isset($firstLines[$box[0]])) { - $firstLines[$box[0]] = round($box[3], 1); - } - } - - $this->assertSame($tops, array_slice($firstLines, 0, 3, true)); + $this->assertSame($tops, array_slice($this->firstLineTops($mpdf->drawnBoxes), 0, 3, true)); } /** @@ -443,36 +438,4 @@ private function painted(TextRecordingMpdf $mpdf) return $this->spans($boxes); } - /** - * The narrowest left edge and widest right edge of the boxes under each key, rounded to a tenth of a millimetre - * - * @param array[] $boxes Each a key, such as the page, then a left and right edge - * - * @return array[] By key, in the order the keys first appear - */ - private function spans($boxes) - { - $spans = []; - foreach ($boxes as $box) { - list($key, $left, $right) = $box; - $spans[$key] = isset($spans[$key]) ? [min($spans[$key][0], $left), max($spans[$key][1], $right)] : [$left, $right]; - } - - return array_map(static function ($span) { - return [round($span[0], 1), round($span[1], 1)]; - }, $spans); - } - - /** - * Justified paragraphs of text - * - * @param int $paragraphs - * - * @return string - */ - private function story($paragraphs) - { - return str_repeat('

' . str_repeat('Lorem ipsum dolor sit amet, consectetur adipiscing elit. ', 8) . '

', $paragraphs); - } - } diff --git a/tests/Snapshots/ImportantPageRuleLegacySnapshotTest.php b/tests/Snapshots/ImportantPageRuleLegacySnapshotTest.php new file mode 100644 index 000000000..5cadf7eca --- /dev/null +++ b/tests/Snapshots/ImportantPageRuleLegacySnapshotTest.php @@ -0,0 +1,21 @@ +cascade(); + } + + /** + * Four pages: a first page, a left page, then a named page and its own left page + */ + public function generatePdf() + { + ob_start(); + ?> + + +
+

!important in @page rules

+

Written with cssMode set to cascade(); ?>. Each panel fills its page area, so its edges are the page's margins.

+

Page 1, the first page and a right page. The plain @page rule's top and left margins of 30mm are important; @page :first gives them 10mm.

+

Standard: the panel starts 30mm from the top and the left, the important declarations beating :first. Legacy: 10mm from each, :first coming last. The right margin is 20mm in both.

+
+ +
+

Page 2, a left page. The side margins are mirrored, so the plain rule's important margin-left of 30mm is this page's right margin. @page :left gives both sides 50mm.

+

Standard: the panel ends 30mm from the right, the important margin beating :left. Legacy: 50mm. The left margin is 50mm in both. The top margin is 30mm in both.

+
+ +
+
+

Page 3, the first page of the named page appendix and a right page. The named page's margin-left of 10mm is important; its :first gives 50mm. Its :first's margin-top of 50mm is important too; the named page gives 15mm.

+

Standard: the panel starts 50mm from the top, the latest important declaration winning, and 10mm from the left, the named page's important margin beating its :first. Legacy: 50mm from each, :first coming last.

+
+ +
+

Page 4, the named page's left page. Its important margin-left of 10mm is the right margin here. Its margin-top of 15mm is not important.

+

Standard: the panel starts 30mm from the top, the plain rule's important margin beating the named page's. Legacy: 15mm, the named page coming last. The left margin is 50mm and the right 10mm in both.

+
+
+ mpdf = $this->createMpdf(['cssMode' => $this->cascade()]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/ImportantPageRuleStandardSnapshotTest.php b/tests/Snapshots/ImportantPageRuleStandardSnapshotTest.php new file mode 100644 index 000000000..5352a893b --- /dev/null +++ b/tests/Snapshots/ImportantPageRuleStandardSnapshotTest.php @@ -0,0 +1,21 @@ +