Skip to content

Round the corners of a table cell with border-radius (#603) - #734

Open
gravitypdf-bot wants to merge 2 commits into
gravitypdffrom
feature/603-table-border-radius
Open

gravitypdf-bot wants to merge 2 commits into
gravitypdffrom
feature/603-table-border-radius

Conversation

@gravitypdf-bot

Copy link
Copy Markdown

Part of #603
Part of #552, part of #520

Problem

mPDF read border-radius on a table cell and never used it, so rounded table cards and cells came out square:

$mpdf = new \Mpdf\Mpdf(['mode' => 'c']);
$mpdf->WriteHTML('<table style="border-collapse: separate"><tr>
  <td style="border: 1mm solid #00f; border-radius: 3mm; background: #0f0">A</td>
</tr></table>');
$mpdf->Output('', 'S');
Rule Browser Before After
td { border: 1mm solid #00f; border-radius: 3mm; background: #0f0 }, border-collapse: separate rounded square rounded
td { border-radius: 50% } on a 60 × 10 mm cell ellipse square ellipse, 30 × 5 mm radii
td { border: 3mm groove #36c; border-radius: 5mm } rounded, two-tone square, two-tone rounded, two-tone
tr { background: #fcc }, td { border-radius: 5mm } row colour clipped to the cell's corners square clipped
td { border-radius: 3mm }, border-collapse: collapse square (CSS ignores it) square square, byte-identical

Fix

  • Tag\Td reads the radius longhands with BorderRadius::parse(), in standard mode only, into $cell['border_radius']. Repeated header and footer rows copy it.
  • Mpdf::roundedCellBox() gives the cell's border box (its slot less half the border-spacing on each side, which is the box a square cell's background already fills). It scales the radii by the table's shrink factor, resolves percentages against the box with BorderRadius::resolve(), and fits them with RoundedBox::fit(). It returns null under border-collapse: collapse, for simple tables, inside columns, and when every corner comes out square. In those cases nothing changes.
  • Backgrounds. The cell's backgrounds are not painted where they are built. They are collected in tableBackgrounds and painted later. So roundedCellBox() counts each layer of the table's level, and once the cell is done clipRoundedCellBackgrounds() gives every entry added since a clip path round the rounded box. That covers the cell's colour, gradient and image, and the row's colour, gradient and image showing through it. A row gradient or image already had a square per-cell clip in the separated model, and the rounded one takes its place. PrintTableBackgrounds() now honours clippath on a plain colour too, as it already did for gradients and images. Doing it this way leaves the background-building code in _tableWrite() and TableHeaderFooter() untouched, and Draw every layer of a multiple background (#609) #723 rewrites that code.
  • Border. paintRoundedCellBorder() strokes each side along the corners with RoundedBox::side(), as PaintImgBorder() does for a rounded image. dashed and dotted keep their dashes. double is drawn as two strokes, each a third of the border's width. groove, ridge, inset and outset take the two tones _tableRect() gives a square cell (ColorConverter::lighten() and darken()): one tone on the outer half of the top and left, the other on the inner half, and the other way round on the bottom and right. The tones change at the diagonal of each corner.
  • RoundedBox::scale() divides radii by a shrink factor. imageBox() and roundedCellBox() both use it.

Decisions.

  • Collapse. CSS says border-radius does not apply in the collapsing border model, on cells or on the table, so a collapsed cell is drawn exactly as before.
  • Row backgrounds. Browsers paint a row's background in each cell's area, clipped to that cell's rounded corners, so this PR does the same.
  • Columns. Inside <columns>, mPDF moves what was drawn by matching the first coordinate of each write. A curved path doesn't survive that, so cells stay square there. Block borders and image borders are also skipped in columns.
  • Simple tables. simpleTables draws every cell with the table's border and ignores the cell's own border, so the cell's radius is ignored too.

Legacy mode

Under 'cssMode' => 'legacy', Td doesn't read the radius, so output is byte-identical to before. I checked a document of rounded cells in every border style: same bytes before and after. TableCellBorderRadiusTest::testTheLegacyModeKeepsTheCellSquare covers this.

Not in this PR

Tests

tests/Mpdf/TableCellBorderRadiusTest.php reads the clip path and border strokes from the page stream:

  • A length rounds every corner of the background by that much.
  • The clip runs round the box a square background fills.
  • A percentage is half of each side of the box.
  • A corner longhand rounds that corner alone.
  • A row background, and a gradient, are painted inside the rounded clip.
  • The border ends each of its four sides in a curve. double and groove draw two strokes per side, and groove uses two colours.
  • A side with no border isn't drawn.
  • Each of these is byte-identical to the same cell without a radius: border-collapse: collapse, legacy mode, and border-radius: 0.
  • Simple tables stay square.
  • A repeated header row is rounded on every page.
  • A nested table keeps its own clips.

With the src/ changes reverted, 10 of the 15 fail. The five that pass check that nothing changes.

tests/Snapshots/TableCellBorderRadiusSnapshotTest.php (fixture table-cell-border-radius) draws twelve cells, each under a caption saying what it should show: solid, 50% ellipse, 6mm 0, dashed, dotted, double, groove, ridge, inset, outset, a gradient, and a row background through a cell with none. It ends with a collapsed cell that stays square. Against the pre-fix src/ it fails (RMSE 0.07). No existing fixture changed.

Checks

Expected conflicts: #723 (multiple backgrounds) touches PrintTableBackgrounds() and the cell background blocks. This PR adds one line before and one after each of those blocks, so any conflict should be small. #727 (outline) edits Tag/Td.php and Mpdf.php around cell painting. #721 and #710 touch Tag/Td.php and _tableWrite() elsewhere.

🤖 Generated with Claude Code

gravitypdf-bot and others added 2 commits October 12, 2026 08:15
A cell's border-radius was read and never used, so rounded table cards
and cells came out square. In the separated borders model a cell's
backgrounds, its row's showing through it included, are now clipped to
its rounded border box, and its border is stroked side by side along the
corners as an image's is, with double and the 3D styles in the tones a
square cell takes. Under border-collapse: collapse CSS ignores the
radius, so the cell is drawn as before; simple tables and columns stay
square too. Standard CSS mode only.

The backgrounds are clipped after they are collected rather than where
each is built, so the painting code shared by _tableWrite() and
TableHeaderFooter() is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed cell's border lean (#603)

The cell box carries the background counts, the clip is built only when
a background needs it, each stroke box is built once, drawing state is
reset once per side, and the alpha is reset only after a translucent
border. RoundedBox::scale() divides radii by a table's shrink factor for
images and cells alike.

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.

1 participant