diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index 86c60e6..05538b5 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -2848,6 +2848,14 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int } } + // Leading colspan markers (`<` with no cell to their left) cannot + // merge: each becomes an empty cell rather than being dropped + // (djot-js / carve parity). + while ($colspanAccumulator > 1) { + array_unshift($processedCells, ['content' => '', 'attributes' => [], 'colspan' => 1]); + $colspanAccumulator--; + } + // Parse regular row $row = new TableRow(false); if ($rowAttributes) { @@ -2859,11 +2867,51 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int $rowCellData = []; $colPosition = 0; + // getChildren() hands back a copy-on-write alias of the table's internal child + // array. Holding it alive across the appendChild() below would force PHP to copy + // the entire array on every row (turning a plain table into O(rows^2)), so it is + // released with unset() right before the append - see the note there. + $tableChildren = $table->getChildren(); + $currentRowIndex = count($tableChildren); // Index where current row will be added + foreach ($processedCells as $index => $cellData) { $colspan = $cellData['colspan']; // Check for rowspan marker if ($this->tableParser->isRowspanMarker($cellData['content'])) { + // A rowspan marker with no cell above to extend (first row, or + // a column with no origin) cannot merge: it becomes an empty + // cell rather than being dropped (djot-js / carve parity). + $cellAbove = null; + for ($prevRowIdx = $currentRowIndex - 1; $prevRowIdx >= 0; $prevRowIdx--) { + if (!($tableChildren[$prevRowIdx] instanceof TableRow)) { + continue; + } + $cellAbove = $this->findCellAtColumnForRowspan( + $tableChildren, + $prevRowIdx, + $colPosition, + $currentRowIndex, + ); + if ($cellAbove !== null) { + break; + } + } + + if ($cellAbove === null) { + $alignment = $alignments[$index] ?? TableCell::ALIGN_DEFAULT; + $cell = new TableCell(false, $alignment, 1, $colspan); + $row->appendChild($cell); + $rowCellData[] = [ + 'type' => 'cell', + 'cell' => $cell, + 'colPosition' => $colPosition, + ]; + $colPosition += $colspan; + + continue; + } + // Mark this position for rowspan processing $rowCellData[] = [ 'type' => 'rowspan_marker', @@ -2894,12 +2942,8 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int // Process rowspan markers - find cells above that should span down // We need to track column positions considering rowspan markers in previous rows. - // getChildren() hands back a copy-on-write alias of the table's internal child - // array. Holding it alive across the appendChild() below would force PHP to copy - // the entire array on every row (turning a plain table into O(rows^2)), so it is - // released with unset() right before the append - see the note there. - $tableChildren = $table->getChildren(); - $currentRowIndex = count($tableChildren); // Index where current row will be added + // $tableChildren / $currentRowIndex were captured before the cell loop above + // (the table's child array is unchanged until the appendChild() below). // Track which cells have already been extended in this row // (multiple ^ markers under a colspan should only extend once) diff --git a/tests/TestCase/TableSpansTest.php b/tests/TestCase/TableSpansTest.php index a12d404..20890cb 100644 --- a/tests/TestCase/TableSpansTest.php +++ b/tests/TestCase/TableSpansTest.php @@ -701,4 +701,64 @@ public function testExplicitMarkersFor2x2Block(): void $cells = $dataRow2->getChildren(); $this->assertCount(1, $cells); // Only "L2" cell } + + public function testLeadingColspanMarkerInFirstColumnIsEmptyCell(): void + { + // A colspan marker `<` in the first column has no cell to its left to + // merge into: it must render as an empty cell, not be dropped. + $djot = <<<'DJOT' +| < | a | +|---|---| +| b | c | +DJOT; + + $doc = $this->converter->parse($djot); + + /** @var \Djot\Node\Block\Table $table */ + $table = $doc->getChildren()[0]; + $this->assertInstanceOf(Table::class, $table); + + $rows = $table->getChildren(); + + /** @var \Djot\Node\Block\TableRow $headerRow */ + $headerRow = $rows[0]; + $headerCells = $headerRow->getChildren(); + $this->assertCount(2, $headerCells); + + /** @var \Djot\Node\Block\TableCell $leadingCell */ + $leadingCell = $headerCells[0]; + $this->assertSame(1, $leadingCell->getColspan()); + $this->assertSame(1, $leadingCell->getRowspan()); + $this->assertCount(0, $leadingCell->getChildren()); + } + + public function testLeadingRowspanMarkerInFirstRowIsEmptyCell(): void + { + // A rowspan marker `^` in the first row has no cell above to merge + // into: it must render as an empty cell, not be dropped. + $djot = <<<'DJOT' +| ^ | a | +|---|---| +| b | c | +DJOT; + + $doc = $this->converter->parse($djot); + + /** @var \Djot\Node\Block\Table $table */ + $table = $doc->getChildren()[0]; + $this->assertInstanceOf(Table::class, $table); + + $rows = $table->getChildren(); + + /** @var \Djot\Node\Block\TableRow $headerRow */ + $headerRow = $rows[0]; + $headerCells = $headerRow->getChildren(); + $this->assertCount(2, $headerCells); + + /** @var \Djot\Node\Block\TableCell $leadingCell */ + $leadingCell = $headerCells[0]; + $this->assertSame(1, $leadingCell->getColspan()); + $this->assertSame(1, $leadingCell->getRowspan()); + $this->assertCount(0, $leadingCell->getChildren()); + } }