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
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        $key = -1;
175        while (++$key < count($this->tableCalls)) {
176            $call = $this->tableCalls[$key];
177
178            switch ($call[0]) {
179                case 'table_open':
180                    if ($this->countTableHeadRows) {
181                        array_splice($this->tableCalls, $key + 1, 0, [['tablethead_open', [], $call[2]]]);
182                    }
183                    break;
184
185                case 'tablerow_open':
186                    $lastRow++;
187                    $lastCell = 0;
188                    break;
189
190                case 'tablecell_open':
191                case 'tableheader_open':
192                    $lastCell++;
193                    $cellKey[$lastRow][$lastCell] = $key;
194                    break;
195
196                case 'table_align':
197                    $prev = in_array($this->tableCalls[$key - 1][0], ['tablecell_open', 'tableheader_open']);
198                    $next = in_array($this->tableCalls[$key + 1][0], ['tablecell_close', 'tableheader_close']);
199                    // If the cell is empty, align left
200                    if ($prev && $next) {
201                        $this->tableCalls[$key - 1][1][1] = 'left';
202
203                        // If the previous element was a cell open, align right
204                    } elseif ($prev) {
205                        $this->tableCalls[$key - 1][1][1] = 'right';
206
207                        // If the next element is the close of an element, align either center or left
208                    } elseif ($next) {
209                        if ($this->tableCalls[$cellKey[$lastRow][$lastCell]][1][1] == 'right') {
210                            $this->tableCalls[$cellKey[$lastRow][$lastCell]][1][1] = 'center';
211                        } else {
212                            $this->tableCalls[$cellKey[$lastRow][$lastCell]][1][1] = 'left';
213                        }
214                    }
215
216                    // Now convert the whitespace back to cdata
217                    $this->tableCalls[$key][0] = 'cdata';
218                    break;
219
220                case 'colspan':
221                    $this->tableCalls[$key - 1][1][0] = false;
222
223                    for ($i = $key - 2; $i >= $cellKey[$lastRow][1]; $i--) {
224                        if (
225                            $this->tableCalls[$i][0] == 'tablecell_open' ||
226                            $this->tableCalls[$i][0] == 'tableheader_open'
227                        ) {
228                            if (false !== $this->tableCalls[$i][1][0]) {
229                                $this->tableCalls[$i][1][0]++;
230                                break;
231                            }
232                        }
233                    }
234
235                    $toDelete[] = $key - 1;
236                    $toDelete[] = $key;
237                    $toDelete[] = $key + 1;
238                    break;
239
240                case 'rowspan':
241                    if ($this->tableCalls[$key - 1][0] == 'cdata') {
242                        // ignore rowspan if previous call was cdata (text mixed with :::)
243                        // we don't have to check next call as that wont match regex
244                        $this->tableCalls[$key][0] = 'cdata';
245                    } else {
246                        $spanning_cell = null;
247
248                        // can't cross thead/tbody boundary
249                        if (!$this->countTableHeadRows || ($lastRow - 1 != $this->countTableHeadRows)) {
250                            for ($i = $lastRow - 1; $i > 0; $i--) {
251                                if (
252                                    $this->tableCalls[$cellKey[$i][$lastCell]][0] == 'tablecell_open' ||
253                                    $this->tableCalls[$cellKey[$i][$lastCell]][0] == 'tableheader_open'
254                                ) {
255                                    if ($this->tableCalls[$cellKey[$i][$lastCell]][1][2] >= $lastRow - $i) {
256                                        $spanning_cell = $i;
257                                        break;
258                                    }
259                                }
260                            }
261                        }
262                        if (is_null($spanning_cell)) {
263                            // No spanning cell found, so convert this cell to
264                            // an empty one to avoid broken tables
265                            $this->tableCalls[$key][0] = 'cdata';
266                            $this->tableCalls[$key][1][0] = '';
267                            break;
268                        }
269                        $this->tableCalls[$cellKey[$spanning_cell][$lastCell]][1][2]++;
270
271                        $this->tableCalls[$key - 1][1][2] = false;
272
273                        $toDelete[] = $key - 1;
274                        $toDelete[] = $key;
275                        $toDelete[] = $key + 1;
276                    }
277                    break;
278
279                case 'tablerow_close':
280                    // Fix broken tables by adding missing cells
281                    $moreCalls = [];
282                    while (++$lastCell < $this->maxCols) {
283                        $moreCalls[] = ['tablecell_open', [1, null, 1], $call[2]];
284                        $moreCalls[] = ['cdata', [''], $call[2]];
285                        $moreCalls[] = ['tablecell_close', [], $call[2]];
286                    }
287                    $moreCallsLength = count($moreCalls);
288                    if ($moreCallsLength) {
289                        array_splice($this->tableCalls, $key, 0, $moreCalls);
290                        $key += $moreCallsLength;
291                    }
292
293                    if ($this->countTableHeadRows == $lastRow) {
294                        array_splice($this->tableCalls, $key + 1, 0, [['tablethead_close', [], $call[2]]]);
295                    }
296                    break;
297            }
298        }
299
300        // condense cdata
301        $cnt = count($this->tableCalls);
302        for ($key = 0; $key < $cnt; $key++) {
303            if ($this->tableCalls[$key][0] == 'cdata') {
304                $ckey = $key;
305                $key++;
306                while ($this->tableCalls[$key][0] == 'cdata') {
307                    $this->tableCalls[$ckey][1][0] .= $this->tableCalls[$key][1][0];
308                    $toDelete[] = $key;
309                    $key++;
310                }
311                continue;
312            }
313        }
314
315        foreach ($toDelete as $delete) {
316            unset($this->tableCalls[$delete]);
317        }
318        $this->tableCalls = array_values($this->tableCalls);
319    }
320}
321