Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
129 / 129 |
|
100.00% |
10 / 10 |
CRAP | |
100.00% |
1 / 1 |
| Layout | |
100.00% |
129 / 129 |
|
100.00% |
10 / 10 |
51 | |
100.00% |
1 / 1 |
| render | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
14 | |||
| calculateColumnWidths | |
100.00% |
41 / 41 |
|
100.00% |
1 / 1 |
18 | |||
| resolveExplicitWidth | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| measureRowHeight | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| drawRow | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
4 | |||
| cellStyles | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| cellText | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| spanWidth | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| spanHeight | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| widthBeforeColumn | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | /** |
| 4 | * Pop PHP Framework (https://www.popphp.org/) |
| 5 | * |
| 6 | * @link https://github.com/popphp/popphp-framework |
| 7 | * @author Nick Sagona, III <nick@popphp.org> |
| 8 | * @copyright Copyright (c) 2009-2026 Nick Sagona, III |
| 9 | * @license https://www.popphp.org/license New BSD License |
| 10 | */ |
| 11 | |
| 12 | /** |
| 13 | * @namespace |
| 14 | */ |
| 15 | namespace Pop\Pdf\Build\Html\Table; |
| 16 | |
| 17 | use Pop\Color\Color; |
| 18 | use Pop\Dom\Child; |
| 19 | use Pop\Pdf\Build\Html\Parser; |
| 20 | use Pop\Pdf\Document; |
| 21 | |
| 22 | /** |
| 23 | * Pdf HTML table layout class |
| 24 | * |
| 25 | * Two-pass table rendering: measures natural column widths from cell |
| 26 | * content, distributes the available table width across columns, then lays |
| 27 | * out rows top to bottom with per-row page-break checks and header-row |
| 28 | * repeat on page break. |
| 29 | * |
| 30 | * @category Pop |
| 31 | * @package Pop\Pdf |
| 32 | * @author Nick Sagona, III <nick@popphp.org> |
| 33 | * @copyright Copyright (c) 2009-2026 Nick Sagona, III |
| 34 | * @license https://www.popphp.org/license New BSD License |
| 35 | * @version 6.0.0 |
| 36 | */ |
| 37 | class Layout |
| 38 | { |
| 39 | |
| 40 | /** |
| 41 | * Minimum row height |
| 42 | */ |
| 43 | protected const MIN_ROW_HEIGHT = 20; |
| 44 | |
| 45 | /** |
| 46 | * Render a <table> node onto the document |
| 47 | * |
| 48 | * @param Parser $parser |
| 49 | * @param Child $tableNode |
| 50 | * @param array $styles |
| 51 | * @param int $startX |
| 52 | * @param int $tableWidth |
| 53 | * @param float $startY |
| 54 | * @return void |
| 55 | */ |
| 56 | public static function render(Parser $parser, Child $tableNode, array $styles, int $startX, int $tableWidth, float $startY): void |
| 57 | { |
| 58 | $grid = Grid::build($tableNode); |
| 59 | |
| 60 | if ($grid->getRowCount() === 0) { |
| 61 | return; |
| 62 | } |
| 63 | |
| 64 | $columnWidths = self::calculateColumnWidths($parser, $grid, $tableWidth, $styles); |
| 65 | $rows = $grid->getRows(); |
| 66 | |
| 67 | $rowHeights = []; |
| 68 | foreach ($rows as $rowIndex => $row) { |
| 69 | $rowHeights[$rowIndex] = self::measureRowHeight($parser, $row['cells'], $columnWidths, $styles); |
| 70 | } |
| 71 | |
| 72 | $headerRows = []; |
| 73 | foreach ($rows as $rowIndex => $row) { |
| 74 | if ($row['isHeader']) { |
| 75 | $headerRows[$rowIndex] = $row; |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | $currentY = $startY; |
| 80 | $pageHeight = $parser->getPage()?->getHeight(); |
| 81 | $usableRowHeight = ($pageHeight !== null) |
| 82 | ? ($pageHeight - $parser->getPageTopMargin() - $parser->getPageBottomMargin()) |
| 83 | : null; |
| 84 | |
| 85 | foreach ($rows as $rowIndex => $row) { |
| 86 | $rowHeight = $rowHeights[$rowIndex]; |
| 87 | |
| 88 | // A row taller than a full page's usable height can never fit |
| 89 | // no matter how many times we break - breaking anyway would |
| 90 | // just leave an empty page in front of it, so it's drawn |
| 91 | // best-effort where we are instead (matches this project's |
| 92 | // accepted no-cell/row-splitting-across-pages limitation). |
| 93 | $fitsOnFreshPage = ($usableRowHeight === null) || ($rowHeight <= $usableRowHeight); |
| 94 | |
| 95 | if ((($currentY - $rowHeight) < $parser->getPageBottomMargin()) && $fitsOnFreshPage) { |
| 96 | $currentY = $parser->newPage(); |
| 97 | |
| 98 | if (!$row['isHeader']) { |
| 99 | foreach ($headerRows as $headerRowIndex => $headerRow) { |
| 100 | $headerHeight = $rowHeights[$headerRowIndex]; |
| 101 | self::drawRow($parser, $headerRow['cells'], $columnWidths, $styles, $startX, $currentY, $rowHeights); |
| 102 | $currentY -= $headerHeight; |
| 103 | } |
| 104 | } |
| 105 | } |
| 106 | |
| 107 | self::drawRow($parser, $row['cells'], $columnWidths, $styles, $startX, $currentY, $rowHeights); |
| 108 | $currentY -= $rowHeight; |
| 109 | } |
| 110 | |
| 111 | $page = $parser->getPage(); |
| 112 | if ($page !== null) { |
| 113 | // A best-effort oversized row above may have pushed $currentY |
| 114 | // past the bottom margin - hand the next node a fresh page |
| 115 | // rather than a Y position that would render it off-page and |
| 116 | // effectively lose it. |
| 117 | if ($currentY < $parser->getPageBottomMargin()) { |
| 118 | $currentY = $parser->newPage(); |
| 119 | $page = $parser->getPage(); |
| 120 | } |
| 121 | |
| 122 | $consumedY = ($page->getHeight() - $parser->getPageTopMargin()) - $currentY; |
| 123 | $parser->setY((int) $consumedY); |
| 124 | $parser->setYOverride((int) $currentY); |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Calculate each column's width, distributing the table's available width |
| 130 | * |
| 131 | * @param Parser $parser |
| 132 | * @param Grid $grid |
| 133 | * @param int $tableWidth |
| 134 | * @param array $styles |
| 135 | * @return array |
| 136 | */ |
| 137 | protected static function calculateColumnWidths(Parser $parser, Grid $grid, int $tableWidth, array $styles): array |
| 138 | { |
| 139 | $columnCount = $grid->getColumnCount(); |
| 140 | if ($columnCount === 0) { |
| 141 | return []; |
| 142 | } |
| 143 | |
| 144 | $natural = array_fill(0, $columnCount, 0.0); |
| 145 | $explicit = array_fill(0, $columnCount, null); |
| 146 | |
| 147 | foreach ($grid->getRows() as $row) { |
| 148 | foreach ($row['cells'] as $cell) { |
| 149 | $cellStyles = self::cellStyles($parser, $cell, $styles); |
| 150 | $fontObject = $parser->getDocument()->getFont($cellStyles['currentFont']); |
| 151 | $text = self::cellText($cell); |
| 152 | $width = ($text !== '') ? $fontObject->getStringWidth($text, $cellStyles['fontSize']) : 0; |
| 153 | $width += $cellStyles['paddingLeft'] + $cellStyles['paddingRight']; |
| 154 | |
| 155 | $perColumn = $width / $cell->getColSpan(); |
| 156 | for ($c = $cell->getCol(); $c < ($cell->getCol() + $cell->getColSpan()); $c++) { |
| 157 | if ($c < $columnCount) { |
| 158 | $natural[$c] = max($natural[$c], $perColumn); |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | if ($cell->getColSpan() === 1) { |
| 163 | $widthValue = !empty($cellStyles['width']) ? (string) $cellStyles['width'] : $cell->getNode()->getAttribute('width'); |
| 164 | if (!empty($widthValue)) { |
| 165 | $resolved = self::resolveExplicitWidth((string) $widthValue, $tableWidth); |
| 166 | if ($resolved !== null) { |
| 167 | $explicit[$cell->getCol()] = max($explicit[$cell->getCol()] ?? 0.0, $resolved); |
| 168 | } |
| 169 | } |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | $explicitTotal = 0.0; |
| 175 | $naturalRemaining = 0.0; |
| 176 | $autoColumnCount = 0; |
| 177 | |
| 178 | foreach ($natural as $i => $w) { |
| 179 | if ($explicit[$i] !== null) { |
| 180 | $explicitTotal += $explicit[$i]; |
| 181 | } else { |
| 182 | $naturalRemaining += $w; |
| 183 | $autoColumnCount++; |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // If the explicit-width columns alone already exceed the table's |
| 188 | // available width, scale them all down proportionally to fit - |
| 189 | // otherwise a column renders past the page's right edge, and every |
| 190 | // auto column collapses to 0 width. |
| 191 | $explicitScale = (($explicitTotal > $tableWidth) && ($explicitTotal > 0)) |
| 192 | ? ($tableWidth / $explicitTotal) : 1.0; |
| 193 | |
| 194 | $remainingWidth = max(0.0, $tableWidth - min($explicitTotal, $tableWidth)); |
| 195 | |
| 196 | $widths = []; |
| 197 | foreach ($natural as $i => $w) { |
| 198 | if ($explicit[$i] !== null) { |
| 199 | $widths[$i] = $explicit[$i] * $explicitScale; |
| 200 | } else if ($naturalRemaining > 0) { |
| 201 | $widths[$i] = ($w / $naturalRemaining) * $remainingWidth; |
| 202 | } else { |
| 203 | $widths[$i] = $remainingWidth / max(1, $autoColumnCount); |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | return $widths; |
| 208 | } |
| 209 | |
| 210 | /** |
| 211 | * Resolve a CSS width value (px or %) against the table's available width |
| 212 | * |
| 213 | * @param string $value |
| 214 | * @param int $tableWidth |
| 215 | * @return ?float |
| 216 | */ |
| 217 | protected static function resolveExplicitWidth(string $value, int $tableWidth): ?float |
| 218 | { |
| 219 | $value = trim($value); |
| 220 | if ($value === '') { |
| 221 | return null; |
| 222 | } |
| 223 | |
| 224 | if (str_ends_with($value, '%')) { |
| 225 | return $tableWidth * ((float) rtrim($value, '%') / 100); |
| 226 | } |
| 227 | |
| 228 | $numeric = (float) $value; |
| 229 | return ($numeric > 0) ? $numeric : null; |
| 230 | } |
| 231 | |
| 232 | /** |
| 233 | * Measure a row's height as the max wrapped height across its cells |
| 234 | * |
| 235 | * @param Parser $parser |
| 236 | * @param array $cells |
| 237 | * @param array $columnWidths |
| 238 | * @param array $styles |
| 239 | * @return float |
| 240 | */ |
| 241 | protected static function measureRowHeight(Parser $parser, array $cells, array $columnWidths, array $styles): float |
| 242 | { |
| 243 | $maxHeight = 0.0; |
| 244 | |
| 245 | foreach ($cells as $cell) { |
| 246 | $cellStyles = self::cellStyles($parser, $cell, $styles); |
| 247 | $fontObject = $parser->getDocument()->getFont($cellStyles['currentFont']); |
| 248 | $cellWidth = max(1.0, self::spanWidth($columnWidths, $cell) - $cellStyles['paddingLeft'] - $cellStyles['paddingRight']); |
| 249 | |
| 250 | $text = self::cellText($cell); |
| 251 | $lines = ($text !== '') ? $parser->getStringLines($text, $cellStyles['fontSize'], (int) $cellWidth, $fontObject) : []; |
| 252 | $lineCount = max(1, count($lines)); |
| 253 | |
| 254 | $height = ($lineCount * $cellStyles['lineHeight']) + $cellStyles['paddingTop'] + $cellStyles['paddingBottom']; |
| 255 | $maxHeight = max($maxHeight, $height); |
| 256 | } |
| 257 | |
| 258 | return max($maxHeight, self::MIN_ROW_HEIGHT); |
| 259 | } |
| 260 | |
| 261 | /** |
| 262 | * Draw one row: each cell's box, then its wrapped text |
| 263 | * |
| 264 | * @param Parser $parser |
| 265 | * @param array $cells |
| 266 | * @param array $columnWidths |
| 267 | * @param array $styles |
| 268 | * @param int $startX |
| 269 | * @param float $rowTopY |
| 270 | * @param array $rowHeights |
| 271 | * @return void |
| 272 | */ |
| 273 | protected static function drawRow(Parser $parser, array $cells, array $columnWidths, array $styles, int $startX, float $rowTopY, array $rowHeights): void |
| 274 | { |
| 275 | foreach ($cells as $cell) { |
| 276 | $cellX = $startX + self::widthBeforeColumn($columnWidths, $cell->getCol()); |
| 277 | $cellWidth = self::spanWidth($columnWidths, $cell); |
| 278 | $cellHeight = self::spanHeight($rowHeights, $cell); |
| 279 | $cellStyles = self::cellStyles($parser, $cell, $styles); |
| 280 | |
| 281 | $parser->drawBox($cellX, $rowTopY, $cellWidth, $cellHeight, $cellStyles); |
| 282 | |
| 283 | $text = self::cellText($cell); |
| 284 | if ($text === '') { |
| 285 | continue; |
| 286 | } |
| 287 | |
| 288 | $fontObject = $parser->getDocument()->getFont($cellStyles['currentFont']); |
| 289 | $textWidth = max(1.0, $cellWidth - $cellStyles['paddingLeft'] - $cellStyles['paddingRight']); |
| 290 | $lines = $parser->getStringLines($text, $cellStyles['fontSize'], (int) $textWidth, $fontObject); |
| 291 | |
| 292 | $textX = $cellX + $cellStyles['paddingLeft']; |
| 293 | $textY = $rowTopY - $cellStyles['paddingTop'] - $cellStyles['fontSize']; |
| 294 | |
| 295 | foreach ($lines as $i => $line) { |
| 296 | $lineText = new Document\Page\Text($line, $cellStyles['fontSize']); |
| 297 | $lineText->setFillColor(new Color\Rgb($cellStyles['color'][0], $cellStyles['color'][1], $cellStyles['color'][2])); |
| 298 | $parser->getPage()->addText($lineText, $cellStyles['currentFont'], $textX, $textY - ($i * $cellStyles['lineHeight'])); |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | /** |
| 304 | * Resolve one cell's styles from its own tag/attributes, inheriting the table's styles |
| 305 | * |
| 306 | * @param Parser $parser |
| 307 | * @param Cell $cell |
| 308 | * @param array $parentStyles |
| 309 | * @return array |
| 310 | */ |
| 311 | protected static function cellStyles(Parser $parser, Cell $cell, array $parentStyles): array |
| 312 | { |
| 313 | $node = $cell->getNode(); |
| 314 | return $parser->prepareNodeStyles($node->getNodeName(), $node->getAttributes(), $parentStyles); |
| 315 | } |
| 316 | |
| 317 | /** |
| 318 | * Resolve a cell's trimmed, whitespace-collapsed text content |
| 319 | * |
| 320 | * Uses getTextContent(false) rather than pop-dom's ignoreWhiteSpace=true |
| 321 | * mode, which normalizes every '.' into '. ' (sentence-punctuation |
| 322 | * spacing) - correct for prose, but it mangles decimal numbers like |
| 323 | * "$19.99" into "$19. 99". Whitespace/newlines from HTML source |
| 324 | * formatting are collapsed here instead, without that side effect. |
| 325 | * |
| 326 | * @param Cell $cell |
| 327 | * @return string |
| 328 | */ |
| 329 | protected static function cellText(Cell $cell): string |
| 330 | { |
| 331 | return trim(preg_replace('/\s+/', ' ', $cell->getNode()->getTextContent(false))); |
| 332 | } |
| 333 | |
| 334 | /** |
| 335 | * Sum the widths of every column a cell spans |
| 336 | * |
| 337 | * @param array $columnWidths |
| 338 | * @param Cell $cell |
| 339 | * @return float |
| 340 | */ |
| 341 | protected static function spanWidth(array $columnWidths, Cell $cell): float |
| 342 | { |
| 343 | $width = 0.0; |
| 344 | for ($c = $cell->getCol(); $c < ($cell->getCol() + $cell->getColSpan()); $c++) { |
| 345 | $width += $columnWidths[$c] ?? 0.0; |
| 346 | } |
| 347 | return $width; |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * Sum the heights of every row a cell spans |
| 352 | * |
| 353 | * @param array $rowHeights |
| 354 | * @param Cell $cell |
| 355 | * @return float |
| 356 | */ |
| 357 | protected static function spanHeight(array $rowHeights, Cell $cell): float |
| 358 | { |
| 359 | $height = 0.0; |
| 360 | for ($r = $cell->getRow(); $r < ($cell->getRow() + $cell->getRowSpan()); $r++) { |
| 361 | $height += $rowHeights[$r] ?? 0.0; |
| 362 | } |
| 363 | return $height; |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Sum the widths of every column before a given column index |
| 368 | * |
| 369 | * @param array $columnWidths |
| 370 | * @param int $col |
| 371 | * @return float |
| 372 | */ |
| 373 | protected static function widthBeforeColumn(array $columnWidths, int $col): float |
| 374 | { |
| 375 | $width = 0.0; |
| 376 | for ($c = 0; $c < $col; $c++) { |
| 377 | $width += $columnWidths[$c] ?? 0.0; |
| 378 | } |
| 379 | return $width; |
| 380 | } |
| 381 | |
| 382 | } |