diff --git a/docs/enhancements.md b/docs/enhancements.md
index b216d6c..0330f12 100644
--- a/docs/enhancements.md
+++ b/docs/enhancements.md
@@ -706,6 +706,120 @@ Renders the second cell as: `this is a really long code span`
---
+### Creole-style Header Cells (`|=`)
+
+**Related:** [php-collective/djot-php#8](https://github.com/php-collective/djot-php/pull/8)
+
+**Status:** Implemented in djot-php
+
+Mark individual cells as headers using the `|=` prefix (Creole/MediaWiki-style):
+
+```djot
+|= Name |= Age |
+| Alice | 28 |
+| Bob | 34 |
+```
+
+**Output:**
+```html
+
+| Name | Age |
+| Alice | 28 |
+| Bob | 34 |
+
+```
+
+**Key Differences from Separator Row Headers:**
+- No separator row (`|---|`) needed
+- Individual cells can be headers (mix header and data cells in same row)
+- Enables row headers on the left side of tables
+
+**Row Headers Example:**
+
+```djot
+|= Category | Value |
+|= Apples | 10 |
+|= Oranges | 20 |
+```
+
+Each row has a header cell on the left and a data cell on the right.
+
+**Inline Alignment:**
+
+Alignment markers attach directly to `|=`:
+
+| Syntax | Alignment |
+|--------|-----------|
+| `\|=< text` | Left |
+| `\|=> text` | Right |
+| `\|=~ text` | Center |
+
+```djot
+|=< Left |=> Right |=~ Center |
+| A | B | C |
+```
+
+Header alignment propagates to data cells below when no separator row is present.
+
+**Combining with Attributes:**
+
+Attributes come after the `=` marker (and alignment if present):
+
+```djot
+|={.name} Name |={.age} Age |
+| Alice | 28 |
+```
+
+Output: `Name | Age | `
+
+Order: `|=` `[alignment]` `[{attributes}]` `content`
+
+```djot
+|=<{.left} Left |=>{#right .highlight} Right |
+| A | B |
+```
+
+Output:
+- `Left | `
+- `Right | `
+
+**Combining with Colspan/Rowspan:**
+
+Headers work with `<` (colspan) and `^` (rowspan) markers:
+
+```djot
+|=~ Report Title | < |
+|= Category |= Items |
+| Fruits | Apple |
+| ^ | Banana |
+```
+
+Output:
+- "Report Title" → ``
+- "Category" → ` | ` (extended by `^` markers below)
+
+**Combining with Multi-line Cells:**
+
+Continuation rows (`+`) merge content into header cells:
+
+```djot
+|= Long Header Name |= Short |
++ (continued) | |
+| data | data |
+```
+
+Output: ` | Long Header Name (continued) | `
+
+Note: `=` in continuation rows is treated as content, not a header marker.
+
+**Compatibility Notes:**
+
+- Markers must be directly attached to pipe: `|= Header` (header), `| = text` (literal)
+- Can coexist with separator rows (separator row alignment takes precedence)
+- Works with colspan (`<`), rowspan (`^`), and multi-line cells (`+`)
+
+---
+
### Captions for Images, Tables, and Block Quotes
**Related:** [php-collective/djot-php#37](https://github.com/php-collective/djot-php/issues/37)
@@ -820,6 +934,7 @@ vendor/bin/phpunit
| Fenced comment blocks | [djot:67](https://github.com/jgm/djot/issues/67) | Open |
| Captions (image/table/blockquote) | [#37](https://github.com/php-collective/djot-php/issues/37) | djot-php |
| Table multi-line/rowspan/colspan | [djot:368](https://github.com/jgm/djot/issues/368) | Open |
+| Creole-style header cells (`\|=`) | [#8](https://github.com/php-collective/djot-php/pull/8) | djot-php |
| Abbreviations (block, not inline) | [djot:51](https://github.com/jgm/djot/issues/51) | djot-php |
### Optional Modes
diff --git a/src/Parser/Block/TableParser.php b/src/Parser/Block/TableParser.php
index 88e6a39..0530366 100644
--- a/src/Parser/Block/TableParser.php
+++ b/src/Parser/Block/TableParser.php
@@ -471,6 +471,84 @@ public function isColspanMarker(string $cellContent): bool
return trim($cellContent) === '<';
}
+ /**
+ * Check if cell content starts with = (Creole-style header marker).
+ * The = must be directly attached to the pipe: |= Header | is a header,
+ * but | = text | is literal content "= text".
+ *
+ * @param string $cellContent The raw cell content (not trimmed)
+ *
+ * @return bool True if the cell is a header cell
+ */
+ public function isHeaderMarker(string $cellContent): bool
+ {
+ return str_starts_with($cellContent, '=');
+ }
+
+ /**
+ * Parse header cell content and extract alignment and attributes.
+ * Supports: |= Header |, |=< Left |, |=> Right |, |=~ Center |, |={.class} Header |
+ *
+ * Order: |= [alignment] [{attributes}] content |
+ * Examples: |=< Header |, |={.class} Header |, |=>{#id .class} Header |
+ *
+ * @param string $cellContent The raw cell content starting with =
+ *
+ * @return array{content: string, alignment: string, attributes: array} Parsed data
+ */
+ public function parseHeaderCell(string $cellContent): array
+ {
+ // Remove the leading =
+ $afterEquals = substr($cellContent, 1);
+ $alignment = TableCell::ALIGN_DEFAULT;
+ $attributes = [];
+
+ // Check for alignment marker (must be directly attached: =< not = <)
+ if (str_starts_with($afterEquals, '<')) {
+ $alignment = TableCell::ALIGN_LEFT;
+ $afterEquals = substr($afterEquals, 1);
+ } elseif (str_starts_with($afterEquals, '>')) {
+ $alignment = TableCell::ALIGN_RIGHT;
+ $afterEquals = substr($afterEquals, 1);
+ } elseif (str_starts_with($afterEquals, '~')) {
+ $alignment = TableCell::ALIGN_CENTER;
+ $afterEquals = substr($afterEquals, 1);
+ }
+
+ // Check for attributes after alignment marker: |={.class} or |=<{.class}
+ if (str_starts_with($afterEquals, '{')) {
+ // Find matching closing brace
+ $braceDepth = 0;
+ $endPos = -1;
+ $len = strlen($afterEquals);
+
+ for ($i = 0; $i < $len; $i++) {
+ if ($afterEquals[$i] === '{') {
+ $braceDepth++;
+ } elseif ($afterEquals[$i] === '}') {
+ $braceDepth--;
+ if ($braceDepth === 0) {
+ $endPos = $i;
+
+ break;
+ }
+ }
+ }
+
+ if ($endPos > 0) {
+ $attrStr = substr($afterEquals, 1, $endPos - 1);
+ $attributes = AttributeParser::parse($attrStr);
+ $afterEquals = substr($afterEquals, $endPos + 1);
+ }
+ }
+
+ return [
+ 'content' => trim($afterEquals),
+ 'alignment' => $alignment,
+ 'attributes' => $attributes,
+ ];
+ }
+
/**
* Check if a line is a continuation row (starts with +).
* Continuation rows use + prefix instead of | to signal that the contents
diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php
index 15afa6f..66e1df2 100644
--- a/src/Parser/BlockParser.php
+++ b/src/Parser/BlockParser.php
@@ -2245,22 +2245,18 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
}
}
- // Parse regular row
- $row = new TableRow(false);
- if ($rowAttributes) {
- $row->setAttributes($rowAttributes);
- }
-
// Store row data for rowspan processing
// Track column positions for cells accounting for rowspan markers
$rowCellData = [];
$colPosition = 0;
+ $rowHasHeaderCell = false;
foreach ($processedCells as $index => $cellData) {
$colspan = $cellData['colspan'];
+ $cellContent = $cellData['content'];
// Check for rowspan marker
- if ($this->tableParser->isRowspanMarker($cellData['content'])) {
+ if ($this->tableParser->isRowspanMarker($cellContent)) {
// Mark this position for rowspan processing
$rowCellData[] = [
'type' => 'rowspan_marker',
@@ -2268,18 +2264,43 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
];
$colPosition += $colspan;
} else {
+ $isHeader = false;
$alignment = $alignments[$index] ?? TableCell::ALIGN_DEFAULT;
- $cell = new TableCell(false, $alignment, 1, $colspan);
- if ($cellData['attributes']) {
- $cell->setAttributes($cellData['attributes']);
+ $contentToParse = trim($cellContent);
+
+ // Check for |= header marker (Creole-style)
+ $headerAttributes = [];
+ if ($this->tableParser->isHeaderMarker($cellContent)) {
+ $isHeader = true;
+ $rowHasHeaderCell = true;
+ $headerData = $this->tableParser->parseHeaderCell($cellContent);
+ $contentToParse = $headerData['content'];
+ $headerAttributes = $headerData['attributes'];
+
+ // Header alignment takes precedence if no separator row alignment
+ if ($headerData['alignment'] !== TableCell::ALIGN_DEFAULT) {
+ $alignment = $headerData['alignment'];
+ // Store alignment for propagation to subsequent data cells
+ if (!isset($alignments[$index])) {
+ $alignments[$index] = $alignment;
+ }
+ }
+ }
+
+ $cell = new TableCell($isHeader, $alignment, 1, $colspan);
+
+ // Merge cell attributes: header attributes (|={.class}) take precedence,
+ // then cell attributes (|{.class}=), allowing both syntaxes
+ $mergedAttributes = array_merge($cellData['attributes'], $headerAttributes);
+ if ($mergedAttributes) {
+ $cell->setAttributes($mergedAttributes);
}
- $trimmedContent = trim($cellData['content']);
- if ($trimmedContent !== '' && $this->isPlainText($trimmedContent)) {
- $cell->appendChild(new Text($trimmedContent));
+
+ if ($contentToParse !== '' && $this->isPlainText($contentToParse)) {
+ $cell->appendChild(new Text($contentToParse));
} else {
- $this->inlineParser->parse($cell, $trimmedContent, $baseLineForRow);
+ $this->inlineParser->parse($cell, $contentToParse, $baseLineForRow);
}
- $row->appendChild($cell);
$rowCellData[] = [
'type' => 'cell',
'cell' => $cell,
@@ -2289,6 +2310,19 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int
}
}
+ // Create the row (mark as header row if any cell has |= syntax)
+ $row = new TableRow($rowHasHeaderCell);
+ if ($rowAttributes) {
+ $row->setAttributes($rowAttributes);
+ }
+
+ // Append cells to the row
+ foreach ($rowCellData as $cellInfo) {
+ if ($cellInfo['type'] === 'cell' && isset($cellInfo['cell'])) {
+ $row->appendChild($cellInfo['cell']);
+ }
+ }
+
// Process rowspan markers - find cells above that should span down
// We need to track column positions considering rowspan markers in previous rows
$tableChildren = $table->getChildren();
diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php
index 9eb6b57..4bff7ed 100644
--- a/src/Renderer/HtmlRenderer.php
+++ b/src/Renderer/HtmlRenderer.php
@@ -657,6 +657,19 @@ protected function renderTableRow(TableRow $node): string
protected function renderTableCell(TableCell $node): string
{
$tag = $node->isHeader() ? 'th' : 'td';
+
+ // Handle alignment - merge with existing style attribute if present
+ $alignment = $node->getAlignment();
+ if ($alignment !== TableCell::ALIGN_DEFAULT) {
+ $existingStyle = $node->getAttribute('style');
+ if ($existingStyle !== null) {
+ // Merge: prepend alignment to existing style
+ $node->setAttribute('style', 'text-align: ' . $alignment . '; ' . $existingStyle);
+ } else {
+ $node->setAttribute('style', 'text-align: ' . $alignment . ';');
+ }
+ }
+
$attrs = $this->renderAttributes($node);
$rowspan = $node->getRowspan();
@@ -669,11 +682,6 @@ protected function renderTableCell(TableCell $node): string
$attrs .= ' colspan="' . $colspan . '"';
}
- $alignment = $node->getAlignment();
- if ($alignment !== TableCell::ALIGN_DEFAULT) {
- $attrs .= ' style="text-align: ' . $alignment . ';"';
- }
-
return '<' . $tag . $attrs . '>' . $this->renderChildren($node) . '' . $tag . ">\n";
}
diff --git a/tests/TestCase/TableHeaderSyntaxTest.php b/tests/TestCase/TableHeaderSyntaxTest.php
new file mode 100644
index 0000000..f35cc04
--- /dev/null
+++ b/tests/TestCase/TableHeaderSyntaxTest.php
@@ -0,0 +1,442 @@
+parser = new BlockParser();
+ }
+
+ public function testBasicEqualsHeaderSyntax(): 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 testEqualsHeaderAlignment(): 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 testMixedHeaderAndDataCells(): 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 testEqualsHeaderNoSeparatorNeeded(): 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 testEqualsHeaderAlignmentPropagates(): 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 testSeparatorRowOverridesHeaderAlignment(): 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 testEqualsWithSpaceIsLiteral(): 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 = $this->getCellTextContent($cells[1]);
+ $this->assertSame('= literal', $cellContent);
+ }
+
+ public function testAlignmentMarkersRequireAttachment(): 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 = $this->getCellTextContent($cells[1]);
+ $this->assertSame('< literal', $cellContent);
+ }
+
+ public function testRowHeadersOnLeftSide(): void
+ {
+ // Use |= for row headers on the left side of a table
+ $doc = $this->parser->parse("|= Category | Value |\n|= Apples | 10 |\n|= Oranges | 20 |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // All rows should be marked as header rows (they contain header cells)
+ foreach ($rows as $row) {
+ $this->assertTrue($row->isHeader());
+ $cells = $row->getChildren();
+ // First cell in each row is a header
+ $this->assertTrue($cells[0]->isHeader());
+ // Second cell is not a header
+ $this->assertFalse($cells[1]->isHeader());
+ }
+ }
+
+ public function testEqualsHeaderWithColspan(): void
+ {
+ // |= combined with colspan using <
+ $doc = $this->parser->parse("|= Contact Info | < |\n| Email | Phone |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+ $headerCells = $rows[0]->getChildren();
+
+ // "Contact Info" should be a header with colspan=2
+ $this->assertCount(1, $headerCells);
+ $this->assertTrue($headerCells[0]->isHeader());
+ $this->assertSame(2, $headerCells[0]->getColspan());
+ }
+
+ public function testEqualsHeaderWithRowspan(): void
+ {
+ // |= combined with rowspan using ^
+ $doc = $this->parser->parse("|= Category |= Item |\n| ^ | Apple |\n| ^ | Banana |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // First row header should have rowspan=3
+ $headerCells = $rows[0]->getChildren();
+ $this->assertTrue($headerCells[0]->isHeader());
+ $this->assertSame(3, $headerCells[0]->getRowspan());
+ }
+
+ public function testHtmlRendering(): void
+ {
+ $converter = new DjotConverter();
+ $html = $converter->convert("|= Name |= Age |\n| Alice | 28 |");
+
+ $this->assertStringContainsString('Name | ', $html);
+ $this->assertStringContainsString('Age | ', $html);
+ $this->assertStringContainsString('Alice | ', $html);
+ $this->assertStringContainsString('28 | ', $html);
+ }
+
+ public function testHtmlRenderingWithAlignment(): void
+ {
+ $converter = new DjotConverter();
+ $html = $converter->convert('|=< Left |=> Right |=~ Center |');
+
+ $this->assertStringContainsString('style="text-align: left;"', $html);
+ $this->assertStringContainsString('style="text-align: right;"', $html);
+ $this->assertStringContainsString('style="text-align: center;"', $html);
+ }
+
+ public function testEqualsHeaderWithMultiLineContinuation(): void
+ {
+ // |= header with + continuation row
+ $doc = $this->parser->parse("|= Long Header |= Short |\n+ continued | |\n| data | data |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // Should have 2 rows (header with continuation merged, then data)
+ $this->assertCount(2, $rows);
+
+ // Header row cells
+ $headerCells = $rows[0]->getChildren();
+ $this->assertTrue($headerCells[0]->isHeader());
+
+ // Content should be merged
+ $content = $this->getCellTextContent($headerCells[0]);
+ $this->assertSame('Long Header continued', $content);
+ }
+
+ public function testEqualsHeaderWithAttributes(): void
+ {
+ // Attributes after = marker: |={.class} Header |
+ $converter = new DjotConverter();
+ $html = $converter->convert("|={.name} Name |={.age} Age |\n| Alice | 28 |");
+
+ $this->assertStringContainsString('Name | ', $html);
+ $this->assertStringContainsString('Age | ', $html);
+ }
+
+ public function testEqualsHeaderWithAlignmentAndAttributes(): void
+ {
+ // Combined: |=<{.class} (alignment then attributes)
+ $converter = new DjotConverter();
+ $html = $converter->convert("|=<{.left} Left |=>{.right} Right |\n| A | B |");
+
+ $this->assertStringContainsString('class="left"', $html);
+ $this->assertStringContainsString('style="text-align: left;"', $html);
+ $this->assertStringContainsString('class="right"', $html);
+ $this->assertStringContainsString('style="text-align: right;"', $html);
+ }
+
+ public function testEqualsHeaderWithComplexAttributes(): void
+ {
+ // Complex attributes: |={#id .class key=val}
+ $converter = new DjotConverter();
+ $html = $converter->convert("|={#header .important data-col=name} Name |\n| Alice |");
+
+ $this->assertStringContainsString('id="header"', $html);
+ $this->assertStringContainsString('class="important"', $html);
+ $this->assertStringContainsString('data-col="name"', $html);
+ }
+
+ public function testOldAttributeSyntaxStillWorks(): void
+ {
+ // Old syntax |{.class}= still works (cell-level attributes)
+ $converter = new DjotConverter();
+ $html = $converter->convert("|{.cell}= Name |\n| Alice |");
+
+ $this->assertStringContainsString('Name | ', $html);
+ }
+
+ public function testAlignmentMergesWithExplicitStyle(): void
+ {
+ // Alignment marker + explicit style should merge, not duplicate
+ $converter = new DjotConverter();
+ $html = $converter->convert('|=<{style="color: red"} Red Left |');
+
+ // Should have merged style, not duplicate style attributes
+ $this->assertStringContainsString('style="text-align: left; color: red"', $html);
+ $this->assertSame(1, substr_count($html, 'style='));
+ }
+
+ public function testEqualsHeaderWithRowspanAndColspan(): void
+ {
+ // |= header spanning multiple rows and columns
+ $doc = $this->parser->parse("|=~ Title | < |\n| A | B |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ $headerCells = $rows[0]->getChildren();
+
+ // Title should have colspan=2 and be centered
+ $this->assertCount(1, $headerCells);
+ $this->assertTrue($headerCells[0]->isHeader());
+ $this->assertSame(2, $headerCells[0]->getColspan());
+ $this->assertSame(TableCell::ALIGN_CENTER, $headerCells[0]->getAlignment());
+ }
+
+ public function testEqualsHeaderRowspanIntoDataRows(): void
+ {
+ // Header that spans down into data rows using ^
+ $doc = $this->parser->parse("|= Category |= Item |\n| ^ | Apple |\n| ^ | Banana |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // All 3 rows should exist
+ $this->assertCount(3, $rows);
+
+ // First cell should be header with rowspan=3
+ $headerCells = $rows[0]->getChildren();
+ $this->assertTrue($headerCells[0]->isHeader());
+ $this->assertSame(3, $headerCells[0]->getRowspan());
+
+ // Second and third rows should only have one cell each (Apple, Banana)
+ $this->assertCount(1, $rows[1]->getChildren());
+ $this->assertCount(1, $rows[2]->getChildren());
+ }
+
+ public function testContinuationDoesNotCreateNewHeaders(): void
+ {
+ // = in continuation row should be content, not header marker
+ $doc = $this->parser->parse("|= Header |= Header2 |\n+ =cont | cont |\n| data | data |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ // Should be 2 rows (header+continuation merged, then data)
+ $this->assertCount(2, $rows);
+
+ // First cell content should include "=cont" as content
+ $headerCells = $rows[0]->getChildren();
+ $content = $this->getCellTextContent($headerCells[0]);
+ $this->assertSame('Header =cont', $content);
+ }
+
+ public function testMultipleHeaderRowsWithDifferentAlignments(): void
+ {
+ // Two header rows with different alignments
+ $doc = $this->parser->parse("|=~ Centered Title | < |\n|=> Right |=< Left |\n| data | data |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ $this->assertCount(3, $rows);
+
+ // First row: centered header with colspan
+ $row1Cells = $rows[0]->getChildren();
+ $this->assertCount(1, $row1Cells);
+ $this->assertSame(TableCell::ALIGN_CENTER, $row1Cells[0]->getAlignment());
+ $this->assertSame(2, $row1Cells[0]->getColspan());
+
+ // Second row: right and left aligned headers
+ $row2Cells = $rows[1]->getChildren();
+ $this->assertSame(TableCell::ALIGN_RIGHT, $row2Cells[0]->getAlignment());
+ $this->assertSame(TableCell::ALIGN_LEFT, $row2Cells[1]->getAlignment());
+ }
+
+ public function testRowHeaderPattern(): void
+ {
+ // Common pattern: first column as row headers
+ $doc = $this->parser->parse("|= Product | Sales |\n|= Widget | 100 |\n|= Gadget | 200 |");
+
+ $table = $doc->getChildren()[0];
+ $rows = $table->getChildren();
+
+ foreach ($rows as $row) {
+ $cells = $row->getChildren();
+ // First cell is header, second is data
+ $this->assertTrue($cells[0]->isHeader());
+ $this->assertFalse($cells[1]->isHeader());
+ }
+ }
+
+ /**
+ * Helper to extract text content from a cell.
+ */
+ private function getCellTextContent(TableCell $cell): string
+ {
+ $content = '';
+ foreach ($cell->getChildren() as $child) {
+ if ($child instanceof Text) {
+ $content .= $child->getContent();
+ }
+ }
+
+ return $content;
+ }
+}