Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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',
Expand Down Expand Up @@ -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)
Expand Down
60 changes: 60 additions & 0 deletions tests/TestCase/TableSpansTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Loading