Skip to content

Draw every layer of a multiple background (#609) - #723

Open
gravitypdf-bot wants to merge 2 commits into
gravitypdffrom
feature/609-multiple-backgrounds
Open

gravitypdf-bot wants to merge 2 commits into
gravitypdffrom
feature/609-multiple-backgrounds

Conversation

@gravitypdf-bot

Copy link
Copy Markdown

Fixes #609
Part of #552, part of #520

Problem

A background or background-image with several comma-separated layers drew only its first layer, and the layer longhands (background-repeat: no-repeat, repeat-x and the rest) were read as one value or dropped:

$mpdf = new \Mpdf\Mpdf(['mode' => 'c']);
$mpdf->WriteHTML('<div style="width: 60mm; height: 30mm; background: url(bg.jpg) no-repeat, linear-gradient(red, blue)"></div>');
$mpdf->Output('', 'S');

Measured on gravitypdf at 384d5b5d, on a 60 × 30 mm block, from the patterns and shadings the page paints:

background Browser Before After
url(bg.jpg) no-repeat, linear-gradient(red, blue) the image over the gradient the image only the image over the gradient
linear-gradient(red, blue), url(bg.jpg) the gradient over the image the gradient only the gradient over the image
background-image: url(bg.jpg), url(bg.jpg); background-repeat: no-repeat two layers one image two images
url(bg.jpg) no-repeat, #0f0 the image over green the image over green the image over green
linear-gradient(…), radial-gradient(…) linear over radial linear only linear over radial
one image, background-position: right bottom, left top bottom right (the list is cut to the one image) the list was dropped: top left bottom right

The shorthand parser fixed before #532 (#583) already read every layer, but kept only the first. The longhands still matched the first keyword they found, and SetBackground() and everything that paints its result held one image per box.

Fix

  • Mpdf\Css\BackgroundLayers (new) keeps a longhand's list of per-layer values in one string, so it cascades, inherits and resolves CSS-wide keywords like any other value. The values are joined with \x1F, which no CSS value holds; a comma would not do, since a layer's image can hold commas (a data URI, a gradient's stops). A list of one is the value itself, so a single-layer document carries exactly the strings it did before. valueFor() gives a layer its value, repeating a shorter list as CSS does.
  • NormalizeProperties, in standard mode: background now sets every longhand to the list of its layers' values, each with its initial value where the layer leaves it out, and the colour from the last layer only. background-image, -repeat, -position, -size, -origin and -clip split on commas outside parentheses (so rgb(…), gradient arguments and url()s stay whole) and read each item the way the shorthand reads that part; a list with an item that is not a value of the property is dropped, as a browser drops it. A value with no top-level comma takes the same path as before. parseCssBackground() became parseBackgroundLayers(), which returns every layer instead of the first. The initial values come from CssWideKeywords::initialValue(), and the size check the shorthand and background-size share is isBackgroundSize().
  • Mpdf::SetBackground() reads each layer through backgroundLayer() (the old body, unchanged), giving each its own repeat, position, size and origin from the lists. One drawable layer returns the same array as before; several return ['layers' => [...]], top first.
  • Painting. The eight places that paint a stored background image (body/@page, blocks in PaintDivBB(), tables twice, rows and cells in _tableWrite(), and repeated header/footer rows and cells in TableHeaderFooter()) loop over backgroundLayers(), which gives the layers bottom first, or the single background on its own. The loop bodies are the old code with $x['background-image'] read as $bg. Table backgrounds go into slots that paint every gradient before every image, so a list's gradients go into its images' slot to keep the authored order; a single background keeps its old slots. In PaintDivBB() each layer starts from the whole box, since the gradient branch narrows $w and $h to its origin box.
  • background-clip. mPDF clips a box's colour and images to one path. With a list, setCSS() takes the value of the bottom layer, the one the colour uses, as the issue says (draw the layers "under the same clip").

Nothing here touches the shadow or clip-path building in PaintDivBB() or BlockTag; the change in PaintDivBB() is in the background-image part after them. I expect textual conflicts with #714, #715 and #719 only if they touch those last lines of PaintDivBB().

Legacy mode

Under 'cssMode' => 'legacy' the longhands are read as before and the shorthand gives the first layer only, so SetBackground() never sees a list and every painting loop runs once over the same array as before. I checked byte-identical output against gravitypdf for seven documents (layered and single backgrounds on blocks, tables, rows, cells, body and @page) in legacy mode, and for the single-layer ones in standard mode too.

Tests

tests/Mpdf/MultipleBackgroundsTest.php reads, in paint order, the shadings and image patterns the first page paints (shading type for a gradient; steps, box and placement for an image):

  • an image over a gradient, a gradient over an image, two images, a gradient over a gradient (radial at the bottom), and a none layer that is skipped;
  • the last layer's colour painted under the image;
  • three layers with two background-repeat values and per-layer background-position and background-size: the third layer takes the first repeat again;
  • a list longer than the images is cut short;
  • layers on a table cell, a table, a table row and @page;
  • the bottom layer's background-clip clipping the box;
  • legacy mode drawing only the top layer.

tests/Mpdf/Css/NormalizePropertiesTest.php covers each longhand's list (including a data URI with commas in it), the lists that are dropped, the shorthand's per-layer values, and legacy mode reading only the top layer.

With the src/ changes reverted, 13 of the 15 MultipleBackgroundsTest tests fail; the image over a colour and the legacy case pass, as they should.

tests/Snapshots/MultipleBackgroundsSnapshotTest.php (fixture multiple-backgrounds) draws each case under a caption saying what should be seen, on blocks and a table cell, and a named @page with an image over a gradient on page 2. Against the pre-fix src/ it fails on both pages (RMSE 0.22 and 0.90).

The background-shorthand fixture's "Two layers" caption said "mPDF draws only the first layer"; I reworded it, which shortens it to one line and moves the cases below it up. Its drawing is unchanged.

Checks

  • composer test: OK, 12762 tests, 31 skipped (the same skips as on gravitypdf); the new multiple-backgrounds fixture is added and background-shorthand is refreshed for its caption
  • composer cs: clean
  • PHPStan 2 (--memory-limit=2G): the same 23 errors as on gravitypdf (the out-of-date baseline Regenerate the PHPStan baseline for PHPStan 2.3, so the Lint job passes again #716 regenerates), none new
  • php -l on PHP 5.6 (docker) for each changed PHP file: no syntax errors

🤖 Generated with Claude Code

gravitypdf-bot and others added 2 commits October 12, 2026 07:51
A background or background-image with several comma-separated layers
drew only its first layer, and the layer longhands were read as one
value or dropped. In standard mode the shorthand and the longhands now
keep a value for each layer, joined by BackgroundLayers so the list
cascades like any other value, and SetBackground() reads every layer.
Blocks, tables, rows, cells, body and @page paint the layers from the
last to the first, over the colour of the last layer and under the clip
of its background-clip. A single layer is read and painted as before,
and legacy mode keeps drawing only the first layer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…isting helpers (#609)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Draw every layer of a multiple background

1 participant