diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index fc5b6036f..6ea0ce14b 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -46,6 +46,12 @@ Read this section before upgrading from upstream mPDF. Each entry says what chan * Important declarations apply after the inline style: those of the stylesheets by specificity and then order, then those of `style=""`, then those of the default stylesheet (`defaultCssFile`), as a browser's own important declarations beat a page's. * A shorthand marked `!important` makes every longhand it sets important, so `border: 1mm solid green !important` beats a `border-top-color` of a more specific rule or of a later declaration in the same block. * `@page` rules, `body` rules and the class rules SVG text looks up take an important declaration over a later one of the same selector that is not. An important declaration of a `body` rule beats `
`. +* **HTML attributes that style an element are read only where HTML defines them**, as a browser reads them, in standard mode. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to keep the old reading. [#531] [#641] + * `color` applies to `font` and `hr` only, `width` to images, tables, cells, `hr`, `meter` and `progress`, `height` to images, tables, rows, row groups, cells, `meter` and `progress`, `valign` to rows, row groups and cells, `vspace` and `hspace` to images, and `align` to `div`, `p`, headings, table parts, `caption`, `hr` and `img`. So ``, ``, ``, `
` no longer do anything. mPDF's own tags, such as ``, keep every attribute. + * A stylesheet rule beats the attribute everywhere. Before, `align` on a paragraph, div or table cell, `nowrap` on a cell, `border` on a table, ` `, `
` in a table cell and `align` on a text field beat any rule for the same property. + * `` passes its alignment on to what it holds, and `` beats the marker the item inherits from its list. + * Attributes mPDF ignored now take effect: `
`, `
`, `` (float left or right, or vertical alignment), `
`, ``, and `
` wider than 1. + * A table's `border` gives each cell a 1px border of its own, which a rule for the table's border leaves alone. Before, the cells copied the table's border. New features ------------ @@ -800,6 +806,7 @@ These changes do not change output. [#526]: https://github.com/GravityPDF/mpdf/issues/526 [#527]: https://github.com/GravityPDF/mpdf/issues/527 [#528]: https://github.com/GravityPDF/mpdf/issues/528 +[#531]: https://github.com/GravityPDF/mpdf/issues/531 [#532]: https://github.com/GravityPDF/mpdf/issues/532 [#533]: https://github.com/GravityPDF/mpdf/issues/533 [#535]: https://github.com/GravityPDF/mpdf/issues/535 @@ -852,8 +859,9 @@ These changes do not change output. [#632]: https://github.com/GravityPDF/mpdf/issues/632 [#633]: https://github.com/GravityPDF/mpdf/pull/633 [#634]: https://github.com/GravityPDF/mpdf/pull/634 +[#635]: https://github.com/GravityPDF/mpdf/pull/635 [#636]: https://github.com/GravityPDF/mpdf/issues/636 [#637]: https://github.com/GravityPDF/mpdf/pull/637 -[#635]: https://github.com/GravityPDF/mpdf/pull/635 -[#639]: https://github.com/GravityPDF/mpdf/pull/639 [#638]: https://github.com/GravityPDF/mpdf/pull/638 +[#639]: https://github.com/GravityPDF/mpdf/pull/639 +[#641]: https://github.com/GravityPDF/mpdf/pull/641 diff --git a/src/Css/CssMerger.php b/src/Css/CssMerger.php index cb4a45e91..ee2c53d3e 100644 --- a/src/Css/CssMerger.php +++ b/src/Css/CssMerger.php @@ -57,6 +57,11 @@ class CssMerger */ private $borderMerger; + /** + * @var \Mpdf\Css\PresentationalHints + */ + private $presentationalHints; + /** * @var bool When true, state outside this object will be modified * @internal self::previewBlockCss() uses this property to look ahead without affecting state @@ -70,7 +75,8 @@ public function __construct( SelectorParser $selectorParser, InlinePropertyConverter $inlinePropertyConverter, ColorConverter $colorConverter, - BorderMerger $borderMerger + BorderMerger $borderMerger, + PresentationalHints $presentationalHints ) { $this->mpdf = $mpdf; $this->normalizeProperties = $normalizeProperties; @@ -79,6 +85,7 @@ public function __construct( $this->inlinePropertyConverter = $inlinePropertyConverter; $this->colorConverter = $colorConverter; $this->borderMerger = $borderMerger; + $this->presentationalHints = $presentationalHints; } /** @@ -199,7 +206,7 @@ private function mergeInCascadeOrder($inherit, $tag, array $attr) list($defaultRules, $importantDefaultRules) = $this->cssManager->getDefaultRules()->matchingDeclarations($tag, $id, $classes, $path); $this->mergeEach($defaultRules, $dominance); - $this->mergeInlineAttributes($tag, $attr); + $this->mergePresentationalHints($tag, $attr); $this->mergeTableSpecificCss($tag, $attr); if ($tag === 'BODY') { @@ -496,30 +503,7 @@ protected function mergeInlineAttributes($tag, $attr) } if ($tag === 'FONT') { - if (!empty($attr['FACE'])) { - $this->cssProperties = array_merge($this->cssProperties, $this->normalizeProperties->normalize(['FONT-FAMILY' => $attr['FACE']])); - } - - $size = isset($attr['SIZE']) ? $attr['SIZE'] : ''; - if ($size === '+1') { - $this->cssProperties['FONT-SIZE'] = '120%'; - } elseif ($size === '-1') { - $this->cssProperties['FONT-SIZE'] = '86%'; - } elseif ($size === '1') { - $this->cssProperties['FONT-SIZE'] = 'XX-SMALL'; - } elseif ($size === '2') { - $this->cssProperties['FONT-SIZE'] = 'X-SMALL'; - } elseif ($size === '3') { - $this->cssProperties['FONT-SIZE'] = 'SMALL'; - } elseif ($size === '4') { - $this->cssProperties['FONT-SIZE'] = 'MEDIUM'; - } elseif ($size === '5') { - $this->cssProperties['FONT-SIZE'] = 'LARGE'; - } elseif ($size === '6') { - $this->cssProperties['FONT-SIZE'] = 'X-LARGE'; - } elseif ($size === '7') { - $this->cssProperties['FONT-SIZE'] = 'XX-LARGE'; - } + $this->cssProperties = array_merge($this->cssProperties, $this->presentationalHints->ofFont($attr)); } if (!empty($attr['VALIGN'])) { @@ -537,6 +521,27 @@ protected function mergeInlineAttributes($tag, $attr) } } + /** + * Merges the presentational attributes of an element as the standard cascade reads them: only on the elements + * HTML gives each to. mPDF's own tags take every attribute, as they do under the legacy cascade + * + * @param string $tag HTML tag name + * @param array $attr HTML attributes + * @return void + */ + private function mergePresentationalHints($tag, array $attr) + { + if (in_array($tag, PresentationalHints::OWN_TAGS, true)) { + $this->mergeInlineAttributes($tag, $attr); + return; + } + + $hints = $this->presentationalHints->of($tag, $attr); + if ($hints) { + $this->setMergedCss($hints, false); + } + } + /** * Merge default CSS for the tag. * @@ -557,7 +562,7 @@ protected function mergeDefaultCss($tag) } /** - * Merge table specific CSS (CELLSPACING, CELLPADDING). + * Merge table specific CSS (CELLSPACING, CELLPADDING, and the cells' border of a table with a border attribute). * * @param string $tag HTML tag name * @param array $attr HTML attributes @@ -588,6 +593,12 @@ protected function mergeTableSpecificCss($tag, $attr) $this->cssProperties['PADDING-TOP'] = $cellPadding; $this->cssProperties['PADDING-BOTTOM'] = $cellPadding; } + + // Only the standard cascade marks a table's cells to take the border its border attribute gives them + if (!empty($tableCell['cell_border'])) { + $border = $this->presentationalHints->ofCellBorder(); + $this->setMergedCss($border, false); + } } /** diff --git a/src/Css/PresentationalHints.php b/src/Css/PresentationalHints.php new file mode 100644 index 000000000..98da6c7af --- /dev/null +++ b/src/Css/PresentationalHints.php @@ -0,0 +1,350 @@ +` is ignored as a browser + * ignores it. `bgcolor`, `cellpadding`, `cellspacing` and a table's `align` are read where the table is built. + */ +class PresentationalHints +{ + + /** + * mPDF's own tags that run the cascade. They keep every attribute mPDF has read for them + */ + const OWN_TAGS = ['BARCODE', 'DOTTAB', 'TEXTCIRCLE']; + + /** + * The elements each attribute that sets one property is a hint on, and the property. mPDF also sizes a meter and + * a progress bar by their width and height + */ + const PROPERTIES = [ + 'COLOR' => [['FONT', 'HR'], 'COLOR'], + 'WIDTH' => [['HR', 'IMG', 'METER', 'PROGRESS', 'TABLE', 'TD', 'TH'], 'WIDTH'], + 'HEIGHT' => [['IMG', 'METER', 'PROGRESS', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR'], 'HEIGHT'], + 'VALIGN' => [['TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR'], 'VERTICAL-ALIGN'], + ]; + + /** + * The text alignment `align` gives the elements it aligns the text of, by its value + */ + const TEXT_ALIGN = [ + 'DIV' => ['left' => 'left', 'right' => 'right', 'center' => 'center', 'middle' => 'center', 'justify' => 'justify'], + 'P' => ['left' => 'left', 'right' => 'right', 'center' => 'center', 'justify' => 'justify'], + 'CELL' => ['left' => 'left', 'right' => 'right', 'center' => 'center', 'middle' => 'center', 'absmiddle' => 'center', 'justify' => 'justify'], + ]; + + /** + * The elements that take their text alignment from `align` as a paragraph does, and as a table cell does + */ + const TEXT_ALIGN_OF = [ + 'DIV' => 'DIV', + 'P' => 'P', 'H1' => 'P', 'H2' => 'P', 'H3' => 'P', 'H4' => 'P', 'H5' => 'P', 'H6' => 'P', + 'THEAD' => 'CELL', 'TBODY' => 'CELL', 'TFOOT' => 'CELL', 'TR' => 'CELL', 'TD' => 'CELL', 'TH' => 'CELL', + ]; + + /** + * How `align` places an image, by its value + */ + const IMAGE_ALIGN = [ + 'left' => ['FLOAT' => 'left'], + 'right' => ['FLOAT' => 'right'], + 'top' => ['VERTICAL-ALIGN' => 'top'], + 'baseline' => ['VERTICAL-ALIGN' => 'baseline'], + 'texttop' => ['VERTICAL-ALIGN' => 'text-top'], + 'absmiddle' => ['VERTICAL-ALIGN' => 'middle'], + 'abscenter' => ['VERTICAL-ALIGN' => 'middle'], + 'middle' => ['VERTICAL-ALIGN' => 'middle'], + 'center' => ['VERTICAL-ALIGN' => 'middle'], + 'bottom' => ['VERTICAL-ALIGN' => 'bottom'], + ]; + + /** + * The margins `align` gives a horizontal rule, by its value + */ + const RULE_ALIGN = [ + 'left' => ['MARGIN-LEFT' => '0', 'MARGIN-RIGHT' => 'auto'], + 'right' => ['MARGIN-LEFT' => 'auto', 'MARGIN-RIGHT' => '0'], + 'center' => ['MARGIN-LEFT' => 'auto', 'MARGIN-RIGHT' => 'auto'], + ]; + + /** + * The marker `type` gives a list or a list item, by its value. The letters and numerals are matched as written, + * the words in any case + */ + const LIST_TYPES = [ + '1' => 'decimal', + 'a' => 'lower-latin', + 'A' => 'upper-latin', + 'i' => 'lower-roman', + 'I' => 'upper-roman', + 'none' => 'none', + 'disc' => 'disc', + 'circle' => 'circle', + 'square' => 'square', + ]; + + /** + * The font sizes of ``, by its value + */ + const FONT_SIZES = [ + '+1' => '120%', + '-1' => '86%', + '1' => 'XX-SMALL', + '2' => 'X-SMALL', + '3' => 'SMALL', + '4' => 'MEDIUM', + '5' => 'LARGE', + '6' => 'X-LARGE', + '7' => 'XX-LARGE', + ]; + + /** + * @var \Mpdf\Mpdf + */ + private $mpdf; + + /** + * @var \Mpdf\Css\NormalizeProperties + */ + private $normalizeProperties; + + /** + * @var array|null The border ofCellBorder() gives, once it has been asked for + */ + private $cellBorder; + + /** + * @param \Mpdf\Mpdf $mpdf For the characters a table cell can align on + * @param \Mpdf\Css\NormalizeProperties $normalizeProperties + */ + public function __construct(Mpdf $mpdf, NormalizeProperties $normalizeProperties) + { + $this->mpdf = $mpdf; + $this->normalizeProperties = $normalizeProperties; + } + + /** + * The properties an element's attributes set as hints. `dir` and `lang` are read on every element + * + * @param string $tag Uppercased + * @param array $attr The element's attributes, keyed uppercased + * + * @return array CSS properties, keyed uppercased + */ + public function of($tag, array $attr) + { + $hints = []; + + if (!empty($attr['DIR'])) { + $hints['DIRECTION'] = $attr['DIR']; + } + + if (!empty($attr['LANG'])) { + $hints['LANG'] = $attr['LANG']; + } + + foreach (self::PROPERTIES as $attribute => list($tags, $property)) { + if (!empty($attr[$attribute]) && in_array($tag, $tags, true)) { + $hints[$property] = $attr[$attribute]; + } + } + + if ($tag === 'FONT') { + $hints = array_merge($hints, $this->ofFont($attr)); + } + + if (isset($attr['ALIGN'])) { + $hints = array_merge($hints, $this->ofAlign($tag, strtolower(trim($attr['ALIGN'])), $attr)); + } + + if (isset($attr['NOWRAP']) && ($tag === 'TD' || $tag === 'TH')) { + $hints['WHITE-SPACE'] = 'nowrap'; + } + + if ($tag === 'IMG' || ($tag === 'INPUT' && isset($attr['TYPE']) && strtolower($attr['TYPE']) === 'image')) { + $hints = array_merge($hints, $this->ofImageSpacing($attr)); + } + + if (($tag === 'TABLE' || $tag === 'IMG') && isset($attr['BORDER'])) { + $hints = array_merge($hints, $this->ofBorder($tag, $attr['BORDER'])); + } + + if (isset($attr['TYPE']) && in_array($tag, ['OL', 'UL', 'LI'], true)) { + $type = array_key_exists($attr['TYPE'], self::LIST_TYPES) ? $attr['TYPE'] : strtolower($attr['TYPE']); + if (array_key_exists($type, self::LIST_TYPES)) { + $hints['LIST-STYLE-TYPE'] = self::LIST_TYPES[$type]; + } + } + + if ($tag === 'HR' && isset($attr['SIZE'])) { + $size = self::nonNegativeInteger($attr['SIZE']); + if ($size) { + $hints['HEIGHT'] = $size . 'px'; + } + } + + return $hints; + } + + /** + * The font family and size `` and `` set + * + * @param array $attr The font element's attributes + * + * @return array CSS properties + */ + public function ofFont(array $attr) + { + $hints = []; + + if (!empty($attr['FACE'])) { + $hints = $this->normalizeProperties->normalize(['FONT-FAMILY' => $attr['FACE']]); + } + + if (isset($attr['SIZE']) && array_key_exists($attr['SIZE'], self::FONT_SIZES)) { + $hints['FONT-SIZE'] = self::FONT_SIZES[$attr['SIZE']]; + } + + return $hints; + } + + /** + * The border each cell of a table with a `border` attribute takes, as a browser's default stylesheet gives it + * + * @return array CSS properties + */ + public function ofCellBorder() + { + if ($this->cellBorder === null) { + $this->cellBorder = $this->ofBorder('TABLE', '1'); + } + + return $this->cellBorder; + } + + /** + * The width of the border `border` gives a table or an image: a table's is 1px where the value is not a number, + * and an image has none at 0 + * + * @param string $tag TABLE or IMG + * @param string $value The attribute's value + * + * @return int Pixels, 0 for no border + */ + public static function borderWidth($tag, $value) + { + $width = self::nonNegativeInteger($value); + + return $width === null && $tag === 'TABLE' ? 1 : (int) $width; + } + + /** + * What `align` sets on an element + * + * @param string $tag + * @param string $align The attribute's value, lowercased + * @param array $attr The element's attributes, for the character a table cell aligns on + * + * @return array CSS properties + */ + private function ofAlign($tag, $align, array $attr) + { + if (array_key_exists($tag, self::TEXT_ALIGN_OF)) { + $values = self::TEXT_ALIGN[self::TEXT_ALIGN_OF[$tag]]; + if (isset($values[$align])) { + return ['TEXT-ALIGN' => $values[$align]]; + } + + return $align === 'char' && ($tag === 'TD' || $tag === 'TH') ? $this->ofCharAlign($attr) : []; + } + + if ($tag === 'IMG' && array_key_exists($align, self::IMAGE_ALIGN)) { + return self::IMAGE_ALIGN[$align]; + } + + if ($tag === 'HR' && array_key_exists($align, self::RULE_ALIGN)) { + return self::RULE_ALIGN[$align]; + } + + if ($tag === 'CAPTION' && $align === 'bottom') { + return ['CAPTION-SIDE' => 'bottom']; + } + + return []; + } + + /** + * The decimal alignment `align="char"` gives a table cell: on its `char`, or on the point where it names none + * + * @param array $attr The cell's attributes + * + * @return array CSS properties, none for a character mPDF cannot align on + */ + private function ofCharAlign(array $attr) + { + if (empty($attr['CHAR'])) { + return ['TEXT-ALIGN' => 'DPR']; + } + + $char = UtfString::strcode2utf(html_entity_decode($attr['CHAR'])); + $key = array_search($char, $this->mpdf->decimal_align, true); + + return $key === false ? [] : ['TEXT-ALIGN' => $key . 'R']; + } + + /** + * The margins `vspace` and `hspace` give an image + * + * @param array $attr The image's attributes + * + * @return array CSS properties + */ + private function ofImageSpacing(array $attr) + { + $hints = []; + + if (!empty($attr['VSPACE'])) { + $hints['MARGIN-TOP'] = $hints['MARGIN-BOTTOM'] = $attr['VSPACE']; + } + + if (!empty($attr['HSPACE'])) { + $hints['MARGIN-LEFT'] = $hints['MARGIN-RIGHT'] = $attr['HSPACE']; + } + + return $hints; + } + + /** + * The solid black border `border` draws around a table or an image + * + * @param string $tag TABLE or IMG + * @param string $value The attribute's value + * + * @return array CSS properties + */ + private function ofBorder($tag, $value) + { + $width = self::borderWidth($tag, $value); + + return $width ? $this->normalizeProperties->normalize(['BORDER' => $width . 'px solid #000000']) : []; + } + + /** + * Reads a value as HTML's rules for parsing non-negative integers do: the digits it starts with, after any + * whitespace and a plus sign + * + * @param string $value + * + * @return int|null Null where the value does not start with a number + */ + private static function nonNegativeInteger($value) + { + return preg_match('/^\s*\+?(\d+)/', $value, $matches) ? (int) $matches[1] : null; + } +} diff --git a/src/ServiceFactory.php b/src/ServiceFactory.php index ffc4300a8..28f78e5c4 100644 --- a/src/ServiceFactory.php +++ b/src/ServiceFactory.php @@ -12,6 +12,7 @@ use Mpdf\Css\InlinePropertyConverter; use Mpdf\Css\InlineStyleParser; use Mpdf\Css\NormalizeProperties; +use Mpdf\Css\PresentationalHints; use Mpdf\Css\SelectorParser; use Mpdf\Css\ShadowParser; use Mpdf\File\LocalContentLoader; @@ -115,7 +116,8 @@ public function getServices( $selectorParser, $inlinePropertyConverter, $colorConverter, - $borderMerger + $borderMerger, + new PresentationalHints($mpdf, $normalizeProperties) ); $cssManager = new CssManager($cssParser, $cssMerger); diff --git a/src/Tag/BlockTag.php b/src/Tag/BlockTag.php index 15b1ab0b2..4a1c76772 100644 --- a/src/Tag/BlockTag.php +++ b/src/Tag/BlockTag.php @@ -54,7 +54,7 @@ public function open($attr, &$ahtml, &$ihtml) } $cappos = 'T'; - if (isset($attr['ALIGN']) && strtolower($attr['ALIGN']) === 'bottom') { + if (isset($attr['ALIGN']) && strtolower($attr['ALIGN']) === 'bottom' && !$this->appliesStandardCascade()) { $cappos = 'B'; } elseif (isset($p['CAPTION-SIDE']) && strtolower($p['CAPTION-SIDE']) === 'bottom') { $cappos = 'B'; @@ -550,7 +550,7 @@ public function open($attr, &$ahtml, &$ihtml) } // mPDF 6 - if (!empty($attr['ALIGN'])) { + if (!empty($attr['ALIGN']) && !$this->appliesStandardCascade()) { $currblk['block-align'] = $this->getAlign($attr['ALIGN']); } diff --git a/src/Tag/Hr.php b/src/Tag/Hr.php index 8d023f2ab..8a00c3091 100644 --- a/src/Tag/Hr.php +++ b/src/Tag/Hr.php @@ -92,7 +92,7 @@ public function open($attr, &$ahtml, &$ihtml) $properties['WIDTH'] = NumericString::removePercentChar($properties['WIDTH']); // make "90%" become simply "90" $objattr['W-PERCENT'] = $properties['WIDTH']; } - if (isset($attr['WIDTH']) && NumericString::containsPercentChar($attr['WIDTH'])) { + if (isset($attr['WIDTH']) && NumericString::containsPercentChar($attr['WIDTH']) && !$this->appliesStandardCascade()) { $attr['WIDTH'] = NumericString::removePercentChar($attr['WIDTH']); // make "90%" become simply "90" $objattr['W-PERCENT'] = $attr['WIDTH']; } diff --git a/src/Tag/Input.php b/src/Tag/Input.php index 06b23d1e0..20d4f0217 100644 --- a/src/Tag/Input.php +++ b/src/Tag/Input.php @@ -80,7 +80,7 @@ public function open($attr, &$ahtml, &$ihtml) $objattr['fontfamily'] = $this->mpdf->FontFamily; $objattr['fontsize'] = $this->mpdf->FontSizePt; if ($this->mpdf->activeForms()) { - if (isset($attr['ALIGN'])) { + if (isset($attr['ALIGN']) && !($this->appliesStandardCascade() && isset($properties['TEXT-ALIGN']))) { $objattr['text_align'] = $this->getAlign($attr['ALIGN']); } elseif (isset($properties['TEXT-ALIGN'])) { $objattr['text_align'] = $this->getAlign($properties['TEXT-ALIGN']); diff --git a/src/Tag/Table.php b/src/Tag/Table.php index db745ed1f..c88690ffd 100644 --- a/src/Tag/Table.php +++ b/src/Tag/Table.php @@ -3,6 +3,7 @@ namespace Mpdf\Tag; use Mpdf\Css\Border; +use Mpdf\Css\PresentationalHints; use Mpdf\Mpdf; use Mpdf\Utils\Rotation; @@ -462,7 +463,11 @@ public function open($attr, &$ahtml, &$ihtml) $table['cell_padding'] = false; } - if (isset($attr['BORDER']) && $attr['BORDER'] == '1') { + $this->mpdf->table_border_attr_set = 0; + if ($this->appliesStandardCascade()) { + // The table's border came in the properties. Its cells take a border of their own, below any rule for them + $table['cell_border'] = isset($attr['BORDER']) && PresentationalHints::borderWidth('TABLE', $attr['BORDER']) > 0; + } elseif (isset($attr['BORDER']) && $attr['BORDER'] == '1') { $this->mpdf->table_border_attr_set = 1; $bord = $this->mpdf->border_details('#000000 1px solid'); if ($bord['s']) { @@ -472,8 +477,6 @@ public function open($attr, &$ahtml, &$ihtml) $table['border_details']['T'] = $bord; $table['border_details']['B'] = $bord; } - } else { - $this->mpdf->table_border_attr_set = 0; } if ($w) { diff --git a/src/Tag/Tag.php b/src/Tag/Tag.php index 5f3040eeb..730862894 100644 --- a/src/Tag/Tag.php +++ b/src/Tag/Tag.php @@ -8,6 +8,7 @@ use Mpdf\Color\ColorConverter; use Mpdf\Css\BorderMerger; use Mpdf\CssManager; +use Mpdf\CssMode; use Mpdf\Form; use Mpdf\Image\ImageProcessor; use Mpdf\Language\LanguageToFontInterface; @@ -121,6 +122,18 @@ protected function getAlign($property) return array_key_exists($property, self::ALIGN) ? self::ALIGN[$property] : ''; } + /** + * Whether the standard cascade applies the document's CSS. Its presentational attributes then reach a tag + * handler as hints among the merged properties, below any stylesheet rule, and are not read again from the + * attributes + * + * @return bool + */ + protected function appliesStandardCascade() + { + return $this->mpdf->cssMode === CssMode::STANDARD; + } + /** * Whether a page-break-before or page-break-after value starts a new page: always, left or right * diff --git a/src/Tag/Td.php b/src/Tag/Td.php index bbe4a3d96..2a137794e 100644 --- a/src/Tag/Td.php +++ b/src/Tag/Td.php @@ -222,7 +222,7 @@ public function open($attr, &$ahtml, &$ihtml) $c['a'] = $this->getAlign($properties['TEXT-ALIGN']); } } - if (!empty($attr['ALIGN'])) { + if (!empty($attr['ALIGN']) && !$this->appliesStandardCascade()) { if (strtolower($attr['ALIGN']) === 'char') { if (!empty($attr['CHAR'])) { $char = html_entity_decode($attr['CHAR']); @@ -402,7 +402,7 @@ public function open($attr, &$ahtml, &$ihtml) if (isset($attr['TEXT-ROTATE'])) { $c['R'] = $attr['TEXT-ROTATE']; } - if (!empty($attr['NOWRAP'])) { + if (!empty($attr['NOWRAP']) && !$this->appliesStandardCascade()) { $c['nowrap'] = 1; } diff --git a/tests/Mpdf/CascadeOrderTest.php b/tests/Mpdf/CascadeOrderTest.php index 6cfa52d42..110cb826f 100644 --- a/tests/Mpdf/CascadeOrderTest.php +++ b/tests/Mpdf/CascadeOrderTest.php @@ -346,7 +346,7 @@ public function uncontestedDocuments() return [ 'collapsed borders of cells, rows and the table' => ['' . '
'; + + return $this->inBothModes([ + 'border' => [''], - 'a border from a cell rule against one as wide from the border attribute, which the rule wins' => ['' + 'a border from a cell rule against one as wide from the border attribute, which the rule wins' => ['' . '
a b c d '], 'separate borders and padding' => ['' . '
a b c '], diff --git a/tests/Mpdf/Css/PresentationalHintsTest.php b/tests/Mpdf/Css/PresentationalHintsTest.php new file mode 100644 index 000000000..e8c899cf9 --- /dev/null +++ b/tests/Mpdf/Css/PresentationalHintsTest.php @@ -0,0 +1,217 @@ +hints = new PresentationalHints($mpdf, $normalizeProperties); + } + + /** + * An attribute sets its property on an element HTML gives it to, and nothing on one it does not + * + * @dataProvider elements + * + * @param string $tag + * @param array $attr + * @param array $expected + */ + public function testReadsEachAttributeOnTheElementsHtmlGivesItTo($tag, array $attr, array $expected) + { + $this->assertEquals($expected, $this->hints->of($tag, $attr)); + } + + /** + * Each attribute on an element it is a hint on, and on elements it is not + * + * @return array[] Each [tag, attributes, the properties they set] + */ + public function elements() + { + $black = function ($width) { + $border = []; + foreach (['TOP', 'RIGHT', 'BOTTOM', 'LEFT'] as $side) { + $border['BORDER-' . $side] = $width . ' solid #000000'; + $border['BORDER-' . $side . '-WIDTH'] = $width; + $border['BORDER-' . $side . '-STYLE'] = 'solid'; + $border['BORDER-' . $side . '-COLOR'] = '#000000'; + } + + return $border; + }; + + return [ + 'dir on any element' => ['SPAN', ['DIR' => 'rtl'], ['DIRECTION' => 'rtl']], + 'lang on any element' => ['P', ['LANG' => 'fr'], ['LANG' => 'fr']], + + 'color on font' => ['FONT', ['COLOR' => '#f00'], ['COLOR' => '#f00']], + 'color on hr' => ['HR', ['COLOR' => '#f00'], ['COLOR' => '#f00']], + 'color on a' => ['A', ['COLOR' => '#f00'], []], + 'color on span' => ['SPAN', ['COLOR' => '#f00'], []], + 'color on p' => ['P', ['COLOR' => '#f00'], []], + 'color on td' => ['TD', ['COLOR' => '#f00'], []], + 'color on input' => ['INPUT', ['COLOR' => '#f00'], []], + + 'face and size on font' => ['FONT', ['FACE' => 'serif', 'SIZE' => '5'], ['FONT-FAMILY' => 'serif', 'FONT-SIZE' => 'LARGE']], + 'a size a step larger on font' => ['FONT', ['SIZE' => '+1'], ['FONT-SIZE' => '120%']], + 'a size mPDF has no size for on font' => ['FONT', ['SIZE' => '9'], []], + 'face on span' => ['SPAN', ['FACE' => 'serif'], []], + + 'width on img' => ['IMG', ['WIDTH' => '20'], ['WIDTH' => '20']], + 'width on table' => ['TABLE', ['WIDTH' => '50%'], ['WIDTH' => '50%']], + 'width on td' => ['TD', ['WIDTH' => '30mm'], ['WIDTH' => '30mm']], + 'width on th' => ['TH', ['WIDTH' => '30mm'], ['WIDTH' => '30mm']], + 'width on hr' => ['HR', ['WIDTH' => '50%'], ['WIDTH' => '50%']], + 'width on meter' => ['METER', ['WIDTH' => '40mm'], ['WIDTH' => '40mm']], + 'width on progress' => ['PROGRESS', ['WIDTH' => '40mm'], ['WIDTH' => '40mm']], + 'width on div' => ['DIV', ['WIDTH' => '50mm'], []], + 'width on p' => ['P', ['WIDTH' => '50mm'], []], + 'width on textarea' => ['TEXTAREA', ['WIDTH' => '50mm'], []], + 'width on input' => ['INPUT', ['WIDTH' => '50mm'], []], + 'width of zero on table' => ['TABLE', ['WIDTH' => '0'], []], + + 'height on img' => ['IMG', ['HEIGHT' => '20'], ['HEIGHT' => '20']], + 'height on td' => ['TD', ['HEIGHT' => '30mm'], ['HEIGHT' => '30mm']], + 'height on tr' => ['TR', ['HEIGHT' => '30mm'], ['HEIGHT' => '30mm']], + 'height on thead' => ['THEAD', ['HEIGHT' => '30mm'], ['HEIGHT' => '30mm']], + 'height on div' => ['DIV', ['HEIGHT' => '30mm'], []], + 'height on hr' => ['HR', ['HEIGHT' => '3mm'], []], + + 'valign on td' => ['TD', ['VALIGN' => 'bottom'], ['VERTICAL-ALIGN' => 'bottom']], + 'valign on th' => ['TH', ['VALIGN' => 'top'], ['VERTICAL-ALIGN' => 'top']], + 'valign on tr' => ['TR', ['VALIGN' => 'top'], ['VERTICAL-ALIGN' => 'top']], + 'valign on thead' => ['THEAD', ['VALIGN' => 'top'], ['VERTICAL-ALIGN' => 'top']], + 'valign on tbody' => ['TBODY', ['VALIGN' => 'top'], ['VERTICAL-ALIGN' => 'top']], + 'valign on tfoot' => ['TFOOT', ['VALIGN' => 'top'], ['VERTICAL-ALIGN' => 'top']], + 'valign on table' => ['TABLE', ['VALIGN' => 'top'], []], + 'valign on img' => ['IMG', ['VALIGN' => 'top'], []], + 'valign on span' => ['SPAN', ['VALIGN' => 'top'], []], + + 'vspace and hspace on img' => ['IMG', ['VSPACE' => '10', 'HSPACE' => '5'], ['MARGIN-TOP' => '10', 'MARGIN-BOTTOM' => '10', 'MARGIN-LEFT' => '5', 'MARGIN-RIGHT' => '5']], + 'vspace on an image button' => ['INPUT', ['TYPE' => 'image', 'VSPACE' => '10'], ['MARGIN-TOP' => '10', 'MARGIN-BOTTOM' => '10']], + 'vspace on a text field' => ['INPUT', ['TYPE' => 'text', 'VSPACE' => '10'], []], + 'hspace on div' => ['DIV', ['HSPACE' => '10'], []], + + 'align on div' => ['DIV', ['ALIGN' => 'center'], ['TEXT-ALIGN' => 'center']], + 'align middle on div' => ['DIV', ['ALIGN' => 'Middle'], ['TEXT-ALIGN' => 'center']], + 'align on p' => ['P', ['ALIGN' => 'right'], ['TEXT-ALIGN' => 'right']], + 'align middle on p' => ['P', ['ALIGN' => 'middle'], []], + 'align on h1' => ['H1', ['ALIGN' => 'justify'], ['TEXT-ALIGN' => 'justify']], + 'align on h6' => ['H6', ['ALIGN' => 'left'], ['TEXT-ALIGN' => 'left']], + 'align on td' => ['TD', ['ALIGN' => 'right'], ['TEXT-ALIGN' => 'right']], + 'align absmiddle on th' => ['TH', ['ALIGN' => 'absmiddle'], ['TEXT-ALIGN' => 'center']], + 'align on tr' => ['TR', ['ALIGN' => 'center'], ['TEXT-ALIGN' => 'center']], + 'align on thead' => ['THEAD', ['ALIGN' => 'right'], ['TEXT-ALIGN' => 'right']], + 'align char on td' => ['TD', ['ALIGN' => 'char'], ['TEXT-ALIGN' => 'DPR']], + 'align char on a comma' => ['TD', ['ALIGN' => 'char', 'CHAR' => ','], ['TEXT-ALIGN' => 'DCR']], + 'align char on a character mPDF cannot align on' => ['TD', ['ALIGN' => 'char', 'CHAR' => 'x'], []], + 'align char on tr' => ['TR', ['ALIGN' => 'char'], []], + 'align on blockquote' => ['BLOCKQUOTE', ['ALIGN' => 'center'], []], + 'align on span' => ['SPAN', ['ALIGN' => 'center'], []], + 'align on ul' => ['UL', ['ALIGN' => 'center'], []], + 'align bottom on caption' => ['CAPTION', ['ALIGN' => 'bottom'], ['CAPTION-SIDE' => 'bottom']], + 'align left on caption' => ['CAPTION', ['ALIGN' => 'left'], []], + 'align left on hr' => ['HR', ['ALIGN' => 'left'], ['MARGIN-LEFT' => '0', 'MARGIN-RIGHT' => 'auto']], + 'align right on hr' => ['HR', ['ALIGN' => 'right'], ['MARGIN-LEFT' => 'auto', 'MARGIN-RIGHT' => '0']], + 'align center on hr' => ['HR', ['ALIGN' => 'center'], ['MARGIN-LEFT' => 'auto', 'MARGIN-RIGHT' => 'auto']], + 'align left on img' => ['IMG', ['ALIGN' => 'left'], ['FLOAT' => 'left']], + 'align right on img' => ['IMG', ['ALIGN' => 'RIGHT'], ['FLOAT' => 'right']], + 'align absmiddle on img' => ['IMG', ['ALIGN' => 'absmiddle'], ['VERTICAL-ALIGN' => 'middle']], + 'align texttop on img' => ['IMG', ['ALIGN' => 'texttop'], ['VERTICAL-ALIGN' => 'text-top']], + 'align bottom on img' => ['IMG', ['ALIGN' => 'bottom'], ['VERTICAL-ALIGN' => 'bottom']], + 'align justify on img' => ['IMG', ['ALIGN' => 'justify'], []], + 'align on table, read where the table is built' => ['TABLE', ['ALIGN' => 'center'], []], + + 'nowrap on td' => ['TD', ['NOWRAP' => 'nowrap'], ['WHITE-SPACE' => 'nowrap']], + 'nowrap on th' => ['TH', ['NOWRAP' => 'nowrap'], ['WHITE-SPACE' => 'nowrap']], + 'nowrap on div' => ['DIV', ['NOWRAP' => 'nowrap'], []], + + 'border on table' => ['TABLE', ['BORDER' => '1'], $black('1px')], + 'a wider border on table' => ['TABLE', ['BORDER' => '3'], $black('3px')], + 'a border that is not a number on table' => ['TABLE', ['BORDER' => 'yes'], $black('1px')], + 'no border on table' => ['TABLE', ['BORDER' => '0'], []], + 'border on img' => ['IMG', ['BORDER' => '2'], $black('2px')], + 'no border on img' => ['IMG', ['BORDER' => '0'], []], + 'a border that is not a number on img' => ['IMG', ['BORDER' => 'yes'], []], + 'border on td' => ['TD', ['BORDER' => '1'], []], + 'border on div' => ['DIV', ['BORDER' => '1'], []], + + 'type on ol' => ['OL', ['TYPE' => 'A'], ['LIST-STYLE-TYPE' => 'upper-latin']], + 'lowercase type on ol' => ['OL', ['TYPE' => 'a'], ['LIST-STYLE-TYPE' => 'lower-latin']], + 'roman type on li' => ['LI', ['TYPE' => 'I'], ['LIST-STYLE-TYPE' => 'upper-roman']], + 'type on ul, in capitals' => ['UL', ['TYPE' => 'SQUARE'], ['LIST-STYLE-TYPE' => 'square']], + 'type none on li' => ['LI', ['TYPE' => 'none'], ['LIST-STYLE-TYPE' => 'none']], + 'a type HTML does not define on ol' => ['OL', ['TYPE' => 'lower-greek'], []], + 'type on input' => ['INPUT', ['TYPE' => 'text'], []], + + 'size on hr' => ['HR', ['SIZE' => '5'], ['HEIGHT' => '5px']], + 'size of zero on hr' => ['HR', ['SIZE' => '0'], []], + 'size on span' => ['SPAN', ['SIZE' => '5'], []], + + 'bgcolor, read where the table is built' => ['TD', ['BGCOLOR' => '#f00'], []], + 'cellpadding, read where the table is built' => ['TABLE', ['CELLPADDING' => '5'], []], + ]; + } + + /** + * The width of the border `border` gives: a table's is 1px where the value is not a number, an image has none + * + * @dataProvider borderWidths + * + * @param string $tag + * @param string $value + * @param int $expected + */ + public function testReadsTheWidthOfABorderAsANonNegativeInteger($tag, $value, $expected) + { + $this->assertSame($expected, PresentationalHints::borderWidth($tag, $value)); + } + + /** + * Values of `border`, read as HTML reads them + * + * @return array[] Each [tag, value, width in pixels] + */ + public function borderWidths() + { + return [ + 'a number' => ['TABLE', '2', 2], + 'zero' => ['TABLE', '0', 0], + 'with leading space and a plus sign' => ['TABLE', ' +4', 4], + 'digits then other characters' => ['TABLE', '3px', 3], + 'not a number, on a table' => ['TABLE', 'border', 1], + 'empty, on a table' => ['TABLE', '', 1], + 'not a number, on an image' => ['IMG', 'border', 0], + 'a negative number, on an image' => ['IMG', '-2', 0], + ]; + } +} diff --git a/tests/Mpdf/LineRecordingMpdf.php b/tests/Mpdf/LineRecordingMpdf.php new file mode 100644 index 000000000..650820b84 --- /dev/null +++ b/tests/Mpdf/LineRecordingMpdf.php @@ -0,0 +1,34 @@ +drawnLines[] = ['page' => $this->page, 'ends' => [$x1, $y1, $x2, $y2], 'colour' => $this->DrawColor, 'width' => $this->LineWidth]; + + parent::Line($x1, $y1, $x2, $y2); + } +} diff --git a/tests/Mpdf/ListMarkerRecordingMpdf.php b/tests/Mpdf/ListMarkerRecordingMpdf.php index 72f8282c8..2ae2b202f 100644 --- a/tests/Mpdf/ListMarkerRecordingMpdf.php +++ b/tests/Mpdf/ListMarkerRecordingMpdf.php @@ -3,12 +3,15 @@ namespace Mpdf; /** - * Records the image each list marker is asked to draw, as the cascade and setCSS() hand it over, before the image - * is looked for. + * Records the type and the image each list marker is asked to draw, as the cascade and setCSS() hand them over, + * before the image is looked for. */ class ListMarkerRecordingMpdf extends Mpdf { + /** The list-style-type value of each marker set, in order. */ + public $markerTypes = []; + /** The list-style-image value of each marker set, in order. */ public $markerImages = []; @@ -19,6 +22,7 @@ class ListMarkerRecordingMpdf extends Mpdf */ function _setListMarker($listitemtype, $listitemimage, $listitemposition) { + $this->markerTypes[] = $listitemtype; $this->markerImages[] = $listitemimage; return parent::_setListMarker($listitemtype, $listitemimage, $listitemposition); diff --git a/tests/Mpdf/PageStreams.php b/tests/Mpdf/PageStreams.php index 2daf8fd58..aac463cbd 100644 --- a/tests/Mpdf/PageStreams.php +++ b/tests/Mpdf/PageStreams.php @@ -271,7 +271,20 @@ private function imagePlacements($html, $page = 0, $config = []) { $pages = $this->pages($this->render($html, $config)); $this->assertArrayHasKey($page, $pages); - preg_match_all('/([-\d.]+) 0 0 ([-\d.]+) ([-\d.]+) [-\d.]+ cm \/I\d+ Do/', $pages[$page], $matches, PREG_SET_ORDER); + + return $this->placementsIn($pages[$page]); + } + + /** + * Where each image in a page's content stream is placed, keyed w/h/x in millimetres + * + * @param string $stream + * + * @return array[] + */ + private function placementsIn($stream) + { + preg_match_all('/([-\d.]+) 0 0 ([-\d.]+) ([-\d.]+) [-\d.]+ cm \/I\d+ Do/', $stream, $matches, PREG_SET_ORDER); return array_map(function ($match) { return ['w' => $match[1] / Mpdf::SCALE, 'h' => $match[2] / Mpdf::SCALE, 'x' => $match[3] / Mpdf::SCALE]; diff --git a/tests/Mpdf/PresentationalAttributeTest.php b/tests/Mpdf/PresentationalAttributeTest.php new file mode 100644 index 000000000..c43fad4c4 --- /dev/null +++ b/tests/Mpdf/PresentationalAttributeTest.php @@ -0,0 +1,869 @@ + $expected + */ + public function testTheColourOfText($cssMode, $html, array $expected) + { + $this->assertDrawnInColours($expected, $this->drawnColours($html, ['cssMode' => $cssMode])); + } + + /** + * `color` on the elements HTML gives it to and on those it does not + * + * @return array[] + */ + public function textColours() + { + return $this->inBothModes([ + 'font color beats the colour it inherits' => [ + '
h a font
', + ['font' => self::GREEN], + ['font' => self::GREEN], + ], + 'a rule beats font color' => [ + 'font
', + ['font' => self::BLUE], + ['font' => self::BLUE], + ], + 'font color inside a link' => [ + '', + ['font' => self::GREEN], + ['font' => self::GREEN], + ], + 'a link keeps its colour over its color attribute' => [ + '', + ['link' => self::BLUE], + ['link' => self::BLUE], + ], + 'a span has no color attribute' => [ + 'span
', + ['span' => self::RED], + ['span' => self::BLACK], + ], + 'a paragraph has no color attribute' => [ + 'para
', + ['para' => self::RED], + ['para' => self::BLACK], + ], + 'a div passes on no color attribute' => [ + '', + ['inside' => self::RED], + ['inside' => self::BLACK], + ], + 'a table cell has no color attribute' => [ + 'inside
', + ['cell' => self::RED], + ['cell' => self::BLACK], + ], + 'a heading has no color attribute' => [ + '
cell heading
', + ['heading' => self::RED], + ['heading' => self::BLACK], + ], + ]); + } + + /** + * A horizontal rule is drawn where, as long, in the colour and as thick as its attributes and rules say + * + * @dataProvider horizontalRules + * + * @param string $cssMode + * @param string $html + * @param array $expected The rule's left and right ends in millimetres, its stroke colour and its width + */ + public function testAHorizontalRule($cssMode, $html, array $expected) + { + $mpdf = $this->document($html, $cssMode); + + $this->assertCount(1, $mpdf->drawnLines); + $line = $mpdf->drawnLines[0]; + $this->assertEqualsWithDelta($expected[0], $line['ends'][0], 0.2, 'Where the rule starts'); + $this->assertEqualsWithDelta($expected[1], $line['ends'][2], 0.2, 'Where the rule ends'); + $this->assertSame($expected[2], $line['colour']); + $this->assertEqualsWithDelta($expected[3], $line['width'], 0.01, 'How thick the rule is'); + } + + /** + * `color`, `width`, `align` and `size` on an hr, and a rule for each + * + * @return array[] + */ + public function horizontalRules() + { + $thin = 0.2; + $tenPixels = 10 * 25.4 / 96; + + return $this->inBothModes([ + 'color' => [ + '
', + [self::LEFT, self::RIGHT, self::GREY_STROKE, $thin], + [self::LEFT, self::RIGHT, self::RED_STROKE, $thin], + ], + 'a rule beats color' => [ + '
', + [self::LEFT, self::RIGHT, self::BLUE_STROKE, $thin], + [self::LEFT, self::RIGHT, self::BLUE_STROKE, $thin], + ], + 'width' => [ + '
', + [self::LEFT, self::RIGHT, self::GREY_STROKE, $thin], + [60, 150, self::GREY_STROKE, $thin], + ], + 'a rule beats width' => [ + '
', + [33, 177, self::GREY_STROKE, $thin], + [33, 177, self::GREY_STROKE, $thin], + ], + 'align left' => [ + '
', + [self::LEFT, self::RIGHT, self::GREY_STROKE, $thin], + [self::LEFT, 105, self::GREY_STROKE, $thin], + ], + 'align right' => [ + '
', + [self::LEFT, self::RIGHT, self::GREY_STROKE, $thin], + [105, self::RIGHT, self::GREY_STROKE, $thin], + ], + 'a rule beats align' => [ + '
', + [self::LEFT, self::RIGHT, self::GREY_STROKE, $thin], + [60, 150, self::GREY_STROKE, $thin], + ], + 'size' => [ + '
', + [self::LEFT, self::RIGHT, self::GREY_STROKE, $thin], + [self::LEFT, self::RIGHT, self::GREY_STROKE, $tenPixels], + ], + 'a rule beats size' => [ + '
', + [self::LEFT, self::RIGHT, self::GREY_STROKE, 1], + [self::LEFT, self::RIGHT, self::GREY_STROKE, 1], + ], + 'width in a table cell' => [ + '', + [60.5, 149.5, self::GREY_STROKE, $thin], + [60.5, 149.5, self::GREY_STROKE, $thin], + ], + 'a rule beats width in a table cell' => [ + '
', + [60.5, 149.5, self::GREY_STROKE, $thin], + [33.7, 176.3, self::GREY_STROKE, $thin], + ], + ]); + } + + /** + * Each piece of text is aligned as the attributes and rules say + * + * @dataProvider textAlignments + * + * @param string $cssMode + * @param string $html + * @param array
$expected Each piece of text and its alignment across the page: left, center or + * right + */ + public function testTheAlignmentOfText($cssMode, $html, array $expected) + { + $boxes = $this->keyedByText($mpdf = $this->document($html, $cssMode), $mpdf->drawnBoxes); + + foreach ($expected as $text => $alignment) { + $this->assertArrayHasKey($text, $boxes, sprintf('"%s" is not drawn', $text)); + $this->assertSame($alignment, $this->alignmentBetween(self::LEFT, self::RIGHT, $boxes[$text]), sprintf('How "%s" is aligned', $text)); + } + } + + /** + * `align` on the blocks and table parts HTML gives it to and on those it does not, and a rule for it + * + * @return array[] + */ + public function textAlignments() + { + return $this->inBothModes([ + 'a paragraph' => [ + ' para
', + ['para' => 'right'], + ['para' => 'right'], + ], + 'a rule beats a paragraph\'s align' => [ + 'para
', + ['para' => 'right'], + ['para' => 'left'], + ], + 'a heading' => [ + 'heading
', + ['heading' => 'center'], + ['heading' => 'center'], + ], + 'a div passes its align on to what is in it' => [ + '', + ['inside' => 'left'], + ['inside' => 'center'], + ], + 'a rule beats the align a paragraph inherits' => [ + 'inside
', + ['inside' => 'right'], + ['inside' => 'right'], + ], + 'middle on a div' => [ + 'inside
div', + ['div' => 'left'], + ['div' => 'center'], + ], + 'an address has no align attribute' => [ + 'address', + ['address' => 'right'], + ['address' => 'left'], + ], + 'a section has no align attribute' => [ + 'section ', + ['section' => 'right'], + ['section' => 'left'], + ], + 'a table cell' => [ + '', + ['cell' => 'right'], + ['cell' => 'right'], + ], + 'a rule beats a table cell\'s align' => [ + '
cell ', + ['cell' => 'right'], + ['cell' => 'left'], + ], + 'a header cell\'s align beats its default centring' => [ + '
cell ', + ['header' => 'left'], + ['header' => 'left'], + ], + 'a table head passes its align on to its cells' => [ + '
header ', + ['head' => 'left', 'body' => 'left'], + ['head' => 'right', 'body' => 'left'], + ], + ]); + } + + /** + * `nowrap` keeps a cell's text on one line, under any rule that wraps it + * + * @dataProvider unwrappedCells + * + * @param string $cssMode + * @param string $html + * @param int $expected How many lines the cell's text is drawn on + */ + public function testNowrapOnATableCell($cssMode, $html, $expected) + { + $this->assertCount($expected, $this->document($html, $cssMode)->drawnText); + } + + /** + * A narrow cell with and without a rule against its nowrap + * + * @return array[] + */ + public function unwrappedCells() + { + $table = '
head body '; + + return $this->inBothModes([ + 'nowrap' => [$table, 1, 1], + 'a rule beats nowrap' => ['' . $table, 1, 2], + ]); + } + + /** + * `valign` places a cell's text, and a rule beats it, under both cascades + * + * @dataProvider verticallyAlignedCells + * + * @param string $cssMode + * @param string $css + * @param bool $lower Whether the cell's text is drawn below that of the cell beside it, which is aligned to the top + */ + public function testValignOnATableCell($cssMode, $css, $lower) + { + $html = '
aaaa bbbb cccc dddd eeee '; + $y = $this->keyedByText($mpdf = $this->document($html, $cssMode), $mpdf->drawnY); + + $this->assertSame($lower, $y['bottom'] > $y['top'] + 10); + } + + /** + * A cell aligned to the bottom by its attribute, with and without a rule + * + * @return array[] + */ + public function verticallyAlignedCells() + { + return $this->inBothModes([ + 'valign' => ['', true, true], + 'a rule beats valign' => ['td { vertical-align: top; }', false, false], + ]); + } + + /** + * `type` sets the marker of a list and of a list item, which beats what the item inherits from its list under the + * standard cascade, and a rule beats it + * + * @dataProvider listTypes + * + * @param string $cssMode + * @param string $html + * @param string[] $expected The type of each marker, in order + */ + public function testTypeOnAListAndAListItem($cssMode, $html, array $expected) + { + $mpdf = new ListMarkerRecordingMpdf(['mode' => 'c', 'cssMode' => $cssMode]); + $mpdf->WriteHTML($html); + + $this->assertSame($expected, $mpdf->markerTypes); + } + + /** + * Lists and items with a type, with and without a rule + * + * @return array[] + */ + public function listTypes() + { + return $this->inBothModes([ + 'on an ordered list' => ['
bottom top ', ['lower-latin'], ['lower-latin']], + 'a rule beats it on an ordered list' => ['
- x
', ['decimal'], ['decimal']], + 'on a list' => ['
- x
', ['square'], ['square']], + 'on a list item' => ['
- x
', ['disc', 'disc'], ['square', 'disc']], + 'a rule beats it on a list item' => ['
- x
- y
', ['circle'], ['circle']], + ]); + } + + /** + * `bgcolor` on a table, a row and a cell fills it, under any rule for its background, under both cascades + * + * @dataProvider backgrounds + * + * @param string $cssMode + * @param string $html + * @param string[] $fills The colour filled, and a colour not filled + */ + public function testBgcolor($cssMode, $html, array $fills) + { + list($expected, $unexpected) = $fills; + $page = $this->pages($this->render($html, ['cssMode' => $cssMode]))[0]; + + $this->assertMatchesRegularExpression('/' . $expected . '\s+[-\d. ]+ re f/', $page); + $this->assertStringNotContainsString($unexpected, $page); + } + + /** + * bgcolor on each part of a table, with and without a rule for its background + * + * @return array[] + */ + public function backgrounds() + { + $cases = []; + foreach (['table' => '
- x
', 'tr' => '
x ', 'td' => '
x '] as $tag => $html) { + $withAttribute = sprintf($html, ' bgcolor="#008000"'); + $cases['on a ' . $tag] = [$withAttribute, [self::GREEN, self::BLUE], [self::GREEN, self::BLUE]]; + $cases['a rule beats it on a ' . $tag] = ['' . $withAttribute, [self::BLUE, self::GREEN], [self::BLUE, self::GREEN]]; + } + + return $this->inBothModes($cases); + } + + /** + * A table's `cellpadding` pads its cells, under any rule for their padding, under both cascades + * + * @dataProvider paddedCells + * + * @param string $cssMode + * @param string $css + * @param float $expected How far in from the page's left edge the cell's text starts, in millimetres + */ + public function testCellpadding($cssMode, $css, $expected) + { + $html = '
x '; + $boxes = $this->keyedByText($mpdf = $this->document($html, $cssMode), $mpdf->drawnBoxes); + + $this->assertEqualsWithDelta(self::LEFT + $expected, $boxes['padded'][1], 0.1); + } + + /** + * A padded table, with and without a rule + * + * @return array[] + */ + public function paddedCells() + { + return $this->inBothModes([ + 'cellpadding' => ['', 20 * 25.4 / 96, 20 * 25.4 / 96], + 'a rule beats cellpadding' => ['td { padding: 0; }', 0, 0], + ]); + } + + /** + * A table's `border` draws a border around it, which a rule for the table's border beats under the standard + * cascade, and one around each of its cells, which a rule for the table's border does not reach + * + * @dataProvider tableBorders + * + * @param string $cssMode + * @param string $html + * @param array $expected The widths of the lines drawn, in millimetres, keyed by the number of lines drawn that wide + */ + public function testTheBorderOfATable($cssMode, $html, array $expected) + { + $this->assertEquals($expected, $this->lineWidths($this->document($html, $cssMode))); + } + + /** + * Tables of one cell with a border attribute, with and without a rule + * + * @return array[] + */ + public function tableBorders() + { + $px = function ($pixels) { + return (string) round($pixels * 25.4 / 96, 2); + }; + $cell = '
padded cell ' . $cell, [$px(1) => 8], [$px(1) => 8]], + 'no border' => ['
' . $cell, [], []], + 'a wider border' => ['
' . $cell, [], [$px(3) => 4, $px(1) => 4]], + 'a rule beats the table\'s border' => ['
' . $cell, [$px(1) => 8], [$px(1) => 4]], + 'a rule beats the table\'s border with its own' => ['
' . $cell, [$px(1) => 8], [$px(2) => 4, $px(1) => 4]], + 'a rule beats the border of the cells' => ['
' . $cell, [$px(1) => 4, $px(2) => 4], [$px(1) => 4, $px(2) => 4]], + 'a wider border wins the outer edges of collapsed cells' => [ + '
', + [$px(1) => 8], + [$px(3) => 6, $px(1) => 2], + ], + ]); + } + + /** + * `align="bottom"` puts a caption below its table, under any rule for its side + * + * @dataProvider captions + * + * @param string $cssMode + * @param string $css + * @param bool $below + */ + public function testAlignOnACaption($cssMode, $css, $below) + { + $html = '
a b '; + $y = $this->keyedByText($mpdf = $this->document($html, $cssMode), $mpdf->drawnY); + + $this->assertSame($below, $y['caption'] > $y['cell']); + } + + /** + * A caption at the bottom by its attribute, with and without a rule + * + * @return array[] + */ + public function captions() + { + return $this->inBothModes([ + 'align bottom' => ['', true, true], + 'a rule beats align' => ['caption { caption-side: top; }', true, false], + ]); + } + + /** + * An image is drawn where, as large as and inside the border its attributes and rules say + * + * @dataProvider images + * + * @param string $cssMode + * @param string $attributes Of the image + * @param string $css + * @param array $expected Its left edge, width and height in millimetres, and the number of lines drawn around it + */ + public function testAnImage($cssMode, $attributes, $css, array $expected) + { + $html = '
caption cell '; + $mpdf = $this->document($html, $cssMode, $pdf); + $placements = $this->placementsIn($this->pages($pdf)[0]); + + $this->assertCount(1, $placements); + $this->assertEqualsWithDelta($expected[0], $placements[0]['x'], 0.1, 'Where the image starts'); + $this->assertEqualsWithDelta($expected[1], $placements[0]['w'], 0.1, 'How wide the image is'); + $this->assertEqualsWithDelta($expected[2], $placements[0]['h'], 0.1, 'How tall the image is'); + $this->assertCount($expected[3], $mpdf->drawnLines, 'How many sides of a border are drawn'); + } + + /** + * `width`, `height`, `hspace`, `align` and `border` on an image, and a rule for each + * + * @return array[] + */ + public function images() + { + $px = function ($pixels) { + return $pixels * 25.4 / 96; + }; + $size = 'width="40" height="20"'; + + return $this->inBothModes([ + 'width and height' => [ + $size, + '', + [self::LEFT, $px(40), $px(20), 0], + [self::LEFT, $px(40), $px(20), 0], + ], + 'a rule beats width and height' => [ + $size, + 'img { width: 20mm; height: 10mm; }', + [self::LEFT, 20, 10, 0], + [self::LEFT, 20, 10, 0], + ], + 'hspace' => [ + $size . ' hspace="20"', + '', + [self::LEFT, $px(40), $px(20), 0], + [self::LEFT + $px(20), $px(40), $px(20), 0], + ], + 'a rule beats hspace' => [ + $size . ' hspace="20"', + 'img { margin: 0; }', + [self::LEFT, $px(40), $px(20), 0], + [self::LEFT, $px(40), $px(20), 0], + ], + 'align right' => [ + $size . ' align="right"', + '', + [self::LEFT, $px(40), $px(20), 0], + [self::RIGHT - $px(40), $px(40), $px(20), 0], + ], + 'a rule beats align' => [ + $size . ' align="right"', + 'img { float: none; }', + [self::LEFT, $px(40), $px(20), 0], + [self::LEFT, $px(40), $px(20), 0], + ], + 'border' => [ + $size . ' border="2"', + '', + [self::LEFT, $px(40), $px(20), 0], + [self::LEFT + $px(2), $px(40), $px(20), 4], + ], + 'no border' => [ + $size . ' border="0"', + '', + [self::LEFT, $px(40), $px(20), 0], + [self::LEFT, $px(40), $px(20), 0], + ], + 'a rule beats border' => [ + $size . ' border="2"', + 'img { border: none; }', + [self::LEFT, $px(40), $px(20), 0], + [self::LEFT, $px(40), $px(20), 0], + ], + ]); + } + + /** + * `
text
` beats the default margin of an image under the standard cascade, and a rule beats it, which moves + * what follows down or not + */ + public function testVspaceOnAnImage() + { + $image = '
after
'; + $y = []; + foreach (['' => $image, 'ruled' => '' . $image] as $name => $html) { + foreach ([CssMode::LEGACY, CssMode::STANDARD] as $cssMode) { + $y[$name][$cssMode] = $this->document($html, $cssMode)->drawnY[0]; + } + } + + $this->assertEqualsWithDelta(2 * 30 * 25.4 / 96, $y[''][CssMode::STANDARD] - $y[''][CssMode::LEGACY], 0.1); + $this->assertEqualsWithDelta($y[''][CssMode::LEGACY], $y['ruled'][CssMode::STANDARD], 0.01); + $this->assertEqualsWithDelta($y[''][CssMode::LEGACY], $y['ruled'][CssMode::LEGACY], 0.01); + } + + /** + * A div has no width attribute under the standard cascade: it takes the width of the page + * + * @dataProvider divWidths + * + * @param string $cssMode + * @param float $expected The width of its background, in millimetres + */ + public function testADivHasNoWidthAttribute($cssMode, $expected) + { + $page = $this->pages($this->render('div', ['cssMode' => $cssMode]))[0]; + + $this->assertSame(1, preg_match('/' . self::GREEN . '\s+[-\d.]+ [-\d.]+ ([-\d.]+) [-\d.]+ re f/', $page, $fill)); + $this->assertEqualsWithDelta($expected, $fill[1] / Mpdf::SCALE, 0.1); + } + + /** + * Each cascade, and the width of a div with a width attribute in it + * + * @return array[] + */ + public function divWidths() + { + return $this->inBothModes(['a div' => [50, self::RIGHT - self::LEFT]]); + } + + /** + * Each cascade + * + * @return array[] + */ + public function cssModes() + { + return [CssMode::LEGACY => [CssMode::LEGACY], CssMode::STANDARD => [CssMode::STANDARD]]; + } + + /** + * mPDF reads `align` on a text field to align its text, which a rule beats under the standard cascade + * + * @dataProvider textFields + * + * @param string $cssMode + * @param string $css + * @param string $expected The field's quadding: 1 centred, 2 to the right + */ + public function testAlignOnATextField($cssMode, $css, $expected) + { + $pdf = $this->render('', ['cssMode' => $cssMode, 'useActiveForms' => true]); + + $this->assertStringContainsString('/Q ' . $expected, $pdf); + } + + /** + * A centred field, with and without a rule + * + * @return array[] + */ + public function textFields() + { + return $this->inBothModes([ + 'align' => ['', '1', '1'], + 'a rule beats align' => ['input { text-align: right; }', '1', '2'], + ]); + } + + /** + * mPDF's own tags keep the attributes mPDF reads for them: a barcode is drawn in its color + * + * @dataProvider cssModes + * + * @param string $cssMode + */ + public function testABarcodeKeepsItsColour($cssMode) + { + $page = $this->pages($this->render('', ['cssMode' => $cssMode]))[0]; + + $this->assertMatchesRegularExpression('/' . self::RED . '\s+[-\d. ]+ re f/', $page); + } + + /** + * The attributes and the rules against them apply the same wherever the element is: a block, a table cell, a + * header or footer, a positioned block, a block laid out again because it is kept together, and after a forced + * page break + * + * @dataProvider contexts + * + * @param string $cssMode + * @param string $context + * @param array $expected The colour of each piece of text, how each is aligned, the stroke colour of the rule, + * and what it spans: full, left half or middle half + */ + public function testInEveryContext($cssMode, $context, array $expected) + { + $html = '' + . $this->inContext($context, ' a wide paragraph that sets the width of what it is in
' + . 'right
centre
' + . 'font span link
' + . 'para
' + . 'aligned
ruled
' + . 'inherited
'); + + $mpdf = $this->document($html, $cssMode); + + $this->assertDrawnInColours($expected[0], $this->keyedByText($mpdf, $mpdf->drawnColours)); + $this->assertDrawnInContext($context, $mpdf, array_keys($expected[0])); + + // The wide paragraph spans a table cell, which is as wide as it, and the text aligned right reaches the edge + // of anything wider + $boxes = $this->keyedByText($mpdf, $mpdf->drawnBoxes); + $wide = $boxes['a wide paragraph that sets the width of what it is in']; + $left = $wide[1]; + $right = max($wide[2], $boxes['right'][2]); + foreach ($expected[1] as $text => $alignment) { + $this->assertSame($alignment, $this->alignmentBetween($left, $right, $boxes[$text]), sprintf('How "%s" is aligned', $text)); + } + + $rule = end($mpdf->drawnLines); + $quarter = ($right - $left) / 4; + $spans = [ + 'full' => [$left, $right], + 'left half' => [$left, $left + 2 * $quarter], + 'middle half' => [$left + $quarter, $right - $quarter], + ]; + $this->assertSame($expected[2], $rule['colour']); + $this->assertEqualsWithDelta($spans[$expected[3]], [$rule['ends'][0], $rule['ends'][2]], 1, 'Where the rule starts and ends'); + } + + /** + * Every context under each cascade + * + * @return array[] + */ + public function contexts() + { + $legacy = [ + ['font' => self::GREEN, 'span' => self::RED, 'link' => self::BLUE, 'para' => self::RED], + ['aligned' => 'right', 'ruled' => 'right', 'inherited' => 'left'], + self::GREY_STROKE, + 'full', + ]; + $standard = [ + ['font' => self::GREEN, 'span' => self::BLACK, 'link' => self::BLUE, 'para' => self::BLACK], + ['aligned' => 'right', 'ruled' => 'left', 'inherited' => 'center'], + self::GREEN_STROKE, + 'left half', + ]; + + $cases = []; + foreach (['block', 'table cell', 'header', 'footer', 'positioned block', 'kept block', 'forced page break'] as $context) { + $cases[$context] = [$context, $legacy, $standard]; + } + + // The blocks in a table cell take the cell's alignment, whatever their own, and the legacy cascade reads the + // width of a rule in a cell from its attribute + $cases['table cell'][1][1] = []; + $cases['table cell'][1][3] = 'middle half'; + $cases['table cell'][2][1] = []; + + return $this->inBothModes($cases); + } + + /** + * Each case once under each cascade, with what that cascade should give + * + * @param array[] $cases Each [..., what the legacy cascade gives, what the standard cascade gives], the values + * before the last two passed to the test ahead of the one for the cascade + * + * @return array[] + */ + private function inBothModes(array $cases) + { + $data = []; + foreach ($cases as $name => $case) { + $standard = array_pop($case); + $legacy = array_pop($case); + $data['legacy: ' . $name] = array_merge([CssMode::LEGACY], $case, [$legacy]); + $data['standard: ' . $name] = array_merge([CssMode::STANDARD], $case, [$standard]); + } + + return $data; + } + + /** + * Writes a document through LineRecordingMpdf and closes it, so its headers and footers are drawn + * + * @param string $html + * @param string $cssMode + * @param string|null $pdf Set to the document, uncompressed + * + * @return LineRecordingMpdf + */ + private function document($html, $cssMode, &$pdf = null) + { + $mpdf = new LineRecordingMpdf(['mode' => 'c', 'cssMode' => $cssMode]); + $mpdf->compress = false; + $mpdf->WriteHTML($html); + $pdf = $mpdf->OutputBinaryData(); + + return $mpdf; + } + + /** + * How a piece of text is aligned between two edges, give or take the padding of a table cell + * + * @param float $left + * @param float $right + * @param array $box As TextRecordingMpdf records it: page, left, right, top + * + * @return string left, center, right, or where it starts if none of those + */ + private function alignmentBetween($left, $right, array $box) + { + if (abs($box[1] - $left) < 1.5) { + return 'left'; + } + + if (abs($box[2] - $right) < 1.5) { + return 'right'; + } + + if (abs(($box[1] + $box[2]) / 2 - ($left + $right) / 2) < 1.5) { + return 'center'; + } + + return (string) $box[1]; + } + + /** + * How many lines of each width a document drew + * + * @param LineRecordingMpdf $mpdf + * + * @return arrayThe number of lines, keyed by their width in millimetres to two places + */ + private function lineWidths(LineRecordingMpdf $mpdf) + { + return array_count_values(array_map(function ($line) { + return (string) round($line['width'], 2); + }, $mpdf->drawnLines)); + } + +} diff --git a/tests/Mpdf/Tag/StandardBlockTagsTest.php b/tests/Mpdf/Tag/StandardBlockTagsTest.php index ed79bc8dc..d24d2cfcb 100644 --- a/tests/Mpdf/Tag/StandardBlockTagsTest.php +++ b/tests/Mpdf/Tag/StandardBlockTagsTest.php @@ -2,13 +2,21 @@ namespace Mpdf\Tag; +use Mpdf\CssMode; + class StandardBlockTagsTest extends BaseTagTestCase { /** + * Opens each block tag, which the legacy cascade aligns by its align attribute whatever the tag + * * @dataProvider blockTagsProvider + * + * @param string $tagName + * @param string $className */ public function testOpenAndClose_BlockTags($tagName, $className) { + $this->mpdf->cssMode = CssMode::LEGACY; $tag = $this->createTag($className); $attr = ['ALIGN' => 'center']; @@ -34,6 +42,27 @@ public function testOpenAndClose_BlockTags($tagName, $className) $this->assertArrayHasKey('InlineProperties', $this->mpdf->blk[$this->mpdf->blklvl]); } + /** + * The standard cascade reads align as the text alignment of a div, and of no other of these tags + * + * @dataProvider blockTagsProvider + * + * @param string $tagName + * @param string $className + */ + public function testAlignIsTheTextAlignmentOfADivOnly($tagName, $className) + { + $attr = ['ALIGN' => 'center']; + $ahtml = []; + $ihtml = 0; + + $this->createTag($className)->open($attr, $ahtml, $ihtml); + + $block = $this->mpdf->blk[$this->mpdf->blklvl]; + $this->assertSame($tagName === 'DIV' ? 'C' : null, isset($block['align']) ? $block['align'] : null); + $this->assertFalse($block['block-align']); + } + public function blockTagsProvider() { return [ diff --git a/tests/Snapshots/EmailInvoiceTemplateLegacySnapshotTest.php b/tests/Snapshots/EmailInvoiceTemplateLegacySnapshotTest.php new file mode 100644 index 000000000..781a7bcb5 --- /dev/null +++ b/tests/Snapshots/EmailInvoiceTemplateLegacySnapshotTest.php @@ -0,0 +1,21 @@ +cssMode(); + } + + /** + * The template, with a caption for each part saying what the standard and the legacy cascade draw + */ + public function generatePdf() + { + ob_start(); + ?> + + + Written with cssMode set to cssMode(); ?>.
+ +Masthead
+A table filled by bgcolor, with font color and size. Both: white "ACME Supplies" and yellow "INVOICE" on dark blue, and a light blue tagline, .tagline beating its font color.
++
+ ++ +ACME Supplies +
Everything for the workshopINVOICE +
No. 10042Greeting
+p align="center" against p.greeting. Standard: the greeting is at the left, the rule beating the attribute. Legacy: it is centred, the attribute beating the rule. The paragraph with only its attribute is centred in both.
+Dear customer, thank you for your order.
+Your order has been sent.
+ +Items
+th bgcolor against table.items th, and td align="center" against td.amount. Both: the headings are white on blue, the rule beating bgcolor. Standard: the amounts are at the right, the rule beating the attribute. Legacy: they are centred. The quantities, with only their attribute, are centred in both, and every cell has a thin black border from border="1".
++
+ ++ Item Qty Amount + Widgets 4 40.00 + Sprockets 12 96.00 + Gaskets (back-ordered) 3 7.50 Summary
+table border="1" against table.summary. Standard: the table's own border is thick and blue, the rule beating the attribute, and its cells keep a thin black border. Legacy: every border is thin and black. The total is on one line in both, by nowrap.
++
+ ++ Subtotal 143.50 + Delivery 10.00 + Total due 153.50 AUD Footer
+hr color against hr.divider, and color on a link and a span. Both: the rule is blue, hr.divider beating the attribute. Standard: the rule is half as wide as the page, at its left, and the link is blue and the note black, neither element taking a color attribute. Legacy: the rule is as wide as the page, and the link is blue and the note red.
+
+Pay online (due within 30 days)
+ mpdf = $this->createMpdf(['cssMode' => $this->cssMode()]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/Snapshots/EmailInvoiceTemplateStandardSnapshotTest.php b/tests/Snapshots/EmailInvoiceTemplateStandardSnapshotTest.php new file mode 100644 index 000000000..8ee535488 --- /dev/null +++ b/tests/Snapshots/EmailInvoiceTemplateStandardSnapshotTest.php @@ -0,0 +1,21 @@ + + + +Presentational attributes
+ +<hr color> beats the grey of the built-in defaults: the rule is green.
++ +<hr width="50%"> beats the full width of the built-in defaults: the rule is half as wide as the box, and centred.
++ +<hr align="left"> puts a half-width rule at the left of the box.
++ +<hr size="6"> draws the rule 6px thick.
++ +<font color> colours its text: "font" is green.
++ +font and text around it
A span, a paragraph and a link have no color attribute: "span" and "paragraph" are black, and the link is blue.
++ +span and a link
paragraph
<p align="right"> puts its text at the right.
++ +Aligned right
A stylesheet rule beats <p align="right">: the text is at the left.
++ +A rule against the attribute
<div align="center"> centres the paragraphs in it.
++ +A paragraph in a centred div
<td align> and <td valign> place the text of a cell: "right" at the bottom right; a rule beats the align of "ruled", which is at the left.
++ +
right ruled <td nowrap> keeps a narrow cell's text on one line.
++ +
one line of text <td bgcolor> fills the cell: the cell is green.
++ +
<table border="1"> draws a thin black border around the table and each cell, and a rule for the table's border beats it: the outer border is thick and green, the cells' thin and black.
++
plain plain + +
ruled ruled <table border="3"> draws a 3px border around the table.
++ +
three pixels <caption align="bottom"> puts the caption below its table.
++ +
The caption The table + + <li type="square"> beats the disc the item inherits from its list: the first marker is a square, the second a disc.
++ +
- A square
- A disc
<img vspace hspace> beats the image's default margin of 0: the image stands 20px clear of the text on each side.
+before+ +after
<img align="right"> floats the image to the right, and the text flows at its left.
++ +Text beside the image.
<img border="3"> draws a 3px black border around the image.
++ mpdf = $this->createMpdf(['cssMode' => \Mpdf\CssMode::STANDARD]); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/snapshots/barcode.pdf b/tests/data/snapshots/barcode.pdf index a0b4e5d6e..09f49708b 100644 Binary files a/tests/data/snapshots/barcode.pdf and b/tests/data/snapshots/barcode.pdf differ diff --git a/tests/data/snapshots/email-invoice-template-legacy.pdf b/tests/data/snapshots/email-invoice-template-legacy.pdf new file mode 100644 index 000000000..08ae10b84 Binary files /dev/null and b/tests/data/snapshots/email-invoice-template-legacy.pdf differ diff --git a/tests/data/snapshots/email-invoice-template-standard.pdf b/tests/data/snapshots/email-invoice-template-standard.pdf new file mode 100644 index 000000000..00f6dbb99 Binary files /dev/null and b/tests/data/snapshots/email-invoice-template-standard.pdf differ diff --git a/tests/data/snapshots/presentational-attributes.pdf b/tests/data/snapshots/presentational-attributes.pdf new file mode 100644 index 000000000..53fe87384 Binary files /dev/null and b/tests/data/snapshots/presentational-attributes.pdf differ