Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
129 / 129
100.00% covered (success)
100.00%
10 / 10
CRAP
100.00% covered (success)
100.00%
1 / 1
Layout
100.00% covered (success)
100.00%
129 / 129
100.00% covered (success)
100.00%
10 / 10
51
100.00% covered (success)
100.00%
1 / 1
 render
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
14
 calculateColumnWidths
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
18
 resolveExplicitWidth
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 measureRowHeight
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 drawRow
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 cellStyles
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 cellText
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 spanWidth
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 spanHeight
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 widthBeforeColumn
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(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 */
15namespace Pop\Pdf\Build\Html\Table;
16
17use Pop\Color\Color;
18use Pop\Dom\Child;
19use Pop\Pdf\Build\Html\Parser;
20use 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 */
37class 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}