1<?php 2 3declare(strict_types=1); 4 5/* 6 * This is part of the league/commonmark package. 7 * 8 * (c) Martin Hasoň <martin.hason@gmail.com> 9 * (c) Webuni s.r.o. <info@webuni.cz> 10 * (c) Colin O'Dell <colinodell@gmail.com> 11 * 12 * For the full copyright and license information, please view the LICENSE 13 * file that was distributed with this source code. 14 */ 15 16namespace League\CommonMark\Extension\Table; 17 18use League\CommonMark\Block\Element\Document; 19use League\CommonMark\Block\Element\Paragraph; 20use League\CommonMark\Block\Parser\BlockParserInterface; 21use League\CommonMark\Context; 22use League\CommonMark\ContextInterface; 23use League\CommonMark\Cursor; 24use League\CommonMark\EnvironmentAwareInterface; 25use League\CommonMark\EnvironmentInterface; 26 27final class TableParser implements BlockParserInterface, EnvironmentAwareInterface 28{ 29 /** 30 * @var EnvironmentInterface 31 */ 32 private $environment; 33 34 public function parse(ContextInterface $context, Cursor $cursor): bool 35 { 36 $container = $context->getContainer(); 37 if (!$container instanceof Paragraph) { 38 return false; 39 } 40 41 $lines = $container->getStrings(); 42 if (count($lines) === 0) { 43 return false; 44 } 45 46 $lastLine = \array_pop($lines); 47 if (\strpos($lastLine, '|') === false) { 48 return false; 49 } 50 51 $oldState = $cursor->saveState(); 52 $cursor->advanceToNextNonSpaceOrTab(); 53 $columns = $this->parseColumns($cursor); 54 55 if (empty($columns)) { 56 $cursor->restoreState($oldState); 57 58 return false; 59 } 60 61 $head = $this->parseRow(trim((string) $lastLine), $columns, TableCell::TYPE_HEAD); 62 if (null === $head) { 63 $cursor->restoreState($oldState); 64 65 return false; 66 } 67 68 $table = new Table(function (Cursor $cursor, Table $table) use ($columns): bool { 69 // The next line cannot be a new block start 70 // This is a bit inefficient, but it's the only feasible way to check 71 // given the current v1 API. 72 if (self::isANewBlock($this->environment, $cursor->getLine())) { 73 return false; 74 } 75 76 $row = $this->parseRow(\trim($cursor->getLine()), $columns); 77 if (null === $row) { 78 return false; 79 } 80 81 $table->getBody()->appendChild($row); 82 83 return true; 84 }); 85 86 $table->getHead()->appendChild($head); 87 88 if (count($lines) >= 1) { 89 $paragraph = new Paragraph(); 90 foreach ($lines as $line) { 91 $paragraph->addLine($line); 92 } 93 94 $context->replaceContainerBlock($paragraph); 95 $context->addBlock($table); 96 } else { 97 $context->replaceContainerBlock($table); 98 } 99 100 return true; 101 } 102 103 /** 104 * @param string $line 105 * @param array<int, string> $columns 106 * @param string $type 107 * 108 * @return TableRow|null 109 */ 110 private function parseRow(string $line, array $columns, string $type = TableCell::TYPE_BODY): ?TableRow 111 { 112 $cells = $this->split(new Cursor(\trim($line))); 113 114 if (empty($cells)) { 115 return null; 116 } 117 118 // The header row must match the delimiter row in the number of cells 119 if ($type === TableCell::TYPE_HEAD && \count($cells) !== \count($columns)) { 120 return null; 121 } 122 123 $i = 0; 124 $row = new TableRow(); 125 foreach ($cells as $i => $cell) { 126 if (!array_key_exists($i, $columns)) { 127 return $row; 128 } 129 130 $row->appendChild(new TableCell(trim($cell), $type, $columns[$i])); 131 } 132 133 for ($j = count($columns) - 1; $j > $i; --$j) { 134 $row->appendChild(new TableCell('', $type, null)); 135 } 136 137 return $row; 138 } 139 140 /** 141 * @param Cursor $cursor 142 * 143 * @return array<int, string> 144 */ 145 private function split(Cursor $cursor): array 146 { 147 if ($cursor->getCharacter() === '|') { 148 $cursor->advanceBy(1); 149 } 150 151 $cells = []; 152 $sb = ''; 153 154 while (!$cursor->isAtEnd()) { 155 switch ($c = $cursor->getCharacter()) { 156 case '\\': 157 if ($cursor->peek() === '|') { 158 // Pipe is special for table parsing. An escaped pipe doesn't result in a new cell, but is 159 // passed down to inline parsing as an unescaped pipe. Note that that applies even for the `\|` 160 // in an input like `\\|` - in other words, table parsing doesn't support escaping backslashes. 161 $sb .= '|'; 162 $cursor->advanceBy(1); 163 } else { 164 // Preserve backslash before other characters or at end of line. 165 $sb .= '\\'; 166 } 167 break; 168 case '|': 169 $cells[] = $sb; 170 $sb = ''; 171 break; 172 default: 173 $sb .= $c; 174 } 175 $cursor->advanceBy(1); 176 } 177 178 if ($sb !== '') { 179 $cells[] = $sb; 180 } 181 182 return $cells; 183 } 184 185 /** 186 * @param Cursor $cursor 187 * 188 * @return array<int, string> 189 */ 190 private function parseColumns(Cursor $cursor): array 191 { 192 $columns = []; 193 $pipes = 0; 194 $valid = false; 195 196 while (!$cursor->isAtEnd()) { 197 switch ($c = $cursor->getCharacter()) { 198 case '|': 199 $cursor->advanceBy(1); 200 $pipes++; 201 if ($pipes > 1) { 202 // More than one adjacent pipe not allowed 203 return []; 204 } 205 206 // Need at least one pipe, even for a one-column table 207 $valid = true; 208 break; 209 case '-': 210 case ':': 211 if ($pipes === 0 && !empty($columns)) { 212 // Need a pipe after the first column (first column doesn't need to start with one) 213 return []; 214 } 215 $left = false; 216 $right = false; 217 if ($c === ':') { 218 $left = true; 219 $cursor->advanceBy(1); 220 } 221 if ($cursor->match('/^-+/') === null) { 222 // Need at least one dash 223 return []; 224 } 225 if ($cursor->getCharacter() === ':') { 226 $right = true; 227 $cursor->advanceBy(1); 228 } 229 $columns[] = $this->getAlignment($left, $right); 230 // Next, need another pipe 231 $pipes = 0; 232 break; 233 case ' ': 234 case "\t": 235 // White space is allowed between pipes and columns 236 $cursor->advanceToNextNonSpaceOrTab(); 237 break; 238 default: 239 // Any other character is invalid 240 return []; 241 } 242 } 243 244 if (!$valid) { 245 return []; 246 } 247 248 return $columns; 249 } 250 251 private static function getAlignment(bool $left, bool $right): ?string 252 { 253 if ($left && $right) { 254 return TableCell::ALIGN_CENTER; 255 } elseif ($left) { 256 return TableCell::ALIGN_LEFT; 257 } elseif ($right) { 258 return TableCell::ALIGN_RIGHT; 259 } 260 261 return null; 262 } 263 264 public function setEnvironment(EnvironmentInterface $environment) 265 { 266 $this->environment = $environment; 267 } 268 269 private static function isANewBlock(EnvironmentInterface $environment, string $line): bool 270 { 271 $context = new Context(new Document(), $environment); 272 $context->setNextLine($line); 273 $cursor = new Cursor($line); 274 275 /** @var BlockParserInterface $parser */ 276 foreach ($environment->getBlockParsers() as $parser) { 277 if ($parser->parse($context, $cursor)) { 278 return true; 279 } 280 } 281 282 return false; 283 } 284} 285