Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
Grid
100.00% covered (success)
100.00%
51 / 51
100.00% covered (success)
100.00%
6 / 6
25
100.00% covered (success)
100.00%
1 / 1
 build
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getRows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getColumnCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRowCount
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parse
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
13
 collectRowNodes
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
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\Dom\Child;
18
19/**
20 * Pdf HTML table grid class
21 *
22 * Walks a <table> DOM node into an explicit [row][col] grid, handling
23 * colspan/rowspan slot-claiming and header-row detection (<thead>, or a
24 * <tr> whose cells are all <th>).
25 *
26 * @category   Pop
27 * @package    Pop\Pdf
28 * @author     Nick Sagona, III <nick@popphp.org>
29 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
30 * @license    https://www.popphp.org/license     New BSD License
31 * @version    6.0.0
32 */
33class Grid
34{
35
36    /**
37     * Rows, keyed by zero-based row index: ['cells' => Cell[], 'isHeader' => bool]
38     * @var array
39     */
40    protected array $rows = [];
41
42    /**
43     * Total column count across the whole table
44     * @var int
45     */
46    protected int $columnCount = 0;
47
48    /**
49     * Total row count
50     * @var int
51     */
52    protected int $rowCount = 0;
53
54    /**
55     * Build a grid from a <table> DOM node
56     *
57     * @param  Child $tableNode
58     * @return Grid
59     */
60    public static function build(Child $tableNode): Grid
61    {
62        $grid = new self();
63        $grid->parse($tableNode);
64        return $grid;
65    }
66
67    /**
68     * Get the rows
69     *
70     * @return array
71     */
72    public function getRows(): array
73    {
74        return $this->rows;
75    }
76
77    /**
78     * Get the total column count
79     *
80     * @return int
81     */
82    public function getColumnCount(): int
83    {
84        return $this->columnCount;
85    }
86
87    /**
88     * Get the total row count
89     *
90     * @return int
91     */
92    public function getRowCount(): int
93    {
94        return $this->rowCount;
95    }
96
97    /**
98     * Parse the <table> DOM node into the grid
99     *
100     * @param  Child $tableNode
101     * @return void
102     */
103    protected function parse(Child $tableNode): void
104    {
105        $occupied = [];
106        $rowIndex = 0;
107
108        foreach (self::collectRowNodes($tableNode) as $rowInfo) {
109            [$trNode, $isHeaderGroup] = $rowInfo;
110
111            $colIndex = 0;
112            $rowCells = [];
113            $isAllTh  = $trNode->hasChildNodes();
114
115            foreach ($trNode->getChildNodes() as $cellNode) {
116                $name = $cellNode->getNodeName();
117                if (($name !== 'td') && ($name !== 'th')) {
118                    continue;
119                }
120                if ($name !== 'th') {
121                    $isAllTh = false;
122                }
123
124                while (isset($occupied[$rowIndex . ':' . $colIndex])) {
125                    $colIndex++;
126                }
127
128                // Clamped to the HTML living standard's own caps (rowspan
129                // 65534, colspan 1000) here, at the point the raw attribute
130                // is read - Cell's constructor clamps too, but by then the
131                // slot-claiming loop below would already have iterated the
132                // unclamped value, which is unbounded author-provided input.
133                $colSpan = min(1000, max(1, (int) ($cellNode->getAttribute('colspan') ?? 1)));
134                $rowSpan = min(65534, max(1, (int) ($cellNode->getAttribute('rowspan') ?? 1)));
135
136                // Each span is individually clamped above, but their PRODUCT
137                // still isn't bounded - rowspan=65534 * colspan=1000 alone
138                // claims 65.5 million occupied-slot entries. Cap the product
139                // a single cell can claim, scaling down whichever dimension
140                // is larger, so one cell can never allocate more than a few
141                // thousand slots regardless of how the two attributes combine.
142                if (($rowSpan * $colSpan) > 10000) {
143                    if ($rowSpan > $colSpan) {
144                        $rowSpan = (int) max(1, floor(10000 / $colSpan));
145                    } else {
146                        $colSpan = (int) max(1, floor(10000 / $rowSpan));
147                    }
148                }
149
150                $cell = new Cell($cellNode, $rowIndex, $colIndex, $rowSpan, $colSpan, ($name === 'th'));
151                $rowCells[] = $cell;
152
153                for ($r = $rowIndex; $r < ($rowIndex + $rowSpan); $r++) {
154                    for ($c = $colIndex; $c < ($colIndex + $colSpan); $c++) {
155                        $occupied[$r . ':' . $c] = true;
156                    }
157                }
158
159                $colIndex += $colSpan;
160            }
161
162            if (!empty($rowCells)) {
163                $this->rows[$rowIndex] = [
164                    'cells'    => $rowCells,
165                    'isHeader' => ($isHeaderGroup || $isAllTh)
166                ];
167                $this->columnCount = max($this->columnCount, $colIndex);
168                $rowIndex++;
169            }
170        }
171
172        $this->rowCount = $rowIndex;
173    }
174
175    /**
176     * Collect every <tr> node in document order, alongside whether it's inside a <thead>
177     *
178     * @param  Child $tableNode
179     * @return array
180     */
181    protected static function collectRowNodes(Child $tableNode): array
182    {
183        $result = [];
184
185        foreach ($tableNode->getChildNodes() as $child) {
186            $name = $child->getNodeName();
187            if (($name === 'thead') || ($name === 'tbody') || ($name === 'tfoot')) {
188                foreach ($child->getChildNodes() as $tr) {
189                    if ($tr->getNodeName() === 'tr') {
190                        $result[] = [$tr, ($name === 'thead')];
191                    }
192                }
193            } else if ($name === 'tr') {
194                $result[] = [$child, false];
195            }
196        }
197
198        return $result;
199    }
200
201}