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