xref: /dokuwiki/inc/Parsing/Handler/Table.php (revision 0e18d4f543d0ed24dda3ba77b417473cfad483e2)
1<?php
2
3namespace dokuwiki\Parsing\Handler;
4
5class Table extends AbstractRewriter
6{
7    protected $tableCalls = [];
8    protected $maxCols = 0;
9    protected $maxRows = 1;
10    protected $currentCols = 0;
11    protected $firstCell = false;
12    protected $lastCellType = 'tablecell';
13    protected $inTableHead = true;
14    protected $currentRow = ['tableheader' => 0, 'tablecell' => 0];
15    protected $countTableHeadRows = 0;
16
17    /** @inheritdoc */
18    public function finalise()
19    {
20        $last_call = end($this->calls);
21        $this->writeCall(['table_end', [], $last_call[2]]);
22
23        $this->process();
24        $this->callWriter->finalise();
25        unset($this->callWriter);
26    }
27
28    /** @inheritdoc */
29    public function process()
30    {
31        foreach ($this->calls as $call) {
32            switch ($call[0]) {
33                case 'table_start':
34                    $this->tableStart($call);
35                    break;
36                case 'table_row':
37                    $this->tableRowClose($call);
38                    $this->tableRowOpen(['tablerow_open', $call[1], $call[2]]);
39                    break;
40                case 'tableheader':
41                case 'tablecell':
42                    $this->tableCell($call);
43                    break;
44                case 'table_end':
45                    $this->tableRowClose($call);
46                    $this->tableEnd($call);
47                    break;
48                default:
49                    $this->tableDefault($call);
50                    break;
51            }
52        }
53        $this->callWriter->writeCalls($this->tableCalls);
54
55        return $this->callWriter;
56    }
57
58    protected function tableStart($call)
59    {
60        $this->tableCalls[] = ['table_open', $call[1], $call[2]];
61        $this->tableCalls[] = ['tablerow_open', [], $call[2]];
62        $this->firstCell = true;
63    }
64
65    protected function tableEnd($call)
66    {
67        $this->tableCalls[] = ['table_close', $call[1], $call[2]];
68        $this->finalizeTable();
69    }
70
71    protected function tableRowOpen($call)
72    {
73        $this->tableCalls[] = $call;
74        $this->currentCols = 0;
75        $this->firstCell = true;
76        $this->lastCellType = 'tablecell';
77        $this->maxRows++;
78        if ($this->inTableHead) {
79            $this->currentRow = ['tablecell' => 0, 'tableheader' => 0];
80        }
81    }
82
83    protected function tableRowClose($call)
84    {
85        if ($this->inTableHead && ($this->inTableHead = $this->isTableHeadRow())) {
86            $this->countTableHeadRows++;
87        }
88        // Strip off final cell opening and anything after it
89        while ($discard = array_pop($this->tableCalls)) {
90            if ($discard[0] == 'tablecell_open' || $discard[0] == 'tableheader_open') {
91                break;
92            }
93            if (!empty($this->currentRow[$discard[0]])) {
94                $this->currentRow[$discard[0]]--;
95            }
96        }
97        $this->tableCalls[] = ['tablerow_close', [], $call[2]];
98
99        if ($this->currentCols > $this->maxCols) {
100            $this->maxCols = $this->currentCols;
101        }
102    }
103
104    protected function isTableHeadRow()
105    {
106        $td = $this->currentRow['tablecell'];
107        $th = $this->currentRow['tableheader'];
108
109        if (!$th || $td > 2) return false;
110        if (2 * $td > $th) return false;
111
112        return true;
113    }
114
115    protected function tableCell($call)
116    {
117        if ($this->inTableHead) {
118            $this->currentRow[$call[0]]++;
119        }
120        if (!$this->firstCell) {
121            // Increase the span
122            $lastCall = end($this->tableCalls);
123
124            // A cell call which follows an open cell means an empty cell so span
125            if ($lastCall[0] == 'tablecell_open' || $lastCall[0] == 'tableheader_open') {
126                $this->tableCalls[] = ['colspan', [], $call[2]];
127            }
128
129            $this->tableCalls[] = [$this->lastCellType . '_close', [], $call[2]];
130            $this->tableCalls[] = [$call[0] . '_open', [1, null, 1], $call[2]];
131            $this->lastCellType = $call[0];
132        } else {
133            $this->tableCalls[] = [$call[0] . '_open', [1, null, 1], $call[2]];
134            $this->lastCellType = $call[0];
135            $this->firstCell = false;
136        }
137
138        $this->currentCols++;
139    }
140
141    protected function tableDefault($call)
142    {
143        $this->tableCalls[] = $call;
144    }
145
146    protected function finalizeTable()
147    {
148
149        // Add the max cols and rows to the table opening
150        if ($this->tableCalls[0][0] == 'table_open') {
151            // Adjust to num cols not num col delimeters
152            $this->tableCalls[0][1][] = $this->maxCols - 1;
153            $this->tableCalls[0][1][] = $this->maxRows;
154            $this->tableCalls[0][1][] = array_shift($this->tableCalls[0][1]);
155        } else {
156            trigger_error('First element in table call list is not table_open');
157        }
158
159        $lastRow = 0;
160        $lastCell = 0;
161        $cellKey = [];
162        $toDelete = [];
163
164        // if still in tableheader, then there can be no table header
165        // as all rows can't be within <THEAD>
166        if ($this->inTableHead) {
167            $this->inTableHead = false;
168            $this->countTableHeadRows = 0;
169        }
170        // Look for the colspan elements and increment the colspan on the
171        // previous non-empty opening cell. Once done, delete all the cells
172        // that contain colspans
173        $counter = count($this->tableCalls);
174
175        // Look for the colspan elements and increment the colspan on the
176        // previous non-empty opening cell. Once done, delete all the cells
177        // that contain colspans
178        for ($key = 0; $key < $counter; ++$key) {
179            $call = $this->tableCalls[$key];
180
181            switch ($call[0]) {
182                case 'table_open':
183                    if ($this->countTableHeadRows) {
184                        array_splice($this->tableCalls, $key + 1, 0, [['tablethead_open', [], $call[2]]]);
185                    }
186                    break;
187
188                case 'tablerow_open':
189                    $lastRow++;
190                    $lastCell = 0;
191                    break;
192
193                case 'tablecell_open':
194                case 'tableheader_open':
195                    $lastCell++;
196                    $cellKey[$lastRow][$lastCell] = $key;
197                    break;
198
199                case 'table_align':
200                    $prev = in_array($this->tableCalls[$key - 1][0], ['tablecell_open', 'tableheader_open']);
201                    $next = in_array($this->tableCalls[$key + 1][0], ['tablecell_close', 'tableheader_close']);
202                    // If the cell is empty, align left
203                    if ($prev && $next) {
204                        $this->tableCalls[$key - 1][1][1] = 'left';
205
206                        // If the previous element was a cell open, align right
207                    } elseif ($prev) {
208                        $this->tableCalls[$key - 1][1][1] = 'right';
209
210                        // If the next element is the close of an element, align either center or left
211                    } elseif ($next) {
212                        if ($this->tableCalls[$cellKey[$lastRow][$lastCell]][1][1] == 'right') {
213                            $this->tableCalls[$cellKey[$lastRow][$lastCell]][1][1] = 'center';
214                        } else {
215                            $this->tableCalls[$cellKey[$lastRow][$lastCell]][1][1] = 'left';
216                        }
217                    }
218
219                    // Now convert the whitespace back to cdata
220                    $this->tableCalls[$key][0] = 'cdata';
221                    break;
222
223                case 'colspan':
224                    $this->tableCalls[$key - 1][1][0] = false;
225
226                    for ($i = $key - 2; $i >= $cellKey[$lastRow][1]; $i--) {
227                        if (
228                            $this->tableCalls[$i][0] == 'tablecell_open' ||
229                            $this->tableCalls[$i][0] == 'tableheader_open'
230                        ) {
231                            if (false !== $this->tableCalls[$i][1][0]) {
232                                $this->tableCalls[$i][1][0]++;
233                                break;
234                            }
235                        }
236                    }
237
238                    $toDelete[] = $key - 1;
239                    $toDelete[] = $key;
240                    $toDelete[] = $key + 1;
241                    break;
242
243                case 'rowspan':
244                    if ($this->tableCalls[$key - 1][0] == 'cdata') {
245                        // ignore rowspan if previous call was cdata (text mixed with :::)
246                        // we don't have to check next call as that wont match regex
247                        $this->tableCalls[$key][0] = 'cdata';
248                    } else {
249                        $spanning_cell = null;
250
251                        // can't cross thead/tbody boundary
252                        if (!$this->countTableHeadRows || ($lastRow - 1 != $this->countTableHeadRows)) {
253                            for ($i = $lastRow - 1; $i > 0; $i--) {
254                                if (
255                                    $this->tableCalls[$cellKey[$i][$lastCell]][0] == 'tablecell_open' ||
256                                    $this->tableCalls[$cellKey[$i][$lastCell]][0] == 'tableheader_open'
257                                ) {
258                                    if ($this->tableCalls[$cellKey[$i][$lastCell]][1][2] >= $lastRow - $i) {
259                                        $spanning_cell = $i;
260                                        break;
261                                    }
262                                }
263                            }
264                        }
265                        if (is_null($spanning_cell)) {
266                            // No spanning cell found, so convert this cell to
267                            // an empty one to avoid broken tables
268                            $this->tableCalls[$key][0] = 'cdata';
269                            $this->tableCalls[$key][1][0] = '';
270                            break;
271                        }
272                        $this->tableCalls[$cellKey[$spanning_cell][$lastCell]][1][2]++;
273
274                        $this->tableCalls[$key - 1][1][2] = false;
275
276                        $toDelete[] = $key - 1;
277                        $toDelete[] = $key;
278                        $toDelete[] = $key + 1;
279                    }
280                    break;
281
282                case 'tablerow_close':
283                    // Fix broken tables by adding missing cells
284                    $moreCalls = [];
285                    while (++$lastCell < $this->maxCols) {
286                        $moreCalls[] = ['tablecell_open', [1, null, 1], $call[2]];
287                        $moreCalls[] = ['cdata', [''], $call[2]];
288                        $moreCalls[] = ['tablecell_close', [], $call[2]];
289                    }
290                    $moreCallsLength = count($moreCalls);
291                    if ($moreCallsLength) {
292                        array_splice($this->tableCalls, $key, 0, $moreCalls);
293                        $key += $moreCallsLength;
294                    }
295
296                    if ($this->countTableHeadRows == $lastRow) {
297                        array_splice($this->tableCalls, $key + 1, 0, [['tablethead_close', [], $call[2]]]);
298                    }
299                    break;
300            }
301        }
302
303        // condense cdata
304        $cnt = count($this->tableCalls);
305        for ($key = 0; $key < $cnt; $key++) {
306            if ($this->tableCalls[$key][0] == 'cdata') {
307                $ckey = $key;
308                $key++;
309                while ($this->tableCalls[$key][0] == 'cdata') {
310                    $this->tableCalls[$ckey][1][0] .= $this->tableCalls[$key][1][0];
311                    $toDelete[] = $key;
312                    $key++;
313                }
314                continue;
315            }
316        }
317
318        foreach ($toDelete as $delete) {
319            unset($this->tableCalls[$delete]);
320        }
321        $this->tableCalls = array_values($this->tableCalls);
322    }
323}
324