Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG-GRAVITYPDF.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ Read this section before upgrading from upstream mPDF. Each entry says what chan
* A bidirectional embedding, as in `<span dir="rtl">…<div>…</div>…</span>`, is opened again for the inline element's text after the block.
* **Stylesheet rules that name `sup`, `sub` or `center` apply**, in both CSS modes. mPDF left the three tags out of `allowedCSStags`, so `sup { color: red }`, `p sub { … }` and `center { … }` did nothing while a class rule on the same element worked. A rule's `font-size` and `vertical-align` take the place of the 55% size and the raise or drop mPDF gives the tags by default, rather than adding to them, so normalize.css's `sub, sup { font-size: 75%; line-height: 0; position: relative; vertical-align: baseline }` now draws them at 75% on the baseline. To keep ignoring such rules, pass an `allowedCSStags` without `SUP`, `SUB` and `CENTER`. [#649] [#680]
* **`min-width` and `max-width` hold the width of a block**, as in a browser, in standard mode. mPDF never read them on a block, so `max-width: 100mm; margin: 0 auto`, the usual centred container, filled the page, `width: 150mm; max-width: 100mm` was 150mm wide, and `width: 50mm; min-width: 80mm` was 50mm. Now the width is held within the two, `max-width` first, then `min-width`, a percentage taken of the container's inner width as `width` is, and the block's auto margins, direction or float place the narrowed box. Floats and positioned blocks take the same limits. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to ignore the two properties as before. [#588] [#691]
* **A background with several comma-separated layers draws every layer**, as in a browser, in standard mode. mPDF drew only the first, so `background: url(logo.png) no-repeat, linear-gradient(red, blue)` lost the gradient and `background-image: url(a.png), url(b.png)` lost `b.png`. Now each layer is drawn, the first on top and the last at the bottom, over the colour the last layer names, on blocks, tables, rows, cells, `body` and `@page`. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to draw only the first layer as before. [#552] [#609]
* `background-image`, `-repeat`, `-position`, `-size`, `-origin` and `-clip` take a comma-separated list, one value per layer. A list shorter than the list of images repeats, and a longer one is cut short, so `background-position: right bottom, left top` with one image places it at the bottom right; before, the list was dropped.
* Every layer is clipped to the box the last layer's `background-clip` names, the one the colour takes.
* `box-sizing` is not read, so the limits measure the content, and a block with padding and borders is wider by them. `min-width` and `max-width` on a table or cell are still not read.

New features
Expand Down Expand Up @@ -965,3 +968,4 @@ These changes do not change output.
[#691]: https://github.com/GravityPDF/mpdf/pull/691
[#584]: https://github.com/GravityPDF/mpdf/issues/584
[#689]: https://github.com/GravityPDF/mpdf/pull/689
[#609]: https://github.com/GravityPDF/mpdf/issues/609
67 changes: 67 additions & 0 deletions src/Css/BackgroundLayers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace Mpdf\Css;

/**
* A background longhand that lists one value per layer, as the standard CSS mode reads `background-image: url(a.png),
* linear-gradient(red, blue)`. The list is kept in one string so it cascades like any other value, joined with a
* character no CSS value holds: a layer's image can itself hold commas, in a data URI or a gradient's stops. A list of
* one is the value itself.
*/
final class BackgroundLayers
{

/**
* Separates the layers' values
*/
const SEPARATOR = "\x1F";

/**
* Joins the layers' values into one property value
*
* @param string[] $values One value per layer, the top layer first
* @return string
*/
public static function join(array $values)
{
return implode(self::SEPARATOR, $values);
}

/**
* Splits a property value into its layers' values
*
* @param string $value
* @return string[] The top layer's value first
*/
public static function split($value)
{
return explode(self::SEPARATOR, (string) $value);
}

/**
* The value a layer takes. A list shorter than the list of images repeats, as CSS has it
*
* @param string $value
* @param int $layer The layer's index, the top layer 0
* @return string
*/
public static function valueFor($value, $layer)
{
$values = self::split($value);

return $values[$layer % count($values)];
}

/**
* The value the bottom layer takes, the one the background colour is drawn with
*
* @param string $value
* @param string $images The background-image list, which gives the number of layers
* @return string
*/
public static function bottomValue($value, $images)
{
return self::valueFor($value, count(self::split($images)) - 1);
}

}
172 changes: 134 additions & 38 deletions src/Css/NormalizeProperties.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,19 @@ class NormalizeProperties
*/
const REPEAT_KEYWORDS = ['repeat', 'no-repeat', 'space', 'round'];

/**
* The background longhands that list a value for each layer, with the key of their part in a parsed layer of the
* shorthand
*/
const LAYERED_LONGHANDS = [
'BACKGROUND-IMAGE' => 'i',
'BACKGROUND-REPEAT' => 'r',
'BACKGROUND-POSITION' => 'p',
'BACKGROUND-SIZE' => 's',
'BACKGROUND-ORIGIN' => 'o',
'BACKGROUND-CLIP' => 'k',
];

/**
* @var array
*/
Expand Down Expand Up @@ -234,7 +247,7 @@ public function normalize($prop)
$this->processPageSizeProperty($k, $v);
} elseif ($k === 'BREAK-BEFORE' || $k === 'BREAK-AFTER' || $k === 'BREAK-INSIDE') {
$this->processBreakProperty($k, $v);
} elseif (in_array($k, ['BACKGROUND', 'BACKGROUND-IMAGE', 'BACKGROUND-REPEAT', 'BACKGROUND-POSITION'], true)) {
} elseif ($k === 'BACKGROUND' || array_key_exists($k, self::LAYERED_LONGHANDS)) {
$this->processBackgroundProperty($k, $v);
} elseif ($k === 'IMAGE-ORIENTATION') {
$this->processImageOrientationProperty($v);
Expand Down Expand Up @@ -569,23 +582,16 @@ protected function processBorderShorthandProperty($key, $value)
* Parse CSS background shorthand property.
*
* Reads the colour, image, position with an optional "/ size", repeat, attachment and boxes of each
* comma-separated layer, in any order. Only the first layer is drawn, over the colour, which only the
* last layer may carry.
* comma-separated layer, in any order. Only the last layer may carry the colour.
*
* @param string $s Background property value
* @return array|false The parts given, keyed 'c' (color), 'i' (image), 'r' (repeat), 'p' (position),
* 's' (size), 'o' (origin) and 'k' (clip), or false when the value is not a background
* @return array[]|false The parts of each layer, the top layer first, keyed 'c' (color), 'i' (image), 'r' (repeat),
* 'p' (position), 's' (size), 'o' (origin) and 'k' (clip); an empty list for a CSS-wide
* keyword; or false when the value is not a background
*/
protected function parseCssBackground($s)
private function parseBackgroundLayers($s)
{
$layers = [[]];
foreach ($this->splitComponents($s) as $component) {
if ($component === ',') {
$layers[] = [];
} else {
$layers[count($layers) - 1][] = $component;
}
}
$layers = $this->splitLayers($s);

// A CSS-wide keyword gives the initial value, no background. The standard CSS mode passes it to the longhands
// before this, for CssMerger to resolve
Expand All @@ -601,19 +607,35 @@ protected function parseCssBackground($s)
}
}

if (isset($layers[$last]['c'])) {
$layers[0]['c'] = $layers[$last]['c'];
return $layers;
}

/**
* Splits a value at the commas outside its function calls and quoted strings
*
* @param string $value
* @return string[][] The components of each comma-separated part
*/
private function splitLayers($value)
{
$layers = [[]];
foreach ($this->splitComponents($value) as $component) {
if ($component === ',') {
$layers[] = [];
} else {
$layers[count($layers) - 1][] = $component;
}
}

return $layers[0];
return $layers;
}

/**
* Parse one layer of the background shorthand, whose parts may come in any order and each at most once.
* A single box keyword sets both the origin and the clip.
*
* @param string[] $components
* @return array|false The parts given, keyed as parseCssBackground() returns them, or false when a component is none of them
* @return array|false The parts given, keyed as parseBackgroundLayers() returns them, or false when a component is none of them
*/
private function parseBackgroundLayer(array $components)
{
Expand Down Expand Up @@ -645,14 +667,11 @@ private function parseBackgroundLayer(array $components)
if ($i + 1 < $count && $components[$i + 1] === '/') {
$i += 2;
$size = isset($components[$i]) ? [strtolower($components[$i])] : [];
if ($size !== ['cover'] && $size !== ['contain']) {
$sizeLength = '/^(auto|' . self::NUMBER . '([a-z]+|%)?)$/';
if (!$size || !preg_match($sizeLength, $size[0])) {
return false;
}
if ($i + 1 < $count && preg_match($sizeLength, strtolower($components[$i + 1]))) {
$size[] = strtolower($components[++$i]);
}
if (!$this->isBackgroundSize($size)) {
return false;
}
if ($size !== ['cover'] && $size !== ['contain'] && $i + 1 < $count && $this->isBackgroundSize([$size[0], strtolower($components[$i + 1])])) {
$size[] = strtolower($components[++$i]);
}
$layer['s'] = implode(' ', $size);
}
Expand Down Expand Up @@ -707,6 +726,19 @@ private function isBackgroundPosition($component)
return preg_match('/^(left|right|top|bottom|center|[+-]?' . self::NUMBER . '([a-z]+|%)?)$/', $component) === 1;
}

/**
* Whether components are a background size: cover, contain, or one or two lengths, percentages or auto
*
* @param string[] $components Lowercased components
* @return bool
*/
private function isBackgroundSize(array $components)
{
$length = '(auto|' . self::NUMBER . '([a-z]+|%)?)';

return preg_match('/^(cover|contain|' . $length . '( ' . $length . ')?)$/', implode(' ', $components)) === 1;
}

/**
* Whether a component of a shorthand is a colour
*
Expand Down Expand Up @@ -1039,7 +1071,7 @@ private function dropsNegativeLineHeight()
* Sets only the parts a background shorthand names, besides its colour and image, as mPDF v7 read it: the
* repeat, position and size only with an image, which they have nothing to apply to without
*
* @param array $bg The shorthand as parseCssBackground() reads it
* @param array $bg The top layer of the shorthand, as parseBackgroundLayers() reads it
* @return void
*/
private function mergeBackgroundParts(array $bg)
Expand All @@ -1065,37 +1097,51 @@ private function mergeBackgroundParts(array $bg)
/**
* Process background related CSS properties.
*
* Handles BACKGROUND, BACKGROUND-IMAGE, BACKGROUND-REPEAT, and BACKGROUND-POSITION.
* Handles BACKGROUND and its longhands. The standard CSS mode reads a comma-separated list of layers into each
* of them, kept as BackgroundLayers joins them.
*
* @param string $property Property name
* @param string $value Property value
* @return void
*/
protected function processBackgroundProperty($property, $value)
{
if ($property !== 'BACKGROUND' && $this->mpdf->cssMode === CssMode::STANDARD && strpos($value, ',') !== false) {
$layers = $this->splitLayers($value);
if (count($layers) > 1) {
$this->processLayeredBackgroundProperty($property, $layers);
return;
}
}

switch ($property) {
case 'BACKGROUND':
$bg = $this->parseCssBackground($value);
$layers = $this->parseBackgroundLayers($value);

// A value that is not a background is dropped, as a browser drops it
if ($bg === false) {
if ($layers === false) {
break;
}

$this->properties['BACKGROUND-COLOR'] = isset($bg['c']) ? $bg['c'] : 'transparent';
$this->properties['BACKGROUND-IMAGE'] = isset($bg['i']) ? $bg['i'] : '';
$layers = $layers ?: [[]];
$bottom = $layers[count($layers) - 1];
$this->properties['BACKGROUND-COLOR'] = isset($bottom['c']) ? $bottom['c'] : 'transparent';

if (!$this->resetsOmittedParts()) {
$this->mergeBackgroundParts($bg);
// mPDF v7 drew only the top layer
$this->properties['BACKGROUND-IMAGE'] = isset($layers[0]['i']) ? $layers[0]['i'] : '';
$this->mergeBackgroundParts($layers[0]);
break;
}

// A part not given takes its initial value, replacing a longhand given before it in the cascade
$this->properties['BACKGROUND-REPEAT'] = isset($bg['r']) ? $bg['r'] : 'repeat';
$this->properties['BACKGROUND-POSITION'] = !empty($bg['p']) ? $bg['p'] : '0% 0%';
$this->properties['BACKGROUND-SIZE'] = isset($bg['s']) ? $bg['s'] : 'auto';
$this->properties['BACKGROUND-ORIGIN'] = isset($bg['o']) ? $bg['o'] : 'padding-box';
$this->properties['BACKGROUND-CLIP'] = isset($bg['k']) ? $bg['k'] : 'border-box';
foreach (self::LAYERED_LONGHANDS as $longhand => $part) {
$values = [];
foreach ($layers as $layer) {
$values[] = isset($layer[$part]) && $layer[$part] !== false ? $layer[$part] : CssWideKeywords::initialValue($longhand);
}
$this->properties[$longhand] = BackgroundLayers::join($values);
}
break;

case 'BACKGROUND-IMAGE':
Expand Down Expand Up @@ -1124,9 +1170,59 @@ protected function processBackgroundProperty($property, $value)
$this->properties['BACKGROUND-POSITION'] = $normalizedPosition;
}
break;

default:
$this->properties[$property] = $value;
}
}

/**
* Sets a background longhand that lists a value for each layer. The list is dropped when one of its values is not
* a value of the property, as a browser drops it
*
* @param string $property Property name
* @param string[][] $layers The components of each layer's value
* @return void
*/
private function processLayeredBackgroundProperty($property, array $layers)
{
$values = [];
foreach ($layers as $components) {
$value = $this->layerValue($property, $components);
if ($value === false) {
return;
}
$values[] = $value;
}

$this->properties[$property] = BackgroundLayers::join($values);
}

/**
* Reads one layer's value of a background longhand, the way the shorthand reads that part
*
* @param string $property Property name
* @param string[] $components
* @return string|false The value, or false when it is not a value of the property
*/
private function layerValue($property, array $components)
{
if ($property === 'BACKGROUND-SIZE') {
return $this->isBackgroundSize($components) ? implode(' ', $components) : false;
}

$part = self::LAYERED_LONGHANDS[$property];
$layer = $this->parseBackgroundLayer($components);
if (!isset($layer[$part]) || $layer[$part] === false) {
return false;
}

// One box keyword sets both the origin and the clip in the shorthand; a longhand takes one keyword
$expected = $part === 'o' || $part === 'k' ? ['o' => $layer[$part], 'k' => $layer[$part]] : [$part => $layer[$part]];

return $layer == $expected ? $layer[$part] : false;
}

/**
* Process border radius property.
*
Expand Down
Loading