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 + + + +
NameContact Info
Alicealice@ex.com555-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 + + + + + +
CategoryItem
FruitsApple
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 +RegionQ1Q2 +UnitsRevenueUnitsRevenue +North100$500150$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('', $result); + $this->assertStringContainsString('', $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('', $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('', $result); + $this->assertStringContainsString('', $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");
NameContact InfoFruitsRegionQ1