From 4967fca05c7efaa0e9248cfe3ea270bf5cbf587e Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 20 Jan 2026 07:05:47 +0100 Subject: [PATCH 1/5] Add Creole-style |= table header syntax Adds support for marking individual cells as headers using the |= prefix (Creole/MediaWiki-style syntax). This enables: - Headers without requiring a separator row - Mixed header and data cells in the same row - Row headers on the left side of tables - Inline alignment markers: |=< (left), |=> (right), |=~ (center) The marker must be directly attached to the pipe character to be recognized as a header (|= is header, | = is literal content). This feature integrates cleanly with the existing table spanning features (colspan with <, rowspan with ^, and multi-line cells with +). --- docs/enhancements.md | 64 ++++++ src/Parser/Block/TableParser.php | 46 ++++ src/Parser/BlockParser.php | 48 +++- tests/TestCase/TableHeaderSyntaxTest.php | 276 +++++++++++++++++++++++ 4 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 tests/TestCase/TableHeaderSyntaxTest.php diff --git a/docs/enhancements.md b/docs/enhancements.md index b216d6c..8339e3d 100644 --- a/docs/enhancements.md +++ b/docs/enhancements.md @@ -706,6 +706,69 @@ 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 + + + + +
NameAge
Alice28
Bob34
+``` + +**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. + +**Compatibility:** + +- 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 +883,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 e0a39eb..3e78f2c 100644 --- a/src/Parser/Block/TableParser.php +++ b/src/Parser/Block/TableParser.php @@ -465,6 +465,52 @@ 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. + * Supports: |= Header |, |=< Left |, |=> Right |, |=~ Center | + * + * @param string $cellContent The raw cell content starting with = + * + * @return array{content: string, alignment: string} Parsed content and alignment + */ + public function parseHeaderCell(string $cellContent): array + { + // Remove the leading = + $afterEquals = substr($cellContent, 1); + $alignment = TableCell::ALIGN_DEFAULT; + + // 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); + } + + return [ + 'content' => trim($afterEquals), + 'alignment' => $alignment, + ]; + } + /** * 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 027c225..1318ad9 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -2223,22 +2223,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', @@ -2246,13 +2242,32 @@ 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); + $contentToParse = trim($cellContent); + + // Check for |= header marker (Creole-style) + if ($this->tableParser->isHeaderMarker($cellContent)) { + $isHeader = true; + $rowHasHeaderCell = true; + $headerData = $this->tableParser->parseHeaderCell($cellContent); + $contentToParse = $headerData['content']; + + // 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); if ($cellData['attributes']) { $cell->setAttributes($cellData['attributes']); } - $this->inlineParser->parse($cell, trim($cellData['content']), $baseLineForRow); - $row->appendChild($cell); + $this->inlineParser->parse($cell, $contentToParse, $baseLineForRow); $rowCellData[] = [ 'type' => 'cell', 'cell' => $cell, @@ -2262,6 +2277,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/tests/TestCase/TableHeaderSyntaxTest.php b/tests/TestCase/TableHeaderSyntaxTest.php new file mode 100644 index 0000000..b780dd2 --- /dev/null +++ b/tests/TestCase/TableHeaderSyntaxTest.php @@ -0,0 +1,276 @@ +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); + } + + /** + * 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; + } +} From 43aed75d1d803b62600ef7ea47d7c89d41a78337 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 20 Jan 2026 07:12:46 +0100 Subject: [PATCH 2/5] Add comprehensive tests for |= header interaction with table features Tests cover: - Multi-line cells with + continuation - Cell attributes with {.class}= syntax - Combined rowspan and colspan - Rowspan from headers into data rows - Continuation rows don't create new headers - Multiple header rows with different alignments - Row header pattern (first column as headers) --- tests/TestCase/TableHeaderSyntaxTest.php | 123 +++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/TestCase/TableHeaderSyntaxTest.php b/tests/TestCase/TableHeaderSyntaxTest.php index b780dd2..a77fe2e 100644 --- a/tests/TestCase/TableHeaderSyntaxTest.php +++ b/tests/TestCase/TableHeaderSyntaxTest.php @@ -259,6 +259,129 @@ public function testHtmlRenderingWithAlignment(): void $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 testEqualsHeaderWithCellAttributes(): void + { + // Cell attributes before = 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 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. */ From bf81cb9c289fd4f0f79f2f763e27ec8a0fa8ac86 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 20 Jan 2026 07:20:02 +0100 Subject: [PATCH 3/5] Add examples for |= header combinations with other table features --- docs/enhancements.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/docs/enhancements.md b/docs/enhancements.md index 8339e3d..40553a3 100644 --- a/docs/enhancements.md +++ b/docs/enhancements.md @@ -761,7 +761,47 @@ Alignment markers attach directly to `|=`: Header alignment propagates to data cells below when no separator row is present. -**Compatibility:** +**Combining with Cell Attributes:** + +Cell attributes come before the `=` marker: + +```djot +|{.name}= Name |{.age}= Age | +| Alice | 28 | +``` + +Output: `NameAge` + +**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) From 8a7944915b0543384b113d22702ac25e798be255 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 20 Jan 2026 07:27:31 +0100 Subject: [PATCH 4/5] Change |= attribute syntax to |={.class} order The header marker now comes before attributes for better visual clarity: - New (preferred): |={.class} Header | - Combined: |=<{.class} Header | (alignment then attributes) - Old syntax |{.class}= still works via cell-level attributes Order: |= [alignment] [{attributes}] content This keeps |= together as a visual unit, making it clearer that the cell is a header with optional modifiers. --- docs/enhancements.md | 17 +++++++++-- src/Parser/Block/TableParser.php | 38 ++++++++++++++++++++++-- src/Parser/BlockParser.php | 10 +++++-- tests/TestCase/TableHeaderSyntaxTest.php | 38 ++++++++++++++++++++++-- 4 files changed, 92 insertions(+), 11 deletions(-) diff --git a/docs/enhancements.md b/docs/enhancements.md index 40553a3..0330f12 100644 --- a/docs/enhancements.md +++ b/docs/enhancements.md @@ -761,17 +761,28 @@ Alignment markers attach directly to `|=`: Header alignment propagates to data cells below when no separator row is present. -**Combining with Cell Attributes:** +**Combining with Attributes:** -Cell attributes come before the `=` marker: +Attributes come after the `=` marker (and alignment if present): ```djot -|{.name}= Name |{.age}= Age | +|={.name} Name |={.age} Age | | Alice | 28 | ``` Output: `NameAge` +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: diff --git a/src/Parser/Block/TableParser.php b/src/Parser/Block/TableParser.php index 3e78f2c..cd4a8b9 100644 --- a/src/Parser/Block/TableParser.php +++ b/src/Parser/Block/TableParser.php @@ -480,18 +480,22 @@ public function isHeaderMarker(string $cellContent): bool } /** - * Parse header cell content and extract alignment. - * Supports: |= Header |, |=< Left |, |=> Right |, |=~ Center | + * 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} Parsed content and alignment + * @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, '<')) { @@ -505,9 +509,37 @@ public function parseHeaderCell(string $cellContent): array $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, ]; } diff --git a/src/Parser/BlockParser.php b/src/Parser/BlockParser.php index 1318ad9..e9416ca 100644 --- a/src/Parser/BlockParser.php +++ b/src/Parser/BlockParser.php @@ -2247,11 +2247,13 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int $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) { @@ -2264,8 +2266,12 @@ protected function tryParseTable(Node $parent, array $lines, int $start): ?int } $cell = new TableCell($isHeader, $alignment, 1, $colspan); - if ($cellData['attributes']) { - $cell->setAttributes($cellData['attributes']); + + // 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); } $this->inlineParser->parse($cell, $contentToParse, $baseLineForRow); $rowCellData[] = [ diff --git a/tests/TestCase/TableHeaderSyntaxTest.php b/tests/TestCase/TableHeaderSyntaxTest.php index a77fe2e..2182933 100644 --- a/tests/TestCase/TableHeaderSyntaxTest.php +++ b/tests/TestCase/TableHeaderSyntaxTest.php @@ -279,16 +279,48 @@ public function testEqualsHeaderWithMultiLineContinuation(): void $this->assertSame('Long Header continued', $content); } - public function testEqualsHeaderWithCellAttributes(): void + public function testEqualsHeaderWithAttributes(): void { - // Cell attributes before = marker: |{.class}= Header | + // Attributes after = marker: |={.class} Header | $converter = new DjotConverter(); - $html = $converter->convert("|{.name}= Name |{.age}= Age |\n| Alice | 28 |"); + $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 testEqualsHeaderWithRowspanAndColspan(): void { // |= header spanning multiple rows and columns From 65f247db90c1f35d9df4e501cff3cadf728ec149 Mon Sep 17 00:00:00 2001 From: mscherer Date: Tue, 20 Jan 2026 07:52:23 +0100 Subject: [PATCH 5/5] Fix duplicate style attributes when alignment + explicit style used When using both alignment markers (|=<) and explicit style attributes ({style="..."}), the renderer now properly merges them into a single style attribute instead of creating duplicate style attributes. Example: |=<{style="color: red"} text | Before: style="color: red" style="text-align: left;" After: style="text-align: left; color: red" --- src/Renderer/HtmlRenderer.php | 18 +++++++++++++----- tests/TestCase/TableHeaderSyntaxTest.php | 11 +++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index b8c82bc..5ab0f09 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -709,6 +709,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(); @@ -721,11 +734,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) . '\n"; } diff --git a/tests/TestCase/TableHeaderSyntaxTest.php b/tests/TestCase/TableHeaderSyntaxTest.php index 2182933..f35cc04 100644 --- a/tests/TestCase/TableHeaderSyntaxTest.php +++ b/tests/TestCase/TableHeaderSyntaxTest.php @@ -321,6 +321,17 @@ public function testOldAttributeSyntaxStillWorks(): void $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