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 + + + + +
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. + +**Combining with Attributes:** + +Attributes come after the `=` marker (and alignment if present): + +```djot +|={.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: + +```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) . '\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; + } +}