xref: /dokuwiki/inc/Parsing/Handler/GfmTable.php (revision 74031e463764923581b9204cebc0fc3f34ce881f)
13dabe4e0SAndreas Gohr<?php
23dabe4e0SAndreas Gohr
33dabe4e0SAndreas Gohrnamespace dokuwiki\Parsing\Handler;
43dabe4e0SAndreas Gohr
53dabe4e0SAndreas Gohr/**
63dabe4e0SAndreas Gohr * CallWriter rewriter for GFM tables.
73dabe4e0SAndreas Gohr *
83dabe4e0SAndreas Gohr * GfmTable's lexer state emits a flat token stream of marker calls
93dabe4e0SAndreas Gohr * (`gfm_table_start`, `gfm_table_row`, `gfm_table_cell`, `gfm_table_end`)
103dabe4e0SAndreas Gohr * interleaved with whatever inline modes (emphasis, code spans, links, …)
113dabe4e0SAndreas Gohr * matched inside the cells. This rewriter:
123dabe4e0SAndreas Gohr *
133dabe4e0SAndreas Gohr *   1. Groups the flat stream into rows-of-cells, where each cell carries
143dabe4e0SAndreas Gohr *      its own list of nested handler calls.
153dabe4e0SAndreas Gohr *   2. Strips the empty leading and trailing cells that result from leading
163dabe4e0SAndreas Gohr *      and trailing pipes (`| a | b |` → cells `["", " a ", " b ", ""]` →
173dabe4e0SAndreas Gohr *      `[" a ", " b "]`).
183dabe4e0SAndreas Gohr *   3. Parses the second row as the GFM delimiter row, deriving per-column
193dabe4e0SAndreas Gohr *      alignment from `:-+:?` patterns and the column count from the cell
203dabe4e0SAndreas Gohr *      count.
213dabe4e0SAndreas Gohr *   4. Validates that the header row's cell count matches the delimiter's.
223dabe4e0SAndreas Gohr *      On mismatch (spec example 203), emits the captured text back as a
233dabe4e0SAndreas Gohr *      single cdata so the Block rewriter wraps it in a paragraph.
243dabe4e0SAndreas Gohr *   5. Pads body rows that are short (spec 202) and truncates body rows
253dabe4e0SAndreas Gohr *      that are long (spec 204) to the header's column count.
263dabe4e0SAndreas Gohr *   6. Trims leading/trailing whitespace from each cell's edge cdata calls
273dabe4e0SAndreas Gohr *      ("Spaces between pipes and cell content are trimmed").
283dabe4e0SAndreas Gohr *   7. Emits the canonical DokuWiki table call sequence — `table_open`,
293dabe4e0SAndreas Gohr *      `tablethead_open`, `tablerow_open`, per-column `tableheader_open`
303dabe4e0SAndreas Gohr *      with alignment, `tablethead_close`, then (only when there are
313dabe4e0SAndreas Gohr *      body rows — spec 205) `tabletbody_open`, per-row `tablerow_open`
323dabe4e0SAndreas Gohr *      with `tablecell_open`, `tabletbody_close`, and finally
333dabe4e0SAndreas Gohr *      `table_close`. No new handler instructions are introduced;
343dabe4e0SAndreas Gohr *      `tabletbody_open` / `tabletbody_close` are part of DokuWiki's
353dabe4e0SAndreas Gohr *      base renderer API but were never emitted before — DW Table omits
363dabe4e0SAndreas Gohr *      `<tbody>` entirely. Activating them here is what frees the test
373dabe4e0SAndreas Gohr *      renderer from having to track tbody state.
383dabe4e0SAndreas Gohr *
39*74031e46SAndreas Gohr * Backslash-escaped pipes outside protected regions are consumed by
40*74031e46SAndreas Gohr * GfmEscape before the cell content reaches this rewriter. Inside
41*74031e46SAndreas Gohr * code spans (and any other whole-span PROTECTED capture) the `\|`
42*74031e46SAndreas Gohr * survives as literal text — and the GFM tables extension demands
43*74031e46SAndreas Gohr * that `\|` unescape to `|` even there, overriding §6.1's
44*74031e46SAndreas Gohr * "escapes don't work in code spans" rule. unescapePipes() applies
45*74031e46SAndreas Gohr * that rewrite per cell to every text-bearing call.
463dabe4e0SAndreas Gohr */
473dabe4e0SAndreas Gohrclass GfmTable extends AbstractRewriter
483dabe4e0SAndreas Gohr{
493dabe4e0SAndreas Gohr    /** @inheritdoc */
503dabe4e0SAndreas Gohr    protected function getClosingCall(): string
513dabe4e0SAndreas Gohr    {
523dabe4e0SAndreas Gohr        return 'gfm_table_end';
533dabe4e0SAndreas Gohr    }
543dabe4e0SAndreas Gohr
553dabe4e0SAndreas Gohr    /** @inheritdoc */
563dabe4e0SAndreas Gohr    public function process()
573dabe4e0SAndreas Gohr    {
583dabe4e0SAndreas Gohr        ['rows' => $rows, 'startPos' => $startPos, 'endPos' => $endPos] = $this->groupRows();
593dabe4e0SAndreas Gohr        $rows = array_map($this->stripBoundaryEmpty(...), $rows);
603dabe4e0SAndreas Gohr
613dabe4e0SAndreas Gohr        $alignments = array_map(
623dabe4e0SAndreas Gohr            fn($cell) => $this->parseAlign($this->cellText($cell)),
633dabe4e0SAndreas Gohr            $rows[1]
643dabe4e0SAndreas Gohr        );
653dabe4e0SAndreas Gohr        $cols = count($alignments);
663dabe4e0SAndreas Gohr
673dabe4e0SAndreas Gohr        // Header / delimiter column-count mismatch is the spec-203 fallback.
683dabe4e0SAndreas Gohr        if (count($rows[0]) !== $cols) {
693dabe4e0SAndreas Gohr            $this->emitFallback($rows, $startPos);
703dabe4e0SAndreas Gohr            return $this->callWriter;
713dabe4e0SAndreas Gohr        }
723dabe4e0SAndreas Gohr
73*74031e46SAndreas Gohr        $headerRow = $this->unescapePipes($this->trimCellEdges($rows[0]));
743dabe4e0SAndreas Gohr        $bodyRows = array_map(
75*74031e46SAndreas Gohr            fn($row) => $this->unescapePipes($this->trimCellEdges($this->padOrTruncate($row, $cols))),
763dabe4e0SAndreas Gohr            array_slice($rows, 2)
773dabe4e0SAndreas Gohr        );
783dabe4e0SAndreas Gohr
793dabe4e0SAndreas Gohr        $out = $this->buildOutput($headerRow, $bodyRows, $alignments, $cols, $startPos, $endPos);
803dabe4e0SAndreas Gohr        $this->callWriter->writeCalls($out);
813dabe4e0SAndreas Gohr        return $this->callWriter;
823dabe4e0SAndreas Gohr    }
833dabe4e0SAndreas Gohr
843dabe4e0SAndreas Gohr    /**
853dabe4e0SAndreas Gohr     * Walk $this->calls and bucket them into rows-of-cells-of-calls.
863dabe4e0SAndreas Gohr     *
873dabe4e0SAndreas Gohr     * @return array{rows: array<int, array<int, array<int, array>>>, startPos: int, endPos: int}
883dabe4e0SAndreas Gohr     *   `rows[r][c]` is a list of handler calls captured inside row `r`'s
893dabe4e0SAndreas Gohr     *   cell `c`. `startPos` and `endPos` carry the table's opening and
903dabe4e0SAndreas Gohr     *   closing source positions.
913dabe4e0SAndreas Gohr     */
923dabe4e0SAndreas Gohr    protected function groupRows(): array
933dabe4e0SAndreas Gohr    {
943dabe4e0SAndreas Gohr        $rows = [];
953dabe4e0SAndreas Gohr        $rowIdx = -1;
963dabe4e0SAndreas Gohr        $startPos = 0;
973dabe4e0SAndreas Gohr        $endPos = 0;
983dabe4e0SAndreas Gohr
993dabe4e0SAndreas Gohr        foreach ($this->calls as $call) {
1003dabe4e0SAndreas Gohr            switch ($call[0]) {
1013dabe4e0SAndreas Gohr                case 'gfm_table_start':
1023dabe4e0SAndreas Gohr                    $startPos = $call[1][0] ?? $call[2];
1033dabe4e0SAndreas Gohr                    break;
1043dabe4e0SAndreas Gohr                case 'gfm_table_end':
1053dabe4e0SAndreas Gohr                    $endPos = $call[2];
1063dabe4e0SAndreas Gohr                    break;
1073dabe4e0SAndreas Gohr                case 'gfm_table_row':
1083dabe4e0SAndreas Gohr                    $rows[] = [];
1093dabe4e0SAndreas Gohr                    $rowIdx++;
1103dabe4e0SAndreas Gohr                    break;
1113dabe4e0SAndreas Gohr                case 'gfm_table_cell':
1123dabe4e0SAndreas Gohr                    $rows[$rowIdx][] = [];
1133dabe4e0SAndreas Gohr                    break;
1143dabe4e0SAndreas Gohr                default:
1153dabe4e0SAndreas Gohr                    if ($rowIdx >= 0 && !empty($rows[$rowIdx])) {
1163dabe4e0SAndreas Gohr                        $cellIdx = count($rows[$rowIdx]) - 1;
1173dabe4e0SAndreas Gohr                        $rows[$rowIdx][$cellIdx][] = $call;
1183dabe4e0SAndreas Gohr                    }
1193dabe4e0SAndreas Gohr                    break;
1203dabe4e0SAndreas Gohr            }
1213dabe4e0SAndreas Gohr        }
1223dabe4e0SAndreas Gohr
1233dabe4e0SAndreas Gohr        return ['rows' => $rows, 'startPos' => $startPos, 'endPos' => $endPos];
1243dabe4e0SAndreas Gohr    }
1253dabe4e0SAndreas Gohr
1263dabe4e0SAndreas Gohr    /**
1273dabe4e0SAndreas Gohr     * Remove leading and trailing empty cell from given row.
1283dabe4e0SAndreas Gohr     *
1293dabe4e0SAndreas Gohr     * Effects of leading and trailing pipes: `| a | b |` parses into four
1303dabe4e0SAndreas Gohr     * cells `["", " a ", " b ", ""]`. A row with no surrounding pipes
1313dabe4e0SAndreas Gohr     * (`a | b`) parses into two non-empty cells, which stay untouched.
1323dabe4e0SAndreas Gohr     *
1333dabe4e0SAndreas Gohr     * @param array $row a row as a list of cells; each cell is a list of
1343dabe4e0SAndreas Gohr     *                   handler calls captured between separators
1353dabe4e0SAndreas Gohr     * @return array the row with at most one boundary empty cell stripped
1363dabe4e0SAndreas Gohr     *               from each end
1373dabe4e0SAndreas Gohr     */
1383dabe4e0SAndreas Gohr    protected function stripBoundaryEmpty(array $row): array
1393dabe4e0SAndreas Gohr    {
1403dabe4e0SAndreas Gohr        if ($row && $row[0] === []) array_shift($row);
1413dabe4e0SAndreas Gohr        if ($row && end($row) === []) array_pop($row);
1423dabe4e0SAndreas Gohr        return $row;
1433dabe4e0SAndreas Gohr    }
1443dabe4e0SAndreas Gohr
1453dabe4e0SAndreas Gohr    /**
1463dabe4e0SAndreas Gohr     * Concatenate the original source text of every text-bearing call in a
1473dabe4e0SAndreas Gohr     * cell. Used for delimiter parsing and the spec-203 fallback.
1483dabe4e0SAndreas Gohr     *
1493dabe4e0SAndreas Gohr     * Relies on the project-wide convention that any inline mode which
1503dabe4e0SAndreas Gohr     * swallows source text records the matched string at args[0] — true
1513dabe4e0SAndreas Gohr     * for `cdata`, `entity`, `unformatted`, `smiley`, `multiplyentity`,
1523dabe4e0SAndreas Gohr     * plugin substitutions, etc. Open/close pairs carry empty args and
1533dabe4e0SAndreas Gohr     * drop out naturally.
1543dabe4e0SAndreas Gohr     *
1553dabe4e0SAndreas Gohr     * Motivating case: Entity eats runs of `---` as em-dash entities, so
1563dabe4e0SAndreas Gohr     * a naive cdata-only join would lose the delimiter dashes and
1573dabe4e0SAndreas Gohr     * parseAlign() would refuse the column.
1583dabe4e0SAndreas Gohr     *
1593dabe4e0SAndreas Gohr     * Implementation: extract every call's args list, extract index 0
1603dabe4e0SAndreas Gohr     * from each, implode.
1613dabe4e0SAndreas Gohr     *
1623dabe4e0SAndreas Gohr     * @param array $cellCalls handler calls captured inside one cell
1633dabe4e0SAndreas Gohr     * @return string the concatenated source text
1643dabe4e0SAndreas Gohr     */
1653dabe4e0SAndreas Gohr    protected function cellText(array $cellCalls): string
1663dabe4e0SAndreas Gohr    {
1673dabe4e0SAndreas Gohr        return implode('', array_column(array_column($cellCalls, 1), 0));
1683dabe4e0SAndreas Gohr    }
1693dabe4e0SAndreas Gohr
1703dabe4e0SAndreas Gohr    /**
1713dabe4e0SAndreas Gohr     * Decode a single delimiter cell into 'left' / 'center' / 'right' / null.
1723dabe4e0SAndreas Gohr     *
1733dabe4e0SAndreas Gohr     * Trusts the entry pattern's validation that the cell has the shape
1743dabe4e0SAndreas Gohr     * `:?-+:?`; just checks for colons at the edges.
1753dabe4e0SAndreas Gohr     *
1763dabe4e0SAndreas Gohr     * @param string $cellText the joined source text of one delimiter cell
1773dabe4e0SAndreas Gohr     * @return string|null 'left', 'center', 'right', or null when no
1783dabe4e0SAndreas Gohr     *                     alignment marker is present
1793dabe4e0SAndreas Gohr     */
1803dabe4e0SAndreas Gohr    protected function parseAlign(string $cellText): ?string
1813dabe4e0SAndreas Gohr    {
1823dabe4e0SAndreas Gohr        $trimmed = trim($cellText);
1833dabe4e0SAndreas Gohr        $left = str_starts_with($trimmed, ':');
1843dabe4e0SAndreas Gohr        $right = str_ends_with($trimmed, ':');
1853dabe4e0SAndreas Gohr        return match (true) {
1863dabe4e0SAndreas Gohr            $left && $right => 'center',
1873dabe4e0SAndreas Gohr            $right => 'right',
1883dabe4e0SAndreas Gohr            $left => 'left',
1893dabe4e0SAndreas Gohr            default => null,
1903dabe4e0SAndreas Gohr        };
1913dabe4e0SAndreas Gohr    }
1923dabe4e0SAndreas Gohr
1933dabe4e0SAndreas Gohr    /**
1943dabe4e0SAndreas Gohr     * Return a copy of the row padded with empty cells (spec 202) or
1953dabe4e0SAndreas Gohr     * truncated to the header column count (spec 204).
1963dabe4e0SAndreas Gohr     *
1973dabe4e0SAndreas Gohr     * @param array $row a body row as a list of cells
1983dabe4e0SAndreas Gohr     * @param int $cols the target column count derived from the delimiter row
1993dabe4e0SAndreas Gohr     * @return array the row with exactly $cols cells
2003dabe4e0SAndreas Gohr     */
2013dabe4e0SAndreas Gohr    protected function padOrTruncate(array $row, int $cols): array
2023dabe4e0SAndreas Gohr    {
2033dabe4e0SAndreas Gohr        $count = count($row);
2043dabe4e0SAndreas Gohr        if ($count < $cols) {
2053dabe4e0SAndreas Gohr            return array_pad($row, $cols, []);
2063dabe4e0SAndreas Gohr        }
2073dabe4e0SAndreas Gohr        if ($count > $cols) {
2083dabe4e0SAndreas Gohr            return array_slice($row, 0, $cols);
2093dabe4e0SAndreas Gohr        }
2103dabe4e0SAndreas Gohr        return $row;
2113dabe4e0SAndreas Gohr    }
2123dabe4e0SAndreas Gohr
2133dabe4e0SAndreas Gohr    /**
2143dabe4e0SAndreas Gohr     * Return a copy of the row with each cell's first cdata ltrimmed,
2153dabe4e0SAndreas Gohr     * its last cdata rtrimmed, and any cdata that became empty dropped.
2163dabe4e0SAndreas Gohr     * Intermediate cdata are left intact so internal spaces are preserved.
2173dabe4e0SAndreas Gohr     *
2183dabe4e0SAndreas Gohr     * @param array $row a row as a list of cells
2193dabe4e0SAndreas Gohr     * @return array the row with each cell's edge cdata trimmed
2203dabe4e0SAndreas Gohr     */
2213dabe4e0SAndreas Gohr    protected function trimCellEdges(array $row): array
2223dabe4e0SAndreas Gohr    {
2233dabe4e0SAndreas Gohr        return array_map($this->trimCell(...), $row);
2243dabe4e0SAndreas Gohr    }
2253dabe4e0SAndreas Gohr
2263dabe4e0SAndreas Gohr    /**
2273dabe4e0SAndreas Gohr     * Helper for trimCellEdges: trim edge cdata of a single cell.
2283dabe4e0SAndreas Gohr     *
2293dabe4e0SAndreas Gohr     * @param array $cell the cell as a list of handler calls
2303dabe4e0SAndreas Gohr     * @return array the cell with its first cdata ltrimmed, its last
2313dabe4e0SAndreas Gohr     *               cdata rtrimmed, and any cdata that became empty
2323dabe4e0SAndreas Gohr     *               dropped
2333dabe4e0SAndreas Gohr     */
2343dabe4e0SAndreas Gohr    protected function trimCell(array $cell): array
2353dabe4e0SAndreas Gohr    {
2363dabe4e0SAndreas Gohr        // get all cdata call indexes
2373dabe4e0SAndreas Gohr        $cdataIdx = array_keys(array_filter($cell, fn($c) => $c[0] === 'cdata'));
2383dabe4e0SAndreas Gohr        if ($cdataIdx) {
2393dabe4e0SAndreas Gohr            // if any, trim the first and last one's text
2403dabe4e0SAndreas Gohr            $cell[$cdataIdx[0]][1][0] = ltrim($cell[$cdataIdx[0]][1][0]);
2413dabe4e0SAndreas Gohr            $cell[end($cdataIdx)][1][0] = rtrim($cell[end($cdataIdx)][1][0]);
2423dabe4e0SAndreas Gohr        }
2433dabe4e0SAndreas Gohr        // return all cells that are not cdate or are not empty after trimming
2443dabe4e0SAndreas Gohr        return array_values(array_filter(
2453dabe4e0SAndreas Gohr            $cell,
2463dabe4e0SAndreas Gohr            fn($c) => $c[0] !== 'cdata' || $c[1][0] !== ''
2473dabe4e0SAndreas Gohr        ));
2483dabe4e0SAndreas Gohr    }
2493dabe4e0SAndreas Gohr
2503dabe4e0SAndreas Gohr    /**
251*74031e46SAndreas Gohr     * Apply the GFM tables-extension rule that `\|` always unescapes to
252*74031e46SAndreas Gohr     * `|` inside table cells — including the bodies of code spans and
253*74031e46SAndreas Gohr     * other whole-span PROTECTED captures, where standard §6.1 escape
254*74031e46SAndreas Gohr     * rules don't fire. Walks every text-bearing call (cdata,
255*74031e46SAndreas Gohr     * unformatted, entity, plugin substitutions, …) and str_replace's
256*74031e46SAndreas Gohr     * the literal two-char sequence on its first arg. Other escapes
257*74031e46SAndreas Gohr     * inside code spans are left alone — only `\|` gets the special
258*74031e46SAndreas Gohr     * table treatment.
259*74031e46SAndreas Gohr     *
260*74031e46SAndreas Gohr     * In normal cell text, GfmEscape has already consumed `\|` upstream,
261*74031e46SAndreas Gohr     * so this pass is a no-op there; its job is to catch the codespan
262*74031e46SAndreas Gohr     * case that bypasses the lexer.
263*74031e46SAndreas Gohr     *
264*74031e46SAndreas Gohr     * @param array $row a row as a list of cells
265*74031e46SAndreas Gohr     * @return array the row with `\|` rewritten to `|` in every cell
266*74031e46SAndreas Gohr     */
267*74031e46SAndreas Gohr    protected function unescapePipes(array $row): array
268*74031e46SAndreas Gohr    {
269*74031e46SAndreas Gohr        foreach ($row as &$cell) {
270*74031e46SAndreas Gohr            foreach ($cell as &$call) {
271*74031e46SAndreas Gohr                if (isset($call[1][0]) && is_string($call[1][0])) {
272*74031e46SAndreas Gohr                    $call[1][0] = str_replace('\\|', '|', $call[1][0]);
273*74031e46SAndreas Gohr                }
274*74031e46SAndreas Gohr            }
275*74031e46SAndreas Gohr        }
276*74031e46SAndreas Gohr        return $row;
277*74031e46SAndreas Gohr    }
278*74031e46SAndreas Gohr
279*74031e46SAndreas Gohr    /**
2803dabe4e0SAndreas Gohr     * Spec-203 fallback. Reconstruct a `|a|b|`-style line from each row's
2813dabe4e0SAndreas Gohr     * cells via cellText() and emit the joined block as a single cdata so
2823dabe4e0SAndreas Gohr     * the Block rewriter wraps it in a paragraph. Because cellText() also
2833dabe4e0SAndreas Gohr     * walks `entity` / `unformatted` / etc., the source-text delimiter
2843dabe4e0SAndreas Gohr     * characters survive even when an inline mode consumed them.
2853dabe4e0SAndreas Gohr     *
2863dabe4e0SAndreas Gohr     * @param array $rows the captured rows-of-cells-of-calls structure
2873dabe4e0SAndreas Gohr     * @param int $pos the source position to attach to the emitted cdata
2883dabe4e0SAndreas Gohr     */
2893dabe4e0SAndreas Gohr    protected function emitFallback(array $rows, int $pos): void
2903dabe4e0SAndreas Gohr    {
2913dabe4e0SAndreas Gohr        $lines = [];
2923dabe4e0SAndreas Gohr        foreach ($rows as $row) {
2933dabe4e0SAndreas Gohr            $cellTexts = [];
2943dabe4e0SAndreas Gohr            foreach ($row as $cell) {
2953dabe4e0SAndreas Gohr                $cellTexts[] = $this->cellText($cell);
2963dabe4e0SAndreas Gohr            }
2973dabe4e0SAndreas Gohr            $lines[] = '|' . implode('|', $cellTexts) . '|';
2983dabe4e0SAndreas Gohr        }
2993dabe4e0SAndreas Gohr        $text = implode("\n", $lines);
3003dabe4e0SAndreas Gohr        if ($text === '') return;
3013dabe4e0SAndreas Gohr        $this->callWriter->writeCall(['cdata', [$text], $pos]);
3023dabe4e0SAndreas Gohr    }
3033dabe4e0SAndreas Gohr
3043dabe4e0SAndreas Gohr    /**
3053dabe4e0SAndreas Gohr     * Assemble the canonical DokuWiki table-instruction sequence.
3063dabe4e0SAndreas Gohr     *
3073dabe4e0SAndreas Gohr     * `tabletbody_open` / `tabletbody_close` are emitted only when there
3083dabe4e0SAndreas Gohr     * are body rows. Suppressing them for empty-body tables (spec 205)
3093dabe4e0SAndreas Gohr     * matches the spec's "<thead> only, no <tbody>" expectation without
3103dabe4e0SAndreas Gohr     * any state-tracking on the renderer side.
3113dabe4e0SAndreas Gohr     *
3123dabe4e0SAndreas Gohr     * @param array $headerRow trimmed header row, one cell per column
3133dabe4e0SAndreas Gohr     * @param array $bodyRows trimmed body rows, each padded or truncated
3143dabe4e0SAndreas Gohr     *                        to $cols
3153dabe4e0SAndreas Gohr     * @param array $alignments per-column alignment from the delimiter
3163dabe4e0SAndreas Gohr     *                          row; each entry is 'left' / 'center' /
3173dabe4e0SAndreas Gohr     *                          'right' / null
3183dabe4e0SAndreas Gohr     * @param int $cols column count derived from the delimiter row
3193dabe4e0SAndreas Gohr     * @param int $startPos source position of the table's start
3203dabe4e0SAndreas Gohr     * @param int $endPos source position of the table's end
3213dabe4e0SAndreas Gohr     * @return array the canonical DokuWiki table call sequence ready for
3223dabe4e0SAndreas Gohr     *               the outer call writer
3233dabe4e0SAndreas Gohr     */
3243dabe4e0SAndreas Gohr    protected function buildOutput(
3253dabe4e0SAndreas Gohr        array $headerRow,
3263dabe4e0SAndreas Gohr        array $bodyRows,
3273dabe4e0SAndreas Gohr        array $alignments,
3283dabe4e0SAndreas Gohr        int $cols,
3293dabe4e0SAndreas Gohr        int $startPos,
3303dabe4e0SAndreas Gohr        int $endPos
3313dabe4e0SAndreas Gohr    ): array {
3323dabe4e0SAndreas Gohr        $out = [];
3333dabe4e0SAndreas Gohr        $out[] = ['table_open', [$cols, 1 + count($bodyRows), $startPos], $startPos];
3343dabe4e0SAndreas Gohr        $out[] = ['tablethead_open', [], $startPos];
3353dabe4e0SAndreas Gohr        $out[] = ['tablerow_open', [], $startPos];
3363dabe4e0SAndreas Gohr        foreach ($headerRow as $i => $cell) {
3373dabe4e0SAndreas Gohr            $out[] = ['tableheader_open', [1, $alignments[$i], 1], $startPos];
3383dabe4e0SAndreas Gohr            foreach ($cell as $c) $out[] = $c;
3393dabe4e0SAndreas Gohr            $out[] = ['tableheader_close', [], $startPos];
3403dabe4e0SAndreas Gohr        }
3413dabe4e0SAndreas Gohr        $out[] = ['tablerow_close', [], $startPos];
3423dabe4e0SAndreas Gohr        $out[] = ['tablethead_close', [], $startPos];
3433dabe4e0SAndreas Gohr
3443dabe4e0SAndreas Gohr        if ($bodyRows) {
3453dabe4e0SAndreas Gohr            $out[] = ['tabletbody_open', [], $startPos];
3463dabe4e0SAndreas Gohr            foreach ($bodyRows as $row) {
3473dabe4e0SAndreas Gohr                $out[] = ['tablerow_open', [], $startPos];
3483dabe4e0SAndreas Gohr                foreach ($row as $i => $cell) {
3493dabe4e0SAndreas Gohr                    $out[] = ['tablecell_open', [1, $alignments[$i], 1], $startPos];
3503dabe4e0SAndreas Gohr                    foreach ($cell as $c) $out[] = $c;
3513dabe4e0SAndreas Gohr                    $out[] = ['tablecell_close', [], $startPos];
3523dabe4e0SAndreas Gohr                }
3533dabe4e0SAndreas Gohr                $out[] = ['tablerow_close', [], $startPos];
3543dabe4e0SAndreas Gohr            }
3553dabe4e0SAndreas Gohr            $out[] = ['tabletbody_close', [], $startPos];
3563dabe4e0SAndreas Gohr        }
3573dabe4e0SAndreas Gohr        $out[] = ['table_close', [$endPos], $endPos];
3583dabe4e0SAndreas Gohr        return $out;
3593dabe4e0SAndreas Gohr    }
3603dabe4e0SAndreas Gohr}
361