From 0eef18954d676d20d882fc47de97277b33f34db7 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 12 Oct 2026 08:15:10 +1100 Subject: [PATCH 1/2] Round the corners of a table cell with border-radius (#603) 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 --- CHANGELOG-GRAVITYPDF.md | 2 + src/Mpdf.php | 180 ++++++++++- src/Tag/Td.php | 9 + tests/Mpdf/TableCellBorderRadiusTest.php | 294 ++++++++++++++++++ .../TableCellBorderRadiusSnapshotTest.php | 76 +++++ .../snapshots/table-cell-border-radius.pdf | Bin 0 -> 60086 bytes 6 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 tests/Mpdf/TableCellBorderRadiusTest.php create mode 100644 tests/Snapshots/TableCellBorderRadiusSnapshotTest.php create mode 100644 tests/data/snapshots/table-cell-border-radius.pdf diff --git a/CHANGELOG-GRAVITYPDF.md b/CHANGELOG-GRAVITYPDF.md index a674b3d97..142789ea5 100644 --- a/CHANGELOG-GRAVITYPDF.md +++ b/CHANGELOG-GRAVITYPDF.md @@ -158,6 +158,7 @@ New features * A reset such as `* { margin: 0; padding: 0 }` now takes away the default margins of paragraphs, headings and lists, a list's indent and a cell's padding, as in a browser. It reaches the lines of the table of contents and the index as well, which are the document's elements, so their levels are no longer indented. Style `div.mpdf_toc_level_1` and the like to indent them again. * mPDF's own ``, `` and `` are not HTML elements. A rule reaches them only by naming their tag, or an id or class they carry, so `*`, and `:empty`, `[attr]` or `:not()` alone, leave them as they are: `:empty { display: none }` does not hide a barcode. * **Font weights as numbers, `bolder` and `lighter`, and `font-size: larger` and `smaller`.** In the standard CSS mode, `font-weight` takes a number from 1 to 1000, and `bolder` and `lighter` step from the parent's weight by the table in CSS Fonts. The weight is inherited as a number, so `bolder` inside `bolder` gives 900 and `lighter` inside 700 gives 400, and `h1 { font-weight: lighter }` is regular, one step down from its parent's 400. A weight above 500 is drawn with the family's bold face and any other with its regular face. `larger` and `smaller` multiply and divide the parent's size by 1.2. A form field reads them against the document's size, as it reads its other font sizes. The `font` shorthand's weight is read the same way. All of these were ignored before, so a document that has them changes. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to read only `normal` and `bold`, as before. [#545] [#647] +* **`border-radius` on table cells, in standard mode.** In the separated borders model a cell's background, its row's background showing through it, a gradient or a background image are clipped to the cell's rounded border box, and its border follows the corners. A percentage is of the cell's own border box. `double` is drawn as two lines, and `groove`, `ridge`, `inset` and `outset` in the two tones a square cell takes. Under `border-collapse: collapse` the radius is ignored, as CSS says, and so it is in simple tables (`simpleTables`) and inside columns. A table's own `border-radius` is not drawn yet. Set `'cssMode' => \Mpdf\CssMode::LEGACY` to draw every cell square as before. [#552] [#603] Performance ----------- @@ -965,3 +966,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 +[#603]: https://github.com/GravityPDF/mpdf/issues/603 diff --git a/src/Mpdf.php b/src/Mpdf.php index b700f3353..db9f46203 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -3256,6 +3256,9 @@ function PrintTableBackgrounds($adjustmenty = 0) foreach ($this->tableBackgrounds as $bl => $pbs) { foreach ($pbs as $pb) { if ((!isset($pb['gradient']) || !$pb['gradient']) && (!isset($pb['image_id']) || !$pb['image_id'])) { + if (!empty($pb['clippath'])) { + $s .= $pb['clippath'] . "\n"; + } $s .= 'q ' . $this->SetFColor($pb['col'], true) . "\n"; if ($pb['col'][0] == 5) { // RGBa $s .= $this->SetAlpha(ord($pb['col'][4]) / 100, 'Normal', true, 'F') . "\n"; @@ -3263,6 +3266,9 @@ function PrintTableBackgrounds($adjustmenty = 0) $s .= $this->SetAlpha(ord($pb['col'][5]) / 100, 'Normal', true, 'F') . "\n"; } $s .= sprintf('%.3F %.3F %.3F %.3F re %s Q', $pb['x'] * Mpdf::SCALE, ($this->h - $pb['y']) * Mpdf::SCALE, $pb['w'] * Mpdf::SCALE, -$pb['h'] * Mpdf::SCALE, 'f') . "\n"; + if (!empty($pb['clippath'])) { + $s .= 'Q' . "\n"; + } } if (isset($pb['gradient']) && $pb['gradient']) { if (isset($pb['clippath']) && $pb['clippath']) { @@ -13239,6 +13245,10 @@ function TableHeaderFooter($content = '', $tablestartpage = '', $tablestartcolum $this->y += $h - $mih; } + $cellBox = $paintcell ? $this->roundedCellBox(Arrays::get($tablehf, 'border_radius', null), $table, $x, $y, $w, $h, $border_details) : null; + if ($cellBox) { + $cellBackgrounds = $this->countTableBackgrounds($level); + } // TABLE ROW OR CELL FILL BGCOLOR $fill = 0; @@ -13345,7 +13355,9 @@ function TableHeaderFooter($content = '', $tablestartpage = '', $tablestartcolum /* -- END BACKGROUNDS -- */ // Cell Border - if ($table['borders_separate'] && $paintcell && $border) { + if ($cellBox && $border) { + $this->paintRoundedCellBorder($cellBox, $border, $border_details); + } elseif ($table['borders_separate'] && $paintcell && $border) { $this->_tableRect($x + ($table['border_spacing_H'] / 2) + ($border_details['L']['w'] / 2), $y + ($table['border_spacing_V'] / 2) + ($border_details['T']['w'] / 2), $w - $table['border_spacing_H'] - ($border_details['L']['w'] / 2) - ($border_details['R']['w'] / 2), $h - $table['border_spacing_V'] - ($border_details['T']['w'] / 2) - ($border_details['B']['w'] / 2), $border, $border_details, false, $table['borders_separate']); } elseif ($paintcell && $border) { $this->_tableRect($x, $y, $w, $h, $border, $border_details, true, $table['borders_separate']); // true causes buffer @@ -13530,6 +13542,10 @@ function TableHeaderFooter($content = '', $tablestartpage = '', $tablestartcolum } /* -- END BACKGROUNDS -- */ + if ($cellBox) { + $this->clipRoundedCellBackgrounds($cellBox, $cellBackgrounds); + } + // TABLE BORDER - if separate OR collapsed and only table border if (($table['borders_separate'] || ($this->simpleTables && !$table['simple']['border'])) && $table['border']) { $halfspaceL = $table['padding']['L'] + ($table['border_spacing_H'] / 2); @@ -21855,6 +21871,153 @@ function _tableGetMaxRowHeight($table, $row) } // CHANGED TO ALLOW TABLE BORDER TO BE SPECIFIED CORRECTLY - added border_details + /** + * The rounded border box of a table cell, or null for a square one. Only the separated borders model rounds a + * cell, as in CSS. Simple tables draw every cell with the table's border, and columns move what is drawn by the + * first coordinate of each write, which a curved path does not survive, so both stay square. + * + * @param array|null $radius The cell's border_radius, as Tag\Td keeps it + * @param array $table + * @param float $x The cell's slot, border-spacing included + * @param float $y + * @param float $w + * @param float $h + * @param array $details The cell's border details + * + * @return array|null Keyed x0, y0, x1, y1 and radii, as RoundedBox::side() takes it + */ + function roundedCellBox($radius, $table, $x, $y, $w, $h, $details) + { + if (!$radius || !$table['borders_separate'] || $this->simpleTables || $this->ColActive) { + return null; + } + + $x0 = $x + $table['border_spacing_H'] / 2; + $y0 = $y + $table['border_spacing_V'] / 2; + $x1 = $x + $w - $table['border_spacing_H'] / 2; + $y1 = $y + $h - $table['border_spacing_V'] / 2; + + // A table shrunk to fit its page shrinks its radii with it + $radii = RoundedBox::SQUARE; + foreach ($radius['radii'] as $corner => $r) { + $radii[$corner] = [$r[0] / $this->shrin_k, $r[1] / $this->shrin_k]; + } + $radii = BorderRadius::resolve($radii, $radius['percent'], $x1 - $x0, $y1 - $y0); + $radii = $this->roundedBox->fit($x1 - $x0, $y1 - $y0, $radii, ['top' => $details['T']['w'], 'right' => $details['R']['w'], 'bottom' => $details['B']['w'], 'left' => $details['L']['w']]); + + if (max(array_map('max', $radii)) <= 0) { + return null; + } + + return ['x0' => $x0, 'y0' => $y0, 'x1' => $x1, 'y1' => $y1, 'radii' => $radii]; + } + + /** + * How many backgrounds each of a table level's layers holds, which marks where those of the next cell begin + * + * @param int $level The table's nesting level + * + * @return int[] Keyed by tableBackgrounds layer + */ + function countTableBackgrounds($level) + { + $counts = []; + for ($layer = $level * 9; $layer < ($level + 1) * 9; $layer++) { + $counts[$layer] = isset($this->tableBackgrounds[$layer]) ? count($this->tableBackgrounds[$layer]) : 0; + } + + return $counts; + } + + /** + * Clip the backgrounds collected for a rounded cell since countTableBackgrounds() to its rounded border box, + * the row's among them. The clip takes the place of the square one a row background is given in each cell. + * + * @param array $box As roundedCellBox() gives it + * @param int[] $counts As countTableBackgrounds() gave them before the cell + */ + function clipRoundedCellBackgrounds($box, $counts) + { + $clip = ' q 0 w ' . $this->roundedBox->path($this->h, $box['x0'], $box['y0'], $box['x1'], $box['y1'], $box['radii']) . ' W n '; + + foreach ($counts as $layer => $count) { + $total = isset($this->tableBackgrounds[$layer]) ? count($this->tableBackgrounds[$layer]) : 0; + for ($n = $count; $n < $total; $n++) { + $this->tableBackgrounds[$layer][$n]['clippath'] = $clip; + } + } + } + + /** + * Draw a rounded cell's border along its corners, each side on its own as PaintImgBorder() does. A double border + * is two strokes a third as wide, and groove, ridge, inset and outset take the two tones _tableRect() gives a + * square cell: the outer half of the top and left in one, the inner half in the other, and the other way round + * on the bottom and right. + * + * @param array $box As roundedCellBox() gives it + * @param int $bord The Border flags of the sides the cell draws + * @param array $details The cell's border details + */ + function paintRoundedCellBorder($box, $bord, $details) + { + $sides = ['top' => ['T', Border::TOP], 'right' => ['R', Border::RIGHT], 'bottom' => ['B', Border::BOTTOM], 'left' => ['L', Border::LEFT]]; + $widths = []; + foreach ($sides as $side => $flag) { + $widths[$side] = $details[$flag[0]]['w']; + } + + foreach ($sides as $side => $flag) { + $bd = $details[$flag[0]]; + if (!$this->issetBorder($bord, $flag[1]) || !Border::drawsSide($bd)) { + continue; + } + if (!$bd['c']) { + $bd['c'] = $this->colorConverter->convert(0, $this->PDFAXwarnings); + } + + // Each stroke as [where it starts, its width] in parts of the border's width from the outer edge, and its colour + $strokes = [[[0, 1], $bd['c']]]; + if ($bd['style'] === 'double') { + $strokes = [[[0, 1 / 3], $bd['c']], [[2 / 3, 1 / 3], $bd['c']]]; + } elseif (in_array($bd['style'], ['groove', 'ridge', 'inset', 'outset'], true)) { + $light = $this->colorConverter->lighten($bd['c']); + $dark = $this->colorConverter->darken($bd['c']); + $tones = ($bd['style'] === 'inset' || $bd['style'] === 'ridge') ? [$light, $dark] : [$dark, $light]; + if ($side === 'bottom' || $side === 'right') { + $tones = array_reverse($tones); + } + $strokes = [[[0, 1 / 2], $tones[0]], [[1 / 2, 1 / 2], $tones[1]]]; + } + + foreach ($strokes as $stroke) { + list($part, $colour) = $stroke; + $insets = []; + foreach ($widths as $s => $width) { + $insets[$s] = $width * $part[0]; + } + $inner = [ + 'x0' => $box['x0'] + $insets['left'], + 'y0' => $box['y0'] + $insets['top'], + 'x1' => $box['x1'] - $insets['right'], + 'y1' => $box['y1'] - $insets['bottom'], + 'radii' => $this->roundedBox->inset($box['radii'], $insets), + ]; + + $this->_setBorderLine(['w' => $bd['w'] * $part[1], 'c' => $colour]); + if ($bd['style'] === 'dotted' || $bd['style'] === 'dashed') { + $this->_setDashBorder($bd['style'], '', '', $flag[0]); + } else { + $this->SetLineCap(0); // An arc stops at the diagonal, so nothing may run past it into the next side + } + $this->writer->write($this->roundedBox->side($this->h, $side, $inner, $bd['w'] * $part[1]) . 'S'); + $this->SetLineJoin(2); + $this->SetLineCap(2); + $this->SetDash(); + } + } + $this->SetAlpha(1); + } + function _tableRect($x, $y, $w, $h, $bord = -1, $details = [], $buffer = false, $bSeparate = false, $cort = 'cell', $tablecorner = '', $bsv = 0, $bsh = 0) { $cellBorderOverlay = []; @@ -23177,6 +23340,7 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl $tablefooter[$i][$js]['border_details'] = $table['simple']['border_details']; } $tablefooter[$i][$js]['bgcolor'] = $cell['bgcolor']; + $tablefooter[$i][$js]['border_radius'] = Arrays::get($cell, 'border_radius', null); $tablefooter[$i][$js]['padding'] = $cell['padding']; if (isset($cell['rowspan'])) { $tablefooter[$i][$js]['rowspan'] = $cell['rowspan']; @@ -23751,6 +23915,11 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl $bord_det = $table['simple']['border_details']; } + $cellBox = $paintcell ? $this->roundedCellBox(Arrays::get($cell, 'border_radius', null), $table, $x, $y, $w, $h, $bord_det) : null; + if ($cellBox) { + $cellBackgrounds = $this->countTableBackgrounds($level); + } + // TABLE ROW OR CELL FILL BGCOLOR $fill = 0; if (isset($cell['bgcolor']) && $cell['bgcolor'] && $cell['bgcolor'] != 'transparent') { @@ -23919,6 +24088,7 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl $tableheader[$i][$j]['rowspan'] = (isset($cell['rowspan']) ? $cell['rowspan'] : null); $tableheader[$i][$j]['colspan'] = (isset($cell['colspan']) ? $cell['colspan'] : null); $tableheader[$i][$j]['bgcolor'] = $cell['bgcolor']; + $tableheader[$i][$j]['border_radius'] = Arrays::get($cell, 'border_radius', null); if (!$this->simpleTables) { $tableheader[$i][$j]['border'] = $bord; @@ -23944,7 +24114,9 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl // CELL BORDER if ($bord) { - if ($table['borders_separate'] && $paintcell) { + if ($cellBox) { + $this->paintRoundedCellBorder($cellBox, $bord, $bord_det); + } elseif ($table['borders_separate'] && $paintcell) { $this->_tableRect($x + ($table['border_spacing_H'] / 2) + ($bord_det['L']['w'] / 2), $y + ($table['border_spacing_V'] / 2) + ($bord_det['T']['w'] / 2), $w - $table['border_spacing_H'] - ($bord_det['L']['w'] / 2) - ($bord_det['R']['w'] / 2), $h - $table['border_spacing_V'] - ($bord_det['T']['w'] / 2) - ($bord_det['B']['w'] / 2), $bord, $bord_det, false, $table['borders_separate']); } elseif (!$table['borders_separate']) { $this->_tableRect($x, $y, $w, $h, $bord, $bord_det, true, $table['borders_separate']); // true causes buffer @@ -24205,6 +24377,10 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl /* -- END BACKGROUNDS -- */ + if ($cellBox) { + $this->clipRoundedCellBackgrounds($cellBox, $cellBackgrounds); + } + // TABLE BORDER - if separate if (($table['borders_separate'] || ($this->simpleTables && !$table['simple']['border'])) && $table['border']) { $halfspaceL = $table['padding']['L'] + ($table['border_spacing_H'] / 2); diff --git a/src/Tag/Td.php b/src/Tag/Td.php index c325325c9..2df6f053e 100644 --- a/src/Tag/Td.php +++ b/src/Tag/Td.php @@ -3,6 +3,7 @@ namespace Mpdf\Tag; use Mpdf\Css\Border; +use Mpdf\Css\BorderRadius; use Mpdf\Css\TextVars; use Mpdf\CssMode; use Mpdf\Utils\Arrays; @@ -210,6 +211,14 @@ public function open($attr, &$ahtml, &$ihtml) } } /* -- END BACKGROUNDS -- */ + + if ($this->mpdf->cssMode === CssMode::STANDARD) { + list($radii, $percent) = BorderRadius::parse($properties, $this->sizeConverter, $this->mpdf->FontSize); + if ($radii) { + $c['border_radius'] = ['radii' => $radii, 'percent' => $percent]; + } + } + if (isset($properties['VERTICAL-ALIGN'])) { $c['va'] = $this->getAlign($properties['VERTICAL-ALIGN']); } elseif (isset($attr['VALIGN'])) { diff --git a/tests/Mpdf/TableCellBorderRadiusTest.php b/tests/Mpdf/TableCellBorderRadiusTest.php new file mode 100644 index 000000000..2099dfa35 --- /dev/null +++ b/tests/Mpdf/TableCellBorderRadiusTest.php @@ -0,0 +1,294 @@ +A'; + + return $this->pages($this->render($html, $config))[0]; + } + + /** + * The clip paths on a page that run through at least one curve + * + * @param string $page + * + * @return string[] + */ + private function roundedClips($page) + { + preg_match_all('/q 0 w ((?:[-\d.]+ [-\d.]+ [ml] |(?:[-\d.]+ ){6}c )+) W n/', $page, $m); + + return array_values(array_filter($m[1], function ($path) { + return strpos($path, ' c ') !== false; + })); + } + + /** + * The radius of each rounded corner of a path, keyed TL/TR/BR/BL as [horizontal, vertical] in millimetres. A + * corner is a run of curves; its radii are how far the run travels along each axis. Square corners are left out. + * + * @param string $path + * + * @return array + */ + private function radii($path) + { + preg_match_all('/((?:[-\d.]+ )+)([mlc]) /', $path, $ops, PREG_SET_ORDER); + + $radii = []; + $corner = 0; + $point = null; + $arcStart = null; + foreach ($ops as $op) { + $numbers = array_map('floatval', explode(' ', trim($op[1]))); + if ($op[2] === 'c') { + if ($arcStart === null) { + $arcStart = $point; + } + } elseif ($arcStart !== null) { + $radii[self::CORNERS[$corner]] = [abs($point[0] - $arcStart[0]) / Mpdf::SCALE, abs($point[1] - $arcStart[1]) / Mpdf::SCALE]; + $arcStart = null; + } + if ($op[2] === 'l') { + $corner++; + } + $point = array_slice($numbers, -2); + } + + return $radii; + } + + /** + * The width and height of the box a path runs round, in millimetres + * + * @param string $path + * + * @return float[] + */ + private function sizeOf($path) + { + preg_match_all('/([-\d.]+) ([-\d.]+) [ml] /', $path, $m); + $xs = array_map('floatval', $m[1]); + $ys = array_map('floatval', $m[2]); + + return [(max($xs) - min($xs)) / Mpdf::SCALE, (max($ys) - min($ys)) / Mpdf::SCALE]; + } + + /** + * The one rounded clip on a page, asserting there is exactly one + * + * @param string $page + * + * @return string + */ + private function onlyClip($page) + { + $clips = $this->roundedClips($page); + $this->assertCount(1, $clips, 'one rounded clip'); + + return $clips[0]; + } + + /** + * A length rounds every corner of the cell's background by that much + */ + public function testALengthRoundsTheBackgroundOfACell() + { + $radii = $this->radii($this->onlyClip($this->cellPage('background: #0f0; border-radius: 3mm'))); + + $this->assertSame(self::CORNERS, array_keys($radii)); + foreach ($radii as $corner => $radius) { + $this->assertEqualsWithDelta([3, 3], $radius, 0.01, $corner); + } + } + + /** + * The clip runs round the box the cell's background fills when it is square, inside the border-spacing + */ + public function testTheClipIsTheBoxASquareBackgroundFills() + { + $this->assertSame(1, preg_match('/[-\d.]+ [-\d.]+ ([-\d.]+) -([-\d.]+) re f/', $this->cellPage('background: #0f0; border: 1mm solid #00f'), $square)); + + $size = $this->sizeOf($this->onlyClip($this->cellPage('background: #0f0; border: 1mm solid #00f; border-radius: 3mm'))); + + $this->assertEqualsWithDelta([$square[1] / Mpdf::SCALE, $square[2] / Mpdf::SCALE], $size, 0.01); + } + + /** + * A percentage is of the cell's own border box: horizontal of its width, vertical of its height + */ + public function testAPercentageIsOfTheCellsOwnBox() + { + $clip = $this->onlyClip($this->cellPage('background: #0f0; border-radius: 50%')); + $size = $this->sizeOf($clip); + + foreach ($this->radii($clip) as $corner => $radius) { + $this->assertEqualsWithDelta([$size[0] / 2, $size[1] / 2], $radius, 0.01, $corner); + } + } + + /** + * A corner longhand rounds that corner alone + */ + public function testACornerLonghandRoundsThatCornerAlone() + { + $radii = $this->radii($this->onlyClip($this->cellPage('background: #0f0; border-bottom-right-radius: 4mm 2mm'))); + + $this->assertSame(['BR'], array_keys($radii)); + $this->assertEqualsWithDelta([4, 2], $radii['BR'], 0.01); + } + + /** + * The row's background shows through a cell with none of its own, and is clipped to the cell's corners too + */ + public function testTheRowBackgroundIsClippedToARoundedCell() + { + $html = '
A
'; + + $this->onlyClip($this->pages($this->render($html))[0]); + } + + /** + * A gradient background is clipped to the rounded box as a colour is + */ + public function testAGradientIsClippedToARoundedCell() + { + $page = $this->cellPage('background-image: linear-gradient(#f00, #00f); border-radius: 3mm'); + + $this->onlyClip($page); + $this->assertMatchesRegularExpression('/c [-\d. ]+ l\s+W n\s+q [-\d. ]+ re W n\s+[-\d. ]+ cm\s+\/Sh\d+ sh/', $page, 'the gradient is painted inside the rounded clip'); + } + + /** + * The border is stroked side by side along the curve of each corner + */ + public function testTheBorderFollowsTheCorners() + { + $page = $this->cellPage('border: 1mm solid #00f; border-radius: 3mm'); + + $this->assertSame(4, preg_match_all('/c S/', $page), 'four sides end in a curve'); + } + + /** + * A double border is two strokes per side, and groove two of different colours + */ + public function testDoubleAndGrooveBordersAreTwoStrokesPerSide() + { + $this->assertSame(8, preg_match_all('/c S/', $this->cellPage('border: 3mm double #00f; border-radius: 5mm'))); + + $groove = $this->cellPage('border: 3mm groove #36c; border-radius: 5mm'); + $this->assertSame(8, preg_match_all('/c S/', $groove)); + preg_match_all('/([\d.]+ [\d.]+ [\d.]+) RG/', $groove, $m); + $this->assertCount(2, array_unique($m[1]), 'groove takes two tones'); + } + + /** + * A side the cell has no border on is not drawn + */ + public function testOnlyTheSidesWithABorderAreDrawn() + { + $this->assertSame(1, preg_match_all('/ S/', $this->cellPage('border-top: 1mm solid #00f; border-radius: 3mm'))); + } + + /** + * CSS ignores border-radius on a cell under border-collapse: collapse; the cell is drawn as if it had none + */ + public function testACollapsedCellStaysSquare() + { + $collapse = 'border-collapse: collapse'; + + $this->assertSame( + $this->cellPage('background: #0f0; border: 1mm solid #00f', $collapse), + $this->cellPage('background: #0f0; border: 1mm solid #00f; border-radius: 3mm', $collapse) + ); + } + + /** + * The legacy CSS mode draws the cell square, as before + */ + public function testTheLegacyModeKeepsTheCellSquare() + { + $legacy = ['cssMode' => CssMode::LEGACY]; + $table = 'border-collapse: separate; border-spacing: 2mm'; + + $this->assertSame( + $this->cellPage('background: #0f0; border: 1mm solid #00f', $table, $legacy), + $this->cellPage('background: #0f0; border: 1mm solid #00f; border-radius: 3mm', $table, $legacy) + ); + } + + /** + * A radius of nothing draws the cell exactly as one without + */ + public function testAZeroRadiusDrawsTheCellAsBefore() + { + $this->assertSame( + $this->cellPage('background: #0f0; border: 1mm solid #00f'), + $this->cellPage('background: #0f0; border: 1mm solid #00f; border-radius: 0') + ); + } + + /** + * Simple tables draw every cell with the table's own border, and leave the radius out with the cell's border + */ + public function testSimpleTablesKeepTheCellSquare() + { + $this->assertSame([], $this->roundedClips($this->cellPage('background: #0f0; border-radius: 3mm', 'border-collapse: separate', ['simpleTables' => true]))); + } + + /** + * A header row repeated on the next page keeps its rounded corners + */ + public function testARepeatedHeaderRowStaysRounded() + { + $rows = str_repeat('Row', 80); + $html = '' . $rows . '
Head
'; + + $pages = $this->pages($this->render($html)); + + $this->assertGreaterThan(1, count($pages)); + foreach ($pages as $n => $page) { + $this->assertCount(1, $this->roundedClips($page), 'page ' . ($n + 1)); + } + } + + /** + * A cell in a nested table is clipped by its own table, not the outer one's rounded cell + */ + public function testANestedTableKeepsItsOwnClips() + { + $inner = '
In
'; + $html = '
' . $inner . '
'; + + $clips = $this->roundedClips($this->pages($this->render($html))[0]); + + $this->assertCount(2, $clips); + $this->assertNotSame($clips[0], $clips[1]); + } +} diff --git a/tests/Snapshots/TableCellBorderRadiusSnapshotTest.php b/tests/Snapshots/TableCellBorderRadiusSnapshotTest.php new file mode 100644 index 000000000..d7a062836 --- /dev/null +++ b/tests/Snapshots/TableCellBorderRadiusSnapshotTest.php @@ -0,0 +1,76 @@ + + body { font-size: 8pt; } + h1 { font-size: 13pt; margin: 0 0 1mm 0; } + p { margin: 1.5mm 0 0.5mm 0; } + table { border-collapse: separate; border-spacing: 1mm; } + td { width: 60mm; height: 10mm; } + tr { background: #fcc; } + +

border-radius on table cells

'; + + foreach ($this->cases() as $case) { + $html .= '

' . $case[2] . '

' . $case[1] . '
'; + } + + $html .= '

border-collapse: collapse ignores the radius: a square green cell with a blue border.

' + . '
collapse
'; + + $this->mpdf = $this->createMpdf(); + + $this->mpdf->WriteHTML($html); + } + +} diff --git a/tests/data/snapshots/table-cell-border-radius.pdf b/tests/data/snapshots/table-cell-border-radius.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1a3c41625070e736b935169569b0043bf2dfc080 GIT binary patch literal 60086 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 a0nJK#2Yow-Uvm;LKru7HQ&5P?i~T=`quHPU literal 0 HcmV?d00001 From 6ced1d25f8256a5eaeef5707ae2f0b3855102d95 Mon Sep 17 00:00:00 2001 From: gravitypdf-bot Date: Mon, 12 Oct 2026 08:19:19 +1100 Subject: [PATCH 2/2] Share the scaling of radii between images and cells, and keep a rounded 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 --- src/Mpdf.php | 115 ++++++++---------- src/RoundedBox.php | 25 +++- .../snapshots/table-cell-border-radius.pdf | Bin 60086 -> 59838 bytes 3 files changed, 71 insertions(+), 69 deletions(-) diff --git a/src/Mpdf.php b/src/Mpdf.php index db9f46203..d1d8f0485 100644 --- a/src/Mpdf.php +++ b/src/Mpdf.php @@ -13245,10 +13245,7 @@ function TableHeaderFooter($content = '', $tablestartpage = '', $tablestartcolum $this->y += $h - $mih; } - $cellBox = $paintcell ? $this->roundedCellBox(Arrays::get($tablehf, 'border_radius', null), $table, $x, $y, $w, $h, $border_details) : null; - if ($cellBox) { - $cellBackgrounds = $this->countTableBackgrounds($level); - } + $cellBox = $paintcell ? $this->roundedCellBox(Arrays::get($tablehf, 'border_radius', null), $table, $level, $x, $y, $w, $h, $border_details) : null; // TABLE ROW OR CELL FILL BGCOLOR $fill = 0; @@ -13543,7 +13540,7 @@ function TableHeaderFooter($content = '', $tablestartpage = '', $tablestartcolum /* -- END BACKGROUNDS -- */ if ($cellBox) { - $this->clipRoundedCellBackgrounds($cellBox, $cellBackgrounds); + $this->clipRoundedCellBackgrounds($cellBox); } // TABLE BORDER - if separate OR collapsed and only table border @@ -21876,17 +21873,21 @@ function _tableGetMaxRowHeight($table, $row) * cell, as in CSS. Simple tables draw every cell with the table's border, and columns move what is drawn by the * first coordinate of each write, which a curved path does not survive, so both stay square. * + * The box also counts the backgrounds each of the table level's layers holds, so the cell's own, collected after, + * can be found by clipRoundedCellBackgrounds(). + * * @param array|null $radius The cell's border_radius, as Tag\Td keeps it * @param array $table + * @param int $level The table's nesting level * @param float $x The cell's slot, border-spacing included * @param float $y * @param float $w * @param float $h * @param array $details The cell's border details * - * @return array|null Keyed x0, y0, x1, y1 and radii, as RoundedBox::side() takes it + * @return array|null Keyed x0, y0, x1, y1 and radii, as RoundedBox::side() takes it, and counts */ - function roundedCellBox($radius, $table, $x, $y, $w, $h, $details) + function roundedCellBox($radius, $table, $level, $x, $y, $w, $h, $details) { if (!$radius || !$table['borders_separate'] || $this->simpleTables || $this->ColActive) { return null; @@ -21898,51 +21899,37 @@ function roundedCellBox($radius, $table, $x, $y, $w, $h, $details) $y1 = $y + $h - $table['border_spacing_V'] / 2; // A table shrunk to fit its page shrinks its radii with it - $radii = RoundedBox::SQUARE; - foreach ($radius['radii'] as $corner => $r) { - $radii[$corner] = [$r[0] / $this->shrin_k, $r[1] / $this->shrin_k]; - } - $radii = BorderRadius::resolve($radii, $radius['percent'], $x1 - $x0, $y1 - $y0); + $radii = BorderRadius::resolve($this->roundedBox->scale($radius['radii'], $this->shrin_k), $radius['percent'], $x1 - $x0, $y1 - $y0); $radii = $this->roundedBox->fit($x1 - $x0, $y1 - $y0, $radii, ['top' => $details['T']['w'], 'right' => $details['R']['w'], 'bottom' => $details['B']['w'], 'left' => $details['L']['w']]); if (max(array_map('max', $radii)) <= 0) { return null; } - return ['x0' => $x0, 'y0' => $y0, 'x1' => $x1, 'y1' => $y1, 'radii' => $radii]; - } - - /** - * How many backgrounds each of a table level's layers holds, which marks where those of the next cell begin - * - * @param int $level The table's nesting level - * - * @return int[] Keyed by tableBackgrounds layer - */ - function countTableBackgrounds($level) - { $counts = []; for ($layer = $level * 9; $layer < ($level + 1) * 9; $layer++) { - $counts[$layer] = isset($this->tableBackgrounds[$layer]) ? count($this->tableBackgrounds[$layer]) : 0; + $counts[$layer] = count(Arrays::get($this->tableBackgrounds, $layer, [])); } - return $counts; + return ['x0' => $x0, 'y0' => $y0, 'x1' => $x1, 'y1' => $y1, 'radii' => $radii, 'counts' => $counts]; } /** - * Clip the backgrounds collected for a rounded cell since countTableBackgrounds() to its rounded border box, - * the row's among them. The clip takes the place of the square one a row background is given in each cell. + * Clip the backgrounds collected for a rounded cell since roundedCellBox() counted them to its rounded border + * box, the row's among them. The clip takes the place of the square one a row background is given in each cell. * * @param array $box As roundedCellBox() gives it - * @param int[] $counts As countTableBackgrounds() gave them before the cell */ - function clipRoundedCellBackgrounds($box, $counts) + function clipRoundedCellBackgrounds($box) { - $clip = ' q 0 w ' . $this->roundedBox->path($this->h, $box['x0'], $box['y0'], $box['x1'], $box['y1'], $box['radii']) . ' W n '; + $clip = null; - foreach ($counts as $layer => $count) { - $total = isset($this->tableBackgrounds[$layer]) ? count($this->tableBackgrounds[$layer]) : 0; + foreach ($box['counts'] as $layer => $count) { + $total = count(Arrays::get($this->tableBackgrounds, $layer, [])); for ($n = $count; $n < $total; $n++) { + if ($clip === null) { + $clip = ' q 0 w ' . $this->roundedBox->path($this->h, $box['x0'], $box['y0'], $box['x1'], $box['y1'], $box['radii']) . ' W n '; + } $this->tableBackgrounds[$layer][$n]['clippath'] = $clip; } } @@ -21961,10 +21948,9 @@ function clipRoundedCellBackgrounds($box, $counts) function paintRoundedCellBorder($box, $bord, $details) { $sides = ['top' => ['T', Border::TOP], 'right' => ['R', Border::RIGHT], 'bottom' => ['B', Border::BOTTOM], 'left' => ['L', Border::LEFT]]; - $widths = []; - foreach ($sides as $side => $flag) { - $widths[$side] = $details[$flag[0]]['w']; - } + // The box a stroke runs round, by how far in from the outer edge it starts, in parts of each side's width + $boxes = [0 => $box]; + $translucent = false; foreach ($sides as $side => $flag) { $bd = $details[$flag[0]]; @@ -21974,11 +21960,12 @@ function paintRoundedCellBorder($box, $bord, $details) if (!$bd['c']) { $bd['c'] = $this->colorConverter->convert(0, $this->PDFAXwarnings); } + $translucent = $translucent || $bd['c'][0] == 5 || $bd['c'][0] == 6; - // Each stroke as [where it starts, its width] in parts of the border's width from the outer edge, and its colour - $strokes = [[[0, 1], $bd['c']]]; + // Each stroke as [where it starts, its width, its colour], the first two in parts of the border's width + $strokes = [[0, 1, $bd['c']]]; if ($bd['style'] === 'double') { - $strokes = [[[0, 1 / 3], $bd['c']], [[2 / 3, 1 / 3], $bd['c']]]; + $strokes = [[0, 1 / 3, $bd['c']], [2 / 3, 1 / 3, $bd['c']]]; } elseif (in_array($bd['style'], ['groove', 'ridge', 'inset', 'outset'], true)) { $light = $this->colorConverter->lighten($bd['c']); $dark = $this->colorConverter->darken($bd['c']); @@ -21986,36 +21973,39 @@ function paintRoundedCellBorder($box, $bord, $details) if ($side === 'bottom' || $side === 'right') { $tones = array_reverse($tones); } - $strokes = [[[0, 1 / 2], $tones[0]], [[1 / 2, 1 / 2], $tones[1]]]; + $strokes = [[0, 1 / 2, $tones[0]], [1 / 2, 1 / 2, $tones[1]]]; } foreach ($strokes as $stroke) { - list($part, $colour) = $stroke; - $insets = []; - foreach ($widths as $s => $width) { - $insets[$s] = $width * $part[0]; - } - $inner = [ - 'x0' => $box['x0'] + $insets['left'], - 'y0' => $box['y0'] + $insets['top'], - 'x1' => $box['x1'] - $insets['right'], - 'y1' => $box['y1'] - $insets['bottom'], - 'radii' => $this->roundedBox->inset($box['radii'], $insets), - ]; + list($start, $width, $colour) = $stroke; + $key = (string) $start; + if (!isset($boxes[$key])) { + $insets = ['top' => $details['T']['w'] * $start, 'right' => $details['R']['w'] * $start, 'bottom' => $details['B']['w'] * $start, 'left' => $details['L']['w'] * $start]; + $boxes[$key] = [ + 'x0' => $box['x0'] + $insets['left'], + 'y0' => $box['y0'] + $insets['top'], + 'x1' => $box['x1'] - $insets['right'], + 'y1' => $box['y1'] - $insets['bottom'], + 'radii' => $this->roundedBox->inset($box['radii'], $insets), + ]; + } - $this->_setBorderLine(['w' => $bd['w'] * $part[1], 'c' => $colour]); + $this->_setBorderLine(['w' => $bd['w'] * $width, 'c' => $colour]); if ($bd['style'] === 'dotted' || $bd['style'] === 'dashed') { $this->_setDashBorder($bd['style'], '', '', $flag[0]); } else { $this->SetLineCap(0); // An arc stops at the diagonal, so nothing may run past it into the next side } - $this->writer->write($this->roundedBox->side($this->h, $side, $inner, $bd['w'] * $part[1]) . 'S'); - $this->SetLineJoin(2); - $this->SetLineCap(2); - $this->SetDash(); + $this->writer->write($this->roundedBox->side($this->h, $side, $boxes[$key], $bd['w'] * $width) . 'S'); } + $this->SetLineJoin(2); + $this->SetLineCap(2); + $this->SetDash(); + } + + if ($translucent) { + $this->SetAlpha(1); } - $this->SetAlpha(1); } function _tableRect($x, $y, $w, $h, $bord = -1, $details = [], $buffer = false, $bSeparate = false, $cort = 'cell', $tablecorner = '', $bsv = 0, $bsh = 0) @@ -23915,10 +23905,7 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl $bord_det = $table['simple']['border_details']; } - $cellBox = $paintcell ? $this->roundedCellBox(Arrays::get($cell, 'border_radius', null), $table, $x, $y, $w, $h, $bord_det) : null; - if ($cellBox) { - $cellBackgrounds = $this->countTableBackgrounds($level); - } + $cellBox = $paintcell ? $this->roundedCellBox(Arrays::get($cell, 'border_radius', null), $table, $level, $x, $y, $w, $h, $bord_det) : null; // TABLE ROW OR CELL FILL BGCOLOR $fill = 0; @@ -24378,7 +24365,7 @@ function _tableWrite(&$table, $split = false, $startrow = 0, $startcol = 0, $spl /* -- END BACKGROUNDS -- */ if ($cellBox) { - $this->clipRoundedCellBackgrounds($cellBox, $cellBackgrounds); + $this->clipRoundedCellBackgrounds($cellBox); } // TABLE BORDER - if separate diff --git a/src/RoundedBox.php b/src/RoundedBox.php index becabb413..e39d9dd29 100644 --- a/src/RoundedBox.php +++ b/src/RoundedBox.php @@ -135,6 +135,25 @@ public function fit($width, $height, $radii, $borders) return $radii; } + /** + * Every corner, its radii divided by $k, as a table shrunk to fit its page shrinks what it holds. A corner left + * out is square. + * + * @param array $radii Keyed by the corners given a radius + * @param float $k + * + * @return array + */ + public function scale($radii, $k) + { + $scaled = self::SQUARE; + foreach ($radii as $corner => $r) { + $scaled[$corner] = [$r[0] / $k, $r[1] / $k]; + } + + return $scaled; + } + /** * Radii moved in from their edge by what lies along each side, stopping at square: the padding edge's corners * are the border edge's less the borders, and the content edge's are those less the paddings. $insets is keyed @@ -171,11 +190,7 @@ public function imageBox($objattr, $k) $paddings[$side] = $objattr['padding_' . $side] / $k; } - $radii = self::SQUARE; - foreach ($objattr['border_radius'] as $corner => $r) { - $radii[$corner] = [$r[0] / $k, $r[1] / $k]; - } - $radii = $this->fit($x1 - $x0, $y1 - $y0, $radii, $borders); + $radii = $this->fit($x1 - $x0, $y1 - $y0, $this->scale($objattr['border_radius'], $k), $borders); return [ 'x0' => $x0, diff --git a/tests/data/snapshots/table-cell-border-radius.pdf b/tests/data/snapshots/table-cell-border-radius.pdf index 1a3c41625070e736b935169569b0043bf2dfc080..94d1fd2787c1a6bf70c5986eae0fb0a647b2a697 100644 GIT binary patch delta 812 zcmaiwOG_L<6oqMYCb4lBd>{zpb1@25SJkZt=!mEZh>S`?(4Yg(Ot%8XoSxx1!*5eHSeEw3W zD$QxV;+8)e(W5&)`3YTVK7GCIx7}4mS|6tm68amubhN}5j&7wVj(UO{eZ~5csvI=v z-hoR;UC%V=?!m`b0Yio{E!8%Bqpnz|%1wRHVx%@1Vl!iguRi=bZuqLmXAN&0RCY4t zb#7`W5wg$UBlgd9#Qyyeu@AHVyp!6=Z6!jd{X&#w&yTVkE<{;&7vK1!rS|%8xu?zH z)h0w(3RO>PIpu}|1m{9!Z)M5DVD$+0gv}F72#;}s$2fwyXLE!Q8LKfG`jy^=A{1d_ zffO*dhfpBG4oyK4Y`(=@gUye7$O;2O zHpWteM1WPx{oq^?6}Tt{1;{ftpQnKY76A55UO}W^%%uAH|ByL1lX2HK=GQj1*ZjZ* N=`rkfb&XDZegUAW=s*Af delta 934 zcmdmYnR(k)<_Vuzj0_A7Hhy#GWY>2OHdIJ2o_w)XdviKhBL|9rkZ>9+ioha?6{spo z6knkVbZE_CWmE9tGEnf^tYmPX5yt*+c#o0Y2r6;M_yU|Wd9~?NBs)|#Z#Ls$o4hnt zcJmdRyNr|dB4sxpv-<>M$ZbC7aE=iuB{zAK(_>^kllKO2Y(C)3!Uk4;(-WuT zb7M3)VWmGIFw_~F8d_qgGqo_s5VNoZn^%wIC}R^Nb3-(RCZ;Cl z=$4q6ni-=z+QiJz!T?>bnW?!MhM1WNvPXdS8yJ|FS(;&3Vs31LZlI}!8HSI6eltRM zw5f%K5xT{uKo47>ImXn|Pz=M>rk2JA29uRu+Q%BEnVT9|rX`ygr