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