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