Skip to content

Commit 306e362

Browse files
authored
Render leading span marker with nothing to merge as an empty cell (#245)
A colspan marker (`<`) in the first column has no cell to its left, and a rowspan marker (`^`) in the first row has no cell above. Such a marker has nothing to merge into, so it was silently dropped, leaving the row one cell short. It now renders as an empty cell instead, matching djot-js / carve parity. Leftover colspan accumulation after the reverse merge loop is flushed into empty leading cells. A rowspan marker that finds no originating cell above becomes an empty cell rather than a dangling marker.
1 parent 30088b9 commit 306e362

2 files changed

Lines changed: 110 additions & 6 deletions

File tree

src/Parser/BlockParser.php

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2848,6 +2848,14 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
28482848
}
28492849
}
28502850

2851+
// Leading colspan markers (`<` with no cell to their left) cannot
2852+
// merge: each becomes an empty cell rather than being dropped
2853+
// (djot-js / carve parity).
2854+
while ($colspanAccumulator > 1) {
2855+
array_unshift($processedCells, ['content' => '', 'attributes' => [], 'colspan' => 1]);
2856+
$colspanAccumulator--;
2857+
}
2858+
28512859
// Parse regular row
28522860
$row = new TableRow(false);
28532861
if ($rowAttributes) {
@@ -2859,11 +2867,51 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
28592867
$rowCellData = [];
28602868
$colPosition = 0;
28612869

2870+
// getChildren() hands back a copy-on-write alias of the table's internal child
2871+
// array. Holding it alive across the appendChild() below would force PHP to copy
2872+
// the entire array on every row (turning a plain table into O(rows^2)), so it is
2873+
// released with unset() right before the append - see the note there.
2874+
$tableChildren = $table->getChildren();
2875+
$currentRowIndex = count($tableChildren); // Index where current row will be added
2876+
28622877
foreach ($processedCells as $index => $cellData) {
28632878
$colspan = $cellData['colspan'];
28642879

28652880
// Check for rowspan marker
28662881
if ($this->tableParser->isRowspanMarker($cellData['content'])) {
2882+
// A rowspan marker with no cell above to extend (first row, or
2883+
// a column with no origin) cannot merge: it becomes an empty
2884+
// cell rather than being dropped (djot-js / carve parity).
2885+
$cellAbove = null;
2886+
for ($prevRowIdx = $currentRowIndex - 1; $prevRowIdx >= 0; $prevRowIdx--) {
2887+
if (!($tableChildren[$prevRowIdx] instanceof TableRow)) {
2888+
continue;
2889+
}
2890+
$cellAbove = $this->findCellAtColumnForRowspan(
2891+
$tableChildren,
2892+
$prevRowIdx,
2893+
$colPosition,
2894+
$currentRowIndex,
2895+
);
2896+
if ($cellAbove !== null) {
2897+
break;
2898+
}
2899+
}
2900+
2901+
if ($cellAbove === null) {
2902+
$alignment = $alignments[$index] ?? TableCell::ALIGN_DEFAULT;
2903+
$cell = new TableCell(false, $alignment, 1, $colspan);
2904+
$row->appendChild($cell);
2905+
$rowCellData[] = [
2906+
'type' => 'cell',
2907+
'cell' => $cell,
2908+
'colPosition' => $colPosition,
2909+
];
2910+
$colPosition += $colspan;
2911+
2912+
continue;
2913+
}
2914+
28672915
// Mark this position for rowspan processing
28682916
$rowCellData[] = [
28692917
'type' => 'rowspan_marker',
@@ -2894,12 +2942,8 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
28942942

28952943
// Process rowspan markers - find cells above that should span down
28962944
// We need to track column positions considering rowspan markers in previous rows.
2897-
// getChildren() hands back a copy-on-write alias of the table's internal child
2898-
// array. Holding it alive across the appendChild() below would force PHP to copy
2899-
// the entire array on every row (turning a plain table into O(rows^2)), so it is
2900-
// released with unset() right before the append - see the note there.
2901-
$tableChildren = $table->getChildren();
2902-
$currentRowIndex = count($tableChildren); // Index where current row will be added
2945+
// $tableChildren / $currentRowIndex were captured before the cell loop above
2946+
// (the table's child array is unchanged until the appendChild() below).
29032947

29042948
// Track which cells have already been extended in this row
29052949
// (multiple ^ markers under a colspan should only extend once)

tests/TestCase/TableSpansTest.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -701,4 +701,64 @@ public function testExplicitMarkersFor2x2Block(): void
701701
$cells = $dataRow2->getChildren();
702702
$this->assertCount(1, $cells); // Only "L2" cell
703703
}
704+
705+
public function testLeadingColspanMarkerInFirstColumnIsEmptyCell(): void
706+
{
707+
// A colspan marker `<` in the first column has no cell to its left to
708+
// merge into: it must render as an empty cell, not be dropped.
709+
$djot = <<<'DJOT'
710+
| < | a |
711+
|---|---|
712+
| b | c |
713+
DJOT;
714+
715+
$doc = $this->converter->parse($djot);
716+
717+
/** @var \Djot\Node\Block\Table $table */
718+
$table = $doc->getChildren()[0];
719+
$this->assertInstanceOf(Table::class, $table);
720+
721+
$rows = $table->getChildren();
722+
723+
/** @var \Djot\Node\Block\TableRow $headerRow */
724+
$headerRow = $rows[0];
725+
$headerCells = $headerRow->getChildren();
726+
$this->assertCount(2, $headerCells);
727+
728+
/** @var \Djot\Node\Block\TableCell $leadingCell */
729+
$leadingCell = $headerCells[0];
730+
$this->assertSame(1, $leadingCell->getColspan());
731+
$this->assertSame(1, $leadingCell->getRowspan());
732+
$this->assertCount(0, $leadingCell->getChildren());
733+
}
734+
735+
public function testLeadingRowspanMarkerInFirstRowIsEmptyCell(): void
736+
{
737+
// A rowspan marker `^` in the first row has no cell above to merge
738+
// into: it must render as an empty cell, not be dropped.
739+
$djot = <<<'DJOT'
740+
| ^ | a |
741+
|---|---|
742+
| b | c |
743+
DJOT;
744+
745+
$doc = $this->converter->parse($djot);
746+
747+
/** @var \Djot\Node\Block\Table $table */
748+
$table = $doc->getChildren()[0];
749+
$this->assertInstanceOf(Table::class, $table);
750+
751+
$rows = $table->getChildren();
752+
753+
/** @var \Djot\Node\Block\TableRow $headerRow */
754+
$headerRow = $rows[0];
755+
$headerCells = $headerRow->getChildren();
756+
$this->assertCount(2, $headerCells);
757+
758+
/** @var \Djot\Node\Block\TableCell $leadingCell */
759+
$leadingCell = $headerCells[0];
760+
$this->assertSame(1, $leadingCell->getColspan());
761+
$this->assertSame(1, $leadingCell->getRowspan());
762+
$this->assertCount(0, $leadingCell->getChildren());
763+
}
704764
}

0 commit comments

Comments
 (0)