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