diff --git a/docs/cookbook.md b/docs/cookbook.md
index 5a5963a..1074c0b 100644
--- a/docs/cookbook.md
+++ b/docs/cookbook.md
@@ -21,6 +21,7 @@ Common recipes and customizations for djot-php.
- [Working with the AST](#working-with-the-ast)
- [Custom Inline Patterns](#custom-inline-patterns)
- [Custom Block Patterns](#custom-block-patterns)
+- [Tables with Spanning](#tables-with-spanning)
- [Alternative Output Formats](#alternative-output-formats)
- [Soft Break Modes](#soft-break-modes)
- [Significant Newlines Mode](#significant-newlines-mode)
@@ -1523,6 +1524,97 @@ DJOT;
echo $converter->convert($djot);
```
+## Tables with Spanning
+
+djot-php extends standard djot with support for table cell spanning (colspan and rowspan).
+
+### Column Spanning (Colspan)
+
+Use `||` (empty cells) after content to span multiple columns:
+
+```djot
+|= Name |= Contact Info ||
+| Alice | alice@ex.com | 555-1234 |
+```
+
+Output:
+```html
+
+| Name | Contact Info |
+| Alice | alice@ex.com | 555-1234 |
+
+```
+
+Multiple empty cells create larger spans:
+
+```djot
+| Title spanning three columns |||
+| A | B | C |
+```
+
+Renders as `Title spanning three columns | `.
+
+**Note:** `| |` (with space) creates an empty cell, while `||` (no space) creates a colspan.
+
+### Row Spanning (Rowspan)
+
+Use `|^|` to continue a cell from the row above:
+
+```djot
+|= Category |= Item |
+| Fruits | Apple |
+|^ | Banana |
+|^ | Cherry |
+```
+
+Output:
+```html
+
+| Category | Item |
+| Fruits | Apple |
+| Banana |
+| Cherry |
+
+```
+
+### Header Row Spanning
+
+Use `|=^` to create header cells that span rows:
+
+```djot
+|= Region |= Q1 ||= Q2 ||
+|=^ |= Units |= Revenue |= Units |= Revenue |
+| North | 100 | $500 | 150 | $750 |
+```
+
+Output:
+```html
+| Region | Q1 | Q2 |
+| Units | Revenue | Units | Revenue |
+| North | 100 | $500 | 150 | $750 |
+```
+
+### Combined Spanning
+
+Colspan and rowspan can be used together for complex tables:
+
+```djot
+|= Product Report ||||
+|= Category |= Q1 || Q2 ||
+|=^ |= A |= B |= A |= B |
+| Widgets | 10 | 20 | 15 | 25 |
+```
+
+### Compatibility Notes
+
+These spanning features are **djot-php extensions** and not part of the official djot specification:
+
+- Standard djot tables work unchanged
+- `| |` (space) creates empty cells (standard behavior)
+- `||` (no space) triggers colspan (extension)
+- `|^|` triggers rowspan (extension)
+- Content starting with `^` like `| ^text |` is treated as normal content
+
## Alternative Output Formats
For detailed customization of alternative renderers, see:
diff --git a/src/Node/Block/TableCell.php b/src/Node/Block/TableCell.php
index 67e623d..7fdb055 100644
--- a/src/Node/Block/TableCell.php
+++ b/src/Node/Block/TableCell.php
@@ -32,6 +32,8 @@ class TableCell extends BlockNode
public function __construct(
protected bool $isHeader = false,
protected string $alignment = self::ALIGN_DEFAULT,
+ protected int $colspan = 1,
+ protected int $rowspan = 1,
) {
}
@@ -45,6 +47,26 @@ public function getAlignment(): string
return $this->alignment;
}
+ public function getColspan(): int
+ {
+ return $this->colspan;
+ }
+
+ public function setColspan(int $colspan): void
+ {
+ $this->colspan = $colspan;
+ }
+
+ public function getRowspan(): int
+ {
+ return $this->rowspan;
+ }
+
+ public function setRowspan(int $rowspan): void
+ {
+ $this->rowspan = $rowspan;
+ }
+
public function getType(): string
{
return 'table_cell';
diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php
index fad6f6c..77c9d77 100644
--- a/src/Parser/BlockParser.php
+++ b/src/Parser/BlockParser.php
@@ -2313,6 +2313,8 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
$count = count($lines);
$alignments = [];
$headerFound = false;
+ // Track rowspan state: colIndex => ['cell' => TableCell, 'remaining' => int]
+ $rowspanState = [];
while ($i < $count) {
$currentLine = $lines[$i];
@@ -2356,16 +2358,142 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
}
// Parse regular row
- $row = new TableRow(false);
$cells = $this->parseTableCells($currentLine);
+ $rowHasHeaderCell = false;
+ $parsedCells = [];
+ $colIndex = 0;
foreach ($cells as $index => $cellContent) {
- $alignment = $alignments[$index] ?? TableCell::ALIGN_DEFAULT;
- $cell = new TableCell(false, $alignment);
- $this->inlineParser->parse($cell, trim($cellContent), $i);
+ $trimmed = trim($cellContent);
+ $isHeader = false;
+ $cellAlignment = $alignments[$colIndex] ?? TableCell::ALIGN_DEFAULT;
+
+ // Check for colspan: empty cell (no content at all, not even whitespace)
+ // || creates an empty string, | | creates a space
+ // Only treat as colspan if there's a previous cell to extend
+ if ($cellContent === '' && $parsedCells) {
+ // Colspan - merge with previous cell
+ $lastIndex = count($parsedCells) - 1;
+ $parsedCells[$lastIndex]['colspan'] = (int)$parsedCells[$lastIndex]['colspan'] + 1;
+ $colIndex++;
+
+ continue;
+ }
+
+ // Check for rowspan: cell contains only ^ and must be attached to pipe
+ // |^| means "continue from cell above"
+ // | ^ | is literal content "^", not a rowspan marker
+ if ($trimmed === '^' && str_starts_with($cellContent, '^')) {
+ $parsedCells[] = [
+ 'content' => '',
+ 'isHeader' => false,
+ 'alignment' => $cellAlignment,
+ 'colspan' => 1,
+ 'isRowspanMarker' => true,
+ 'colIndex' => $colIndex,
+ ];
+ $colIndex++;
+
+ continue;
+ }
+
+ // Check for |= header cell syntax (Creole-style)
+ // Supports: |= Header |=< Left |=> Right |=~ Center |=^ (header rowspan)
+ // Must be directly attached to pipe: | = text | is literal, not header
+ if (str_starts_with($cellContent, '=')) {
+ $isHeader = true;
+ $rowHasHeaderCell = true;
+ $afterEquals = substr($cellContent, 1); // Remove =
+
+ // Check for rowspan marker in header: |=^
+ if (str_starts_with($afterEquals, '^') && trim($afterEquals) === '^') {
+ $parsedCells[] = [
+ 'content' => '',
+ 'isHeader' => true,
+ 'alignment' => $cellAlignment,
+ 'colspan' => 1,
+ 'isRowspanMarker' => true,
+ 'colIndex' => $colIndex,
+ ];
+ $colIndex++;
+
+ continue;
+ }
+
+ // Check for alignment marker after = (must be directly attached: |=< not |= <)
+ // This sets column alignment if no separator row defined it
+ if (str_starts_with($afterEquals, '<')) {
+ $cellAlignment = TableCell::ALIGN_LEFT;
+ $afterEquals = substr($afterEquals, 1);
+ if (!isset($alignments[$colIndex])) {
+ $alignments[$colIndex] = TableCell::ALIGN_LEFT;
+ }
+ } elseif (str_starts_with($afterEquals, '>')) {
+ $cellAlignment = TableCell::ALIGN_RIGHT;
+ $afterEquals = substr($afterEquals, 1);
+ if (!isset($alignments[$colIndex])) {
+ $alignments[$colIndex] = TableCell::ALIGN_RIGHT;
+ }
+ } elseif (str_starts_with($afterEquals, '~')) {
+ $cellAlignment = TableCell::ALIGN_CENTER;
+ $afterEquals = substr($afterEquals, 1);
+ if (!isset($alignments[$colIndex])) {
+ $alignments[$colIndex] = TableCell::ALIGN_CENTER;
+ }
+ }
+
+ $cellContent = $afterEquals;
+ }
+
+ $parsedCells[] = [
+ 'content' => trim($cellContent),
+ 'isHeader' => $isHeader,
+ 'alignment' => $cellAlignment,
+ 'colspan' => 1,
+ 'isRowspanMarker' => false,
+ 'colIndex' => $colIndex,
+ ];
+ $colIndex++;
+ }
+
+ // Create the row (header row if any cell has |= syntax)
+ $row = new TableRow($rowHasHeaderCell);
+ $newRowspanState = [];
+
+ foreach ($parsedCells as $cellData) {
+ /** @var int<0, max> $colIdx */
+ $colIdx = $cellData['colIndex'];
+ /** @var int<1, max> $colSpan */
+ $colSpan = $cellData['colspan'];
+
+ if ($cellData['isRowspanMarker']) {
+ // Find cell that owns this column and increment its rowspan
+ if (isset($rowspanState[$colIdx])) {
+ // Column is covered by an active rowspan - extend it
+ $rowspanState[$colIdx]['cell']->setRowspan(
+ $rowspanState[$colIdx]['cell']->getRowspan() + 1,
+ );
+ // Keep tracking this cell for next row
+ $newRowspanState[$colIdx] = $rowspanState[$colIdx];
+ }
+ // Don't create a cell for rowspan markers
+
+ continue;
+ }
+
+ $cell = new TableCell($cellData['isHeader'], $cellData['alignment'], $colSpan);
+ $this->inlineParser->parse($cell, $cellData['content'], $i);
$row->appendChild($cell);
+
+ // Track this cell for potential rowspan extension in next row
+ for ($c = $colIdx; $c < $colIdx + $colSpan; $c++) {
+ $newRowspanState[$c] = ['cell' => $cell];
+ }
}
+ // Replace rowspan state with new state for next row
+ $rowspanState = $newRowspanState;
+
$table->appendChild($row);
$i++;
}
diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php
index 4af2387..361f2a3 100644
--- a/src/Renderer/HtmlRenderer.php
+++ b/src/Renderer/HtmlRenderer.php
@@ -647,6 +647,13 @@ protected function renderTableCell(TableCell $node): string
$tag = $node->isHeader() ? 'th' : 'td';
$attrs = $this->renderAttributes($node);
+ if ($node->getColspan() > 1) {
+ $attrs .= ' colspan="' . $node->getColspan() . '"';
+ }
+ if ($node->getRowspan() > 1) {
+ $attrs .= ' rowspan="' . $node->getRowspan() . '"';
+ }
+
$alignment = $node->getAlignment();
if ($alignment !== TableCell::ALIGN_DEFAULT) {
$attrs .= ' style="text-align: ' . $alignment . ';"';
diff --git a/tests/TestCase/DjotConverterTest.php b/tests/TestCase/DjotConverterTest.php
index 1ff0bb3..69abb51 100644
--- a/tests/TestCase/DjotConverterTest.php
+++ b/tests/TestCase/DjotConverterTest.php
@@ -1669,6 +1669,39 @@ public function testTableWithMismatchedColumns(): void
$this->assertStringContainsString('', $result);
}
+ public function testTableColspanRendering(): void
+ {
+ // || creates colspan
+ $djot = "|= Name |= Contact Info ||\n| Alice | alice@ex.com | 555-1234 |";
+ $result = $this->converter->convert($djot);
+
+ $this->assertStringContainsString('colspan="2"', $result);
+ $this->assertStringContainsString('| Name | ', $result);
+ $this->assertStringContainsString('Contact Info | ', $result);
+ }
+
+ public function testTableRowspanRendering(): void
+ {
+ // |^| creates rowspan
+ $djot = "| Fruits | Apple |\n|^ | Banana |\n|^ | Cherry |";
+ $result = $this->converter->convert($djot);
+
+ $this->assertStringContainsString('rowspan="3"', $result);
+ $this->assertStringContainsString('Fruits | ', $result);
+ }
+
+ public function testTableCombinedSpanning(): void
+ {
+ // Complex table with both colspan and rowspan
+ $djot = "|= Region |= Q1 ||\n|=^ |= A |= B |\n| North | 1 | 2 |";
+ $result = $this->converter->convert($djot);
+
+ $this->assertStringContainsString('rowspan="2"', $result);
+ $this->assertStringContainsString('colspan="2"', $result);
+ $this->assertStringContainsString('Region | ', $result);
+ $this->assertStringContainsString('Q1 | ', $result);
+ }
+
// Edge cases: Code blocks
public function testCodeBlockWithLongerClosingFence(): void
diff --git a/tests/TestCase/Parser/BlockParserTest.php b/tests/TestCase/Parser/BlockParserTest.php
index 1aeedda..add5624 100644
--- a/tests/TestCase/Parser/BlockParserTest.php
+++ b/tests/TestCase/Parser/BlockParserTest.php
@@ -12,8 +12,11 @@
use Djot\Node\Block\ListBlock;
use Djot\Node\Block\Paragraph;
use Djot\Node\Block\Table;
+use Djot\Node\Block\TableCell;
+use Djot\Node\Block\TableRow;
use Djot\Node\Block\ThematicBreak;
use Djot\Node\Document;
+use Djot\Node\Inline\Text;
use Djot\Parser\BlockParser;
use PHPUnit\Framework\TestCase;
use function str_contains;
@@ -204,6 +207,331 @@ public function testParseTable(): void
$this->assertInstanceOf(Table::class, $doc->getChildren()[0]);
}
+ public function testParseTableWithEqualsHeaderSyntax(): void
+ {
+ // Creole-style |= header syntax (no separator row needed)
+ $doc = $this->parser->parse("|= Name |= Age |\n| Alice | 28 |");
+
+ $this->assertCount(1, $doc->getChildren());
+ $table = $doc->getChildren()[0];
+ $this->assertInstanceOf(Table::class, $table);
+
+ $rows = $table->getChildren();
+ $this->assertCount(2, $rows);
+
+ // First row should be a header row
+ $headerRow = $rows[0];
+ $this->assertInstanceOf(TableRow::class, $headerRow);
+ $this->assertTrue($headerRow->isHeader());
+
+ // Header cells should be marked as headers
+ $headerCells = $headerRow->getChildren();
+ $this->assertCount(2, $headerCells);
+ $this->assertInstanceOf(TableCell::class, $headerCells[0]);
+ $this->assertTrue($headerCells[0]->isHeader());
+ $this->assertTrue($headerCells[1]->isHeader());
+
+ // Second row should be a data row
+ $dataRow = $rows[1];
+ $this->assertInstanceOf(TableRow::class, $dataRow);
+ $this->assertFalse($dataRow->isHeader());
+ }
+
+ public function testParseTableWithEqualsHeaderAlignment(): void
+ {
+ // |=< left, |=> right, |=~ center
+ $doc = $this->parser->parse("|=< Left |=> Right |=~ Center |\n| A | B | C |");
+
+ $table = $doc->getChildren()[0];
+ $this->assertInstanceOf(Table::class, $table);
+
+ $headerRow = $table->getChildren()[0];
+ $cells = $headerRow->getChildren();
+
+ $this->assertSame(TableCell::ALIGN_LEFT, $cells[0]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_RIGHT, $cells[1]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_CENTER, $cells[2]->getAlignment());
+ }
+
+ public function testParseTableWithMixedHeaderCells(): void
+ {
+ // Mix of header and regular cells in a row
+ $doc = $this->parser->parse("|= Header | Regular |\n| Data | Data |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // Row with any header cell is marked as header row
+ $firstRow = $rows[0];
+ $this->assertTrue($firstRow->isHeader());
+
+ $cells = $firstRow->getChildren();
+ $this->assertTrue($cells[0]->isHeader());
+ $this->assertFalse($cells[1]->isHeader());
+ }
+
+ public function testParseTableWithEqualsHeaderNoSeparatorNeeded(): void
+ {
+ // Unlike traditional tables, |= syntax doesn't need separator row
+ $doc = $this->parser->parse("|= A |= B |\n| 1 | 2 |\n| 3 | 4 |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // Should have 3 rows (1 header + 2 data), no separator consumed
+ $this->assertCount(3, $rows);
+ $this->assertTrue($rows[0]->isHeader());
+ $this->assertFalse($rows[1]->isHeader());
+ $this->assertFalse($rows[2]->isHeader());
+ }
+
+ public function testParseTableWithEqualsHeaderAlignmentPropagates(): void
+ {
+ // Header alignment should propagate to data cells when no separator row
+ $doc = $this->parser->parse("|=> Right |=< Left |=~ Center |\n| A | B | C |\n| D | E | F |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // Header row alignments
+ $headerCells = $rows[0]->getChildren();
+ $this->assertSame(TableCell::ALIGN_RIGHT, $headerCells[0]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_LEFT, $headerCells[1]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_CENTER, $headerCells[2]->getAlignment());
+
+ // Data rows should inherit column alignment from header
+ $dataCells1 = $rows[1]->getChildren();
+ $this->assertSame(TableCell::ALIGN_RIGHT, $dataCells1[0]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_LEFT, $dataCells1[1]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_CENTER, $dataCells1[2]->getAlignment());
+
+ $dataCells2 = $rows[2]->getChildren();
+ $this->assertSame(TableCell::ALIGN_RIGHT, $dataCells2[0]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_LEFT, $dataCells2[1]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_CENTER, $dataCells2[2]->getAlignment());
+ }
+
+ public function testParseTableSeparatorRowOverridesHeaderAlignment(): void
+ {
+ // Separator row alignment takes precedence over header |= alignment
+ $doc = $this->parser->parse("|=> Right |=< Left |\n|:--------|------:|\n| A | B |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // Header cells get alignment from separator row, not from |= markers
+ $headerCells = $rows[0]->getChildren();
+ $this->assertSame(TableCell::ALIGN_LEFT, $headerCells[0]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_RIGHT, $headerCells[1]->getAlignment());
+
+ // Data row also uses separator row alignment
+ $dataCells = $rows[1]->getChildren();
+ $this->assertSame(TableCell::ALIGN_LEFT, $dataCells[0]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_RIGHT, $dataCells[1]->getAlignment());
+ }
+
+ public function testParseTableColspan(): void
+ {
+ // || creates colspan - empty cell merges with previous
+ $doc = $this->parser->parse("|= Name |= Contact Info ||\n| Alice | alice@ex.com | 555-1234 |");
+
+ $table = $doc->getChildren()[0];
+ $this->assertInstanceOf(Table::class, $table);
+
+ $rows = $table->getChildren();
+ $headerCells = $rows[0]->getChildren();
+
+ // "Contact Info" should have colspan=2
+ $this->assertCount(2, $headerCells);
+ $this->assertSame(1, $headerCells[0]->getColspan());
+ $this->assertSame(2, $headerCells[1]->getColspan());
+ }
+
+ public function testParseTableColspanMultiple(): void
+ {
+ // ||| creates colspan=3
+ $doc = $this->parser->parse("| Spans three |||\n| A | B | C |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+ $firstRowCells = $rows[0]->getChildren();
+
+ $this->assertCount(1, $firstRowCells);
+ $this->assertSame(3, $firstRowCells[0]->getColspan());
+ }
+
+ public function testParseTableRowspan(): void
+ {
+ // |^| creates rowspan - cell continues from above
+ $doc = $this->parser->parse("| Fruits | Apple |\n|^ | Banana |\n|^ | Cherry |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // First row: "Fruits" should have rowspan=3
+ $firstRowCells = $rows[0]->getChildren();
+ $this->assertSame(3, $firstRowCells[0]->getRowspan());
+ $this->assertSame(1, $firstRowCells[1]->getRowspan());
+
+ // Second row should only have one cell (Banana)
+ $this->assertCount(1, $rows[1]->getChildren());
+
+ // Third row should only have one cell (Cherry)
+ $this->assertCount(1, $rows[2]->getChildren());
+ }
+
+ public function testParseTableRowspanWithHeader(): void
+ {
+ // |=^ creates header rowspan
+ $doc = $this->parser->parse("|= Region |= Q1 ||\n|=^ |= A |= B |\n| North | 1 | 2 |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // "Region" header should have rowspan=2
+ $firstRowCells = $rows[0]->getChildren();
+ $this->assertSame(2, $firstRowCells[0]->getRowspan());
+ $this->assertTrue($firstRowCells[0]->isHeader());
+
+ // "Q1" should have colspan=2
+ $this->assertSame(2, $firstRowCells[1]->getColspan());
+ }
+
+ public function testParseTableEmptyCellNotColspan(): void
+ {
+ // | | (with space) is an empty cell, not colspan
+ $doc = $this->parser->parse("| A | |\n| B | C |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+ $firstRowCells = $rows[0]->getChildren();
+
+ // Should have 2 cells, both with colspan=1
+ $this->assertCount(2, $firstRowCells);
+ $this->assertSame(1, $firstRowCells[0]->getColspan());
+ $this->assertSame(1, $firstRowCells[1]->getColspan());
+ }
+
+ public function testParseTableCaretAsContent(): void
+ {
+ // ^text is regular content, not rowspan marker
+ $doc = $this->parser->parse('| ^caret | text |');
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+ $cells = $rows[0]->getChildren();
+
+ // Should have 2 cells, first contains "^caret"
+ $this->assertCount(2, $cells);
+ $firstCellContent = '';
+ foreach ($cells[0]->getChildren() as $child) {
+ if ($child instanceof Text) {
+ $firstCellContent .= $child->getContent();
+ }
+ }
+ $this->assertSame('^caret', $firstCellContent);
+ }
+
+ public function testParseTableMarkersRequireAttachment(): void
+ {
+ // Markers with leading space should be treated as literal content
+ // | ^ | should be literal "^", not rowspan
+ $doc = $this->parser->parse("| A |\n|---|\n| 1 |\n| ^ |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // Should have 3 rows (header + 2 data rows), no rowspan
+ $this->assertCount(3, $rows);
+ $lastRowCells = $rows[2]->getChildren();
+ $this->assertCount(1, $lastRowCells);
+ $this->assertSame(1, $lastRowCells[0]->getRowspan());
+
+ // Content should be "^"
+ $cellContent = '';
+ foreach ($lastRowCells[0]->getChildren() as $child) {
+ if ($child instanceof Text) {
+ $cellContent .= $child->getContent();
+ }
+ }
+ $this->assertSame('^', $cellContent);
+ }
+
+ public function testParseTableEqualsWithSpaceIsLiteral(): void
+ {
+ // | = text | should be literal "= text", not header
+ $doc = $this->parser->parse('|= Header | = literal |');
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+ $cells = $rows[0]->getChildren();
+
+ $this->assertCount(2, $cells);
+ // First cell is header (|= attached)
+ $this->assertTrue($cells[0]->isHeader());
+ // Second cell is NOT header (| = has space)
+ $this->assertFalse($cells[1]->isHeader());
+
+ // Second cell content should be "= literal"
+ $cellContent = '';
+ foreach ($cells[1]->getChildren() as $child) {
+ if ($child instanceof Text) {
+ $cellContent .= $child->getContent();
+ }
+ }
+ $this->assertSame('= literal', $cellContent);
+ }
+
+ public function testParseTableAlignmentMarkersRequireAttachment(): void
+ {
+ // |=< attached should align, |= < with space should be literal
+ $doc = $this->parser->parse('|=< Left |= < literal |');
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+ $cells = $rows[0]->getChildren();
+
+ $this->assertCount(2, $cells);
+ // First cell has left alignment (|=< attached)
+ $this->assertSame(TableCell::ALIGN_LEFT, $cells[0]->getAlignment());
+ // Second cell has default alignment (|= < has space before <)
+ $this->assertSame(TableCell::ALIGN_DEFAULT, $cells[1]->getAlignment());
+
+ // Second cell content should include the "<"
+ $cellContent = '';
+ foreach ($cells[1]->getChildren() as $child) {
+ if ($child instanceof Text) {
+ $cellContent .= $child->getContent();
+ }
+ }
+ $this->assertSame('< literal', $cellContent);
+ }
+
+ public function testParseTableHeaderRowspanRequiresAttachment(): void
+ {
+ // |=^ attached should be header rowspan, |= ^ with space should be literal
+ $doc = $this->parser->parse("|= Group |= A |\n|=^| 1 |\n|= ^ | 2 |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // First row header cell should have rowspan 2 (from |=^ in second row)
+ $this->assertSame(2, $rows[0]->getChildren()[0]->getRowspan());
+
+ // Third row first cell should be a header with content "^" (|= ^ has space)
+ $thirdRowCells = $rows[2]->getChildren();
+ $this->assertTrue($thirdRowCells[0]->isHeader());
+ $this->assertSame(1, $thirdRowCells[0]->getRowspan());
+
+ $cellContent = '';
+ foreach ($thirdRowCells[0]->getChildren() as $child) {
+ if ($child instanceof Text) {
+ $cellContent .= $child->getContent();
+ }
+ }
+ $this->assertSame('^', $cellContent);
+ }
+
public function testParseBlockAttributes(): void
{
$doc = $this->parser->parse("{.highlight}\n# Heading");