1 <?php
2 
3 namespace dokuwiki\Parsing\Handler;
4 
5 class 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