Skip to content
Draft
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
92 changes: 92 additions & 0 deletions docs/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
<table>
<tr><th>Name</th><th colspan="2">Contact Info</th></tr>
<tr><td>Alice</td><td>alice@ex.com</td><td>555-1234</td></tr>
</table>
```

Multiple empty cells create larger spans:

```djot
| Title spanning three columns |||
| A | B | C |
```

Renders as `<td colspan="3">Title spanning three columns</td>`.

**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
<table>
<tr><th>Category</th><th>Item</th></tr>
<tr><td rowspan="3">Fruits</td><td>Apple</td></tr>
<tr><td>Banana</td></tr>
<tr><td>Cherry</td></tr>
</table>
```

### 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
<tr><th rowspan="2">Region</th><th colspan="2">Q1</th><th colspan="2">Q2</th></tr>
<tr><th>Units</th><th>Revenue</th><th>Units</th><th>Revenue</th></tr>
<tr><td>North</td><td>100</td><td>$500</td><td>150</td><td>$750</td></tr>
```

### 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:
Expand Down
22 changes: 22 additions & 0 deletions src/Node/Block/TableCell.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
}

Expand All @@ -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';
Expand Down
136 changes: 132 additions & 4 deletions src/Parser/BlockParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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++;
}
Expand Down
7 changes: 7 additions & 0 deletions src/Renderer/HtmlRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 . ';"';
Expand Down
33 changes: 33 additions & 0 deletions tests/TestCase/DjotConverterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1669,6 +1669,39 @@ public function testTableWithMismatchedColumns(): void
$this->assertStringContainsString('<table>', $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('<th>Name</th>', $result);
$this->assertStringContainsString('<th colspan="2">Contact Info</th>', $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('<td rowspan="3">Fruits</td>', $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('<th rowspan="2">Region</th>', $result);
$this->assertStringContainsString('<th colspan="2">Q1</th>', $result);
}

// Edge cases: Code blocks

public function testCodeBlockWithLongerClosingFence(): void
Expand Down
Loading