xref: /dokuwiki/inc/Parsing/Handler/GfmTable.php (revision 3dabe4e0a0d70b79a7aced8ac8a36d4b37a61024)
1*3dabe4e0SAndreas Gohr<?php
2*3dabe4e0SAndreas Gohr
3*3dabe4e0SAndreas Gohrnamespace dokuwiki\Parsing\Handler;
4*3dabe4e0SAndreas Gohr
5*3dabe4e0SAndreas Gohr/**
6*3dabe4e0SAndreas Gohr * CallWriter rewriter for GFM tables.
7*3dabe4e0SAndreas Gohr *
8*3dabe4e0SAndreas Gohr * GfmTable's lexer state emits a flat token stream of marker calls
9*3dabe4e0SAndreas Gohr * (`gfm_table_start`, `gfm_table_row`, `gfm_table_cell`, `gfm_table_end`)
10*3dabe4e0SAndreas Gohr * interleaved with whatever inline modes (emphasis, code spans, links, …)
11*3dabe4e0SAndreas Gohr * matched inside the cells. This rewriter:
12*3dabe4e0SAndreas Gohr *
13*3dabe4e0SAndreas Gohr *   1. Groups the flat stream into rows-of-cells, where each cell carries
14*3dabe4e0SAndreas Gohr *      its own list of nested handler calls.
15*3dabe4e0SAndreas Gohr *   2. Strips the empty leading and trailing cells that result from leading
16*3dabe4e0SAndreas Gohr *      and trailing pipes (`| a | b |` → cells `["", " a ", " b ", ""]` →
17*3dabe4e0SAndreas Gohr *      `[" a ", " b "]`).
18*3dabe4e0SAndreas Gohr *   3. Parses the second row as the GFM delimiter row, deriving per-column
19*3dabe4e0SAndreas Gohr *      alignment from `:-+:?` patterns and the column count from the cell
20*3dabe4e0SAndreas Gohr *      count.
21*3dabe4e0SAndreas Gohr *   4. Validates that the header row's cell count matches the delimiter's.
22*3dabe4e0SAndreas Gohr *      On mismatch (spec example 203), emits the captured text back as a
23*3dabe4e0SAndreas Gohr *      single cdata so the Block rewriter wraps it in a paragraph.
24*3dabe4e0SAndreas Gohr *   5. Pads body rows that are short (spec 202) and truncates body rows
25*3dabe4e0SAndreas Gohr *      that are long (spec 204) to the header's column count.
26*3dabe4e0SAndreas Gohr *   6. Trims leading/trailing whitespace from each cell's edge cdata calls
27*3dabe4e0SAndreas Gohr *      ("Spaces between pipes and cell content are trimmed").
28*3dabe4e0SAndreas Gohr *   7. Emits the canonical DokuWiki table call sequence — `table_open`,
29*3dabe4e0SAndreas Gohr *      `tablethead_open`, `tablerow_open`, per-column `tableheader_open`
30*3dabe4e0SAndreas Gohr *      with alignment, `tablethead_close`, then (only when there are
31*3dabe4e0SAndreas Gohr *      body rows — spec 205) `tabletbody_open`, per-row `tablerow_open`
32*3dabe4e0SAndreas Gohr *      with `tablecell_open`, `tabletbody_close`, and finally
33*3dabe4e0SAndreas Gohr *      `table_close`. No new handler instructions are introduced;
34*3dabe4e0SAndreas Gohr *      `tabletbody_open` / `tabletbody_close` are part of DokuWiki's
35*3dabe4e0SAndreas Gohr *      base renderer API but were never emitted before — DW Table omits
36*3dabe4e0SAndreas Gohr *      `<tbody>` entirely. Activating them here is what frees the test
37*3dabe4e0SAndreas Gohr *      renderer from having to track tbody state.
38*3dabe4e0SAndreas Gohr *
39*3dabe4e0SAndreas Gohr * Backslash-escaped pipes (`\|`) are not unescaped here — that is
40*3dabe4e0SAndreas Gohr * GfmEscape's responsibility and applies project-wide. Until that mode
41*3dabe4e0SAndreas Gohr * lands, the literal `\|` survives in cell content. The lexer's cell-
42*3dabe4e0SAndreas Gohr * separator lookbehind ensures the escape at least keeps cells from
43*3dabe4e0SAndreas Gohr * being split on the protected pipe (spec 200, partially).
44*3dabe4e0SAndreas Gohr */
45*3dabe4e0SAndreas Gohrclass GfmTable extends AbstractRewriter
46*3dabe4e0SAndreas Gohr{
47*3dabe4e0SAndreas Gohr    /** @inheritdoc */
48*3dabe4e0SAndreas Gohr    protected function getClosingCall(): string
49*3dabe4e0SAndreas Gohr    {
50*3dabe4e0SAndreas Gohr        return 'gfm_table_end';
51*3dabe4e0SAndreas Gohr    }
52*3dabe4e0SAndreas Gohr
53*3dabe4e0SAndreas Gohr    /** @inheritdoc */
54*3dabe4e0SAndreas Gohr    public function process()
55*3dabe4e0SAndreas Gohr    {
56*3dabe4e0SAndreas Gohr        ['rows' => $rows, 'startPos' => $startPos, 'endPos' => $endPos] = $this->groupRows();
57*3dabe4e0SAndreas Gohr        $rows = array_map($this->stripBoundaryEmpty(...), $rows);
58*3dabe4e0SAndreas Gohr
59*3dabe4e0SAndreas Gohr        $alignments = array_map(
60*3dabe4e0SAndreas Gohr            fn($cell) => $this->parseAlign($this->cellText($cell)),
61*3dabe4e0SAndreas Gohr            $rows[1]
62*3dabe4e0SAndreas Gohr        );
63*3dabe4e0SAndreas Gohr        $cols = count($alignments);
64*3dabe4e0SAndreas Gohr
65*3dabe4e0SAndreas Gohr        // Header / delimiter column-count mismatch is the spec-203 fallback.
66*3dabe4e0SAndreas Gohr        if (count($rows[0]) !== $cols) {
67*3dabe4e0SAndreas Gohr            $this->emitFallback($rows, $startPos);
68*3dabe4e0SAndreas Gohr            return $this->callWriter;
69*3dabe4e0SAndreas Gohr        }
70*3dabe4e0SAndreas Gohr
71*3dabe4e0SAndreas Gohr        $headerRow = $this->trimCellEdges($rows[0]);
72*3dabe4e0SAndreas Gohr        $bodyRows = array_map(
73*3dabe4e0SAndreas Gohr            fn($row) => $this->trimCellEdges($this->padOrTruncate($row, $cols)),
74*3dabe4e0SAndreas Gohr            array_slice($rows, 2)
75*3dabe4e0SAndreas Gohr        );
76*3dabe4e0SAndreas Gohr
77*3dabe4e0SAndreas Gohr        $out = $this->buildOutput($headerRow, $bodyRows, $alignments, $cols, $startPos, $endPos);
78*3dabe4e0SAndreas Gohr        $this->callWriter->writeCalls($out);
79*3dabe4e0SAndreas Gohr        return $this->callWriter;
80*3dabe4e0SAndreas Gohr    }
81*3dabe4e0SAndreas Gohr
82*3dabe4e0SAndreas Gohr    /**
83*3dabe4e0SAndreas Gohr     * Walk $this->calls and bucket them into rows-of-cells-of-calls.
84*3dabe4e0SAndreas Gohr     *
85*3dabe4e0SAndreas Gohr     * @return array{rows: array<int, array<int, array<int, array>>>, startPos: int, endPos: int}
86*3dabe4e0SAndreas Gohr     *   `rows[r][c]` is a list of handler calls captured inside row `r`'s
87*3dabe4e0SAndreas Gohr     *   cell `c`. `startPos` and `endPos` carry the table's opening and
88*3dabe4e0SAndreas Gohr     *   closing source positions.
89*3dabe4e0SAndreas Gohr     */
90*3dabe4e0SAndreas Gohr    protected function groupRows(): array
91*3dabe4e0SAndreas Gohr    {
92*3dabe4e0SAndreas Gohr        $rows = [];
93*3dabe4e0SAndreas Gohr        $rowIdx = -1;
94*3dabe4e0SAndreas Gohr        $startPos = 0;
95*3dabe4e0SAndreas Gohr        $endPos = 0;
96*3dabe4e0SAndreas Gohr
97*3dabe4e0SAndreas Gohr        foreach ($this->calls as $call) {
98*3dabe4e0SAndreas Gohr            switch ($call[0]) {
99*3dabe4e0SAndreas Gohr                case 'gfm_table_start':
100*3dabe4e0SAndreas Gohr                    $startPos = $call[1][0] ?? $call[2];
101*3dabe4e0SAndreas Gohr                    break;
102*3dabe4e0SAndreas Gohr                case 'gfm_table_end':
103*3dabe4e0SAndreas Gohr                    $endPos = $call[2];
104*3dabe4e0SAndreas Gohr                    break;
105*3dabe4e0SAndreas Gohr                case 'gfm_table_row':
106*3dabe4e0SAndreas Gohr                    $rows[] = [];
107*3dabe4e0SAndreas Gohr                    $rowIdx++;
108*3dabe4e0SAndreas Gohr                    break;
109*3dabe4e0SAndreas Gohr                case 'gfm_table_cell':
110*3dabe4e0SAndreas Gohr                    $rows[$rowIdx][] = [];
111*3dabe4e0SAndreas Gohr                    break;
112*3dabe4e0SAndreas Gohr                default:
113*3dabe4e0SAndreas Gohr                    if ($rowIdx >= 0 && !empty($rows[$rowIdx])) {
114*3dabe4e0SAndreas Gohr                        $cellIdx = count($rows[$rowIdx]) - 1;
115*3dabe4e0SAndreas Gohr                        $rows[$rowIdx][$cellIdx][] = $call;
116*3dabe4e0SAndreas Gohr                    }
117*3dabe4e0SAndreas Gohr                    break;
118*3dabe4e0SAndreas Gohr            }
119*3dabe4e0SAndreas Gohr        }
120*3dabe4e0SAndreas Gohr
121*3dabe4e0SAndreas Gohr        return ['rows' => $rows, 'startPos' => $startPos, 'endPos' => $endPos];
122*3dabe4e0SAndreas Gohr    }
123*3dabe4e0SAndreas Gohr
124*3dabe4e0SAndreas Gohr    /**
125*3dabe4e0SAndreas Gohr     * Remove leading and trailing empty cell from given row.
126*3dabe4e0SAndreas Gohr     *
127*3dabe4e0SAndreas Gohr     * Effects of leading and trailing pipes: `| a | b |` parses into four
128*3dabe4e0SAndreas Gohr     * cells `["", " a ", " b ", ""]`. A row with no surrounding pipes
129*3dabe4e0SAndreas Gohr     * (`a | b`) parses into two non-empty cells, which stay untouched.
130*3dabe4e0SAndreas Gohr     *
131*3dabe4e0SAndreas Gohr     * @param array $row a row as a list of cells; each cell is a list of
132*3dabe4e0SAndreas Gohr     *                   handler calls captured between separators
133*3dabe4e0SAndreas Gohr     * @return array the row with at most one boundary empty cell stripped
134*3dabe4e0SAndreas Gohr     *               from each end
135*3dabe4e0SAndreas Gohr     */
136*3dabe4e0SAndreas Gohr    protected function stripBoundaryEmpty(array $row): array
137*3dabe4e0SAndreas Gohr    {
138*3dabe4e0SAndreas Gohr        if ($row && $row[0] === []) array_shift($row);
139*3dabe4e0SAndreas Gohr        if ($row && end($row) === []) array_pop($row);
140*3dabe4e0SAndreas Gohr        return $row;
141*3dabe4e0SAndreas Gohr    }
142*3dabe4e0SAndreas Gohr
143*3dabe4e0SAndreas Gohr    /**
144*3dabe4e0SAndreas Gohr     * Concatenate the original source text of every text-bearing call in a
145*3dabe4e0SAndreas Gohr     * cell. Used for delimiter parsing and the spec-203 fallback.
146*3dabe4e0SAndreas Gohr     *
147*3dabe4e0SAndreas Gohr     * Relies on the project-wide convention that any inline mode which
148*3dabe4e0SAndreas Gohr     * swallows source text records the matched string at args[0] — true
149*3dabe4e0SAndreas Gohr     * for `cdata`, `entity`, `unformatted`, `smiley`, `multiplyentity`,
150*3dabe4e0SAndreas Gohr     * plugin substitutions, etc. Open/close pairs carry empty args and
151*3dabe4e0SAndreas Gohr     * drop out naturally.
152*3dabe4e0SAndreas Gohr     *
153*3dabe4e0SAndreas Gohr     * Motivating case: Entity eats runs of `---` as em-dash entities, so
154*3dabe4e0SAndreas Gohr     * a naive cdata-only join would lose the delimiter dashes and
155*3dabe4e0SAndreas Gohr     * parseAlign() would refuse the column.
156*3dabe4e0SAndreas Gohr     *
157*3dabe4e0SAndreas Gohr     * Implementation: extract every call's args list, extract index 0
158*3dabe4e0SAndreas Gohr     * from each, implode.
159*3dabe4e0SAndreas Gohr     *
160*3dabe4e0SAndreas Gohr     * @param array $cellCalls handler calls captured inside one cell
161*3dabe4e0SAndreas Gohr     * @return string the concatenated source text
162*3dabe4e0SAndreas Gohr     */
163*3dabe4e0SAndreas Gohr    protected function cellText(array $cellCalls): string
164*3dabe4e0SAndreas Gohr    {
165*3dabe4e0SAndreas Gohr        return implode('', array_column(array_column($cellCalls, 1), 0));
166*3dabe4e0SAndreas Gohr    }
167*3dabe4e0SAndreas Gohr
168*3dabe4e0SAndreas Gohr    /**
169*3dabe4e0SAndreas Gohr     * Decode a single delimiter cell into 'left' / 'center' / 'right' / null.
170*3dabe4e0SAndreas Gohr     *
171*3dabe4e0SAndreas Gohr     * Trusts the entry pattern's validation that the cell has the shape
172*3dabe4e0SAndreas Gohr     * `:?-+:?`; just checks for colons at the edges.
173*3dabe4e0SAndreas Gohr     *
174*3dabe4e0SAndreas Gohr     * @param string $cellText the joined source text of one delimiter cell
175*3dabe4e0SAndreas Gohr     * @return string|null 'left', 'center', 'right', or null when no
176*3dabe4e0SAndreas Gohr     *                     alignment marker is present
177*3dabe4e0SAndreas Gohr     */
178*3dabe4e0SAndreas Gohr    protected function parseAlign(string $cellText): ?string
179*3dabe4e0SAndreas Gohr    {
180*3dabe4e0SAndreas Gohr        $trimmed = trim($cellText);
181*3dabe4e0SAndreas Gohr        $left = str_starts_with($trimmed, ':');
182*3dabe4e0SAndreas Gohr        $right = str_ends_with($trimmed, ':');
183*3dabe4e0SAndreas Gohr        return match (true) {
184*3dabe4e0SAndreas Gohr            $left && $right => 'center',
185*3dabe4e0SAndreas Gohr            $right => 'right',
186*3dabe4e0SAndreas Gohr            $left => 'left',
187*3dabe4e0SAndreas Gohr            default => null,
188*3dabe4e0SAndreas Gohr        };
189*3dabe4e0SAndreas Gohr    }
190*3dabe4e0SAndreas Gohr
191*3dabe4e0SAndreas Gohr    /**
192*3dabe4e0SAndreas Gohr     * Return a copy of the row padded with empty cells (spec 202) or
193*3dabe4e0SAndreas Gohr     * truncated to the header column count (spec 204).
194*3dabe4e0SAndreas Gohr     *
195*3dabe4e0SAndreas Gohr     * @param array $row a body row as a list of cells
196*3dabe4e0SAndreas Gohr     * @param int $cols the target column count derived from the delimiter row
197*3dabe4e0SAndreas Gohr     * @return array the row with exactly $cols cells
198*3dabe4e0SAndreas Gohr     */
199*3dabe4e0SAndreas Gohr    protected function padOrTruncate(array $row, int $cols): array
200*3dabe4e0SAndreas Gohr    {
201*3dabe4e0SAndreas Gohr        $count = count($row);
202*3dabe4e0SAndreas Gohr        if ($count < $cols) {
203*3dabe4e0SAndreas Gohr            return array_pad($row, $cols, []);
204*3dabe4e0SAndreas Gohr        }
205*3dabe4e0SAndreas Gohr        if ($count > $cols) {
206*3dabe4e0SAndreas Gohr            return array_slice($row, 0, $cols);
207*3dabe4e0SAndreas Gohr        }
208*3dabe4e0SAndreas Gohr        return $row;
209*3dabe4e0SAndreas Gohr    }
210*3dabe4e0SAndreas Gohr
211*3dabe4e0SAndreas Gohr    /**
212*3dabe4e0SAndreas Gohr     * Return a copy of the row with each cell's first cdata ltrimmed,
213*3dabe4e0SAndreas Gohr     * its last cdata rtrimmed, and any cdata that became empty dropped.
214*3dabe4e0SAndreas Gohr     * Intermediate cdata are left intact so internal spaces are preserved.
215*3dabe4e0SAndreas Gohr     *
216*3dabe4e0SAndreas Gohr     * @param array $row a row as a list of cells
217*3dabe4e0SAndreas Gohr     * @return array the row with each cell's edge cdata trimmed
218*3dabe4e0SAndreas Gohr     */
219*3dabe4e0SAndreas Gohr    protected function trimCellEdges(array $row): array
220*3dabe4e0SAndreas Gohr    {
221*3dabe4e0SAndreas Gohr        return array_map($this->trimCell(...), $row);
222*3dabe4e0SAndreas Gohr    }
223*3dabe4e0SAndreas Gohr
224*3dabe4e0SAndreas Gohr    /**
225*3dabe4e0SAndreas Gohr     * Helper for trimCellEdges: trim edge cdata of a single cell.
226*3dabe4e0SAndreas Gohr     *
227*3dabe4e0SAndreas Gohr     * @param array $cell the cell as a list of handler calls
228*3dabe4e0SAndreas Gohr     * @return array the cell with its first cdata ltrimmed, its last
229*3dabe4e0SAndreas Gohr     *               cdata rtrimmed, and any cdata that became empty
230*3dabe4e0SAndreas Gohr     *               dropped
231*3dabe4e0SAndreas Gohr     */
232*3dabe4e0SAndreas Gohr    protected function trimCell(array $cell): array
233*3dabe4e0SAndreas Gohr    {
234*3dabe4e0SAndreas Gohr        // get all cdata call indexes
235*3dabe4e0SAndreas Gohr        $cdataIdx = array_keys(array_filter($cell, fn($c) => $c[0] === 'cdata'));
236*3dabe4e0SAndreas Gohr        if ($cdataIdx) {
237*3dabe4e0SAndreas Gohr            // if any, trim the first and last one's text
238*3dabe4e0SAndreas Gohr            $cell[$cdataIdx[0]][1][0] = ltrim($cell[$cdataIdx[0]][1][0]);
239*3dabe4e0SAndreas Gohr            $cell[end($cdataIdx)][1][0] = rtrim($cell[end($cdataIdx)][1][0]);
240*3dabe4e0SAndreas Gohr        }
241*3dabe4e0SAndreas Gohr        // return all cells that are not cdate or are not empty after trimming
242*3dabe4e0SAndreas Gohr        return array_values(array_filter(
243*3dabe4e0SAndreas Gohr            $cell,
244*3dabe4e0SAndreas Gohr            fn($c) => $c[0] !== 'cdata' || $c[1][0] !== ''
245*3dabe4e0SAndreas Gohr        ));
246*3dabe4e0SAndreas Gohr    }
247*3dabe4e0SAndreas Gohr
248*3dabe4e0SAndreas Gohr    /**
249*3dabe4e0SAndreas Gohr     * Spec-203 fallback. Reconstruct a `|a|b|`-style line from each row's
250*3dabe4e0SAndreas Gohr     * cells via cellText() and emit the joined block as a single cdata so
251*3dabe4e0SAndreas Gohr     * the Block rewriter wraps it in a paragraph. Because cellText() also
252*3dabe4e0SAndreas Gohr     * walks `entity` / `unformatted` / etc., the source-text delimiter
253*3dabe4e0SAndreas Gohr     * characters survive even when an inline mode consumed them.
254*3dabe4e0SAndreas Gohr     *
255*3dabe4e0SAndreas Gohr     * @param array $rows the captured rows-of-cells-of-calls structure
256*3dabe4e0SAndreas Gohr     * @param int $pos the source position to attach to the emitted cdata
257*3dabe4e0SAndreas Gohr     */
258*3dabe4e0SAndreas Gohr    protected function emitFallback(array $rows, int $pos): void
259*3dabe4e0SAndreas Gohr    {
260*3dabe4e0SAndreas Gohr        $lines = [];
261*3dabe4e0SAndreas Gohr        foreach ($rows as $row) {
262*3dabe4e0SAndreas Gohr            $cellTexts = [];
263*3dabe4e0SAndreas Gohr            foreach ($row as $cell) {
264*3dabe4e0SAndreas Gohr                $cellTexts[] = $this->cellText($cell);
265*3dabe4e0SAndreas Gohr            }
266*3dabe4e0SAndreas Gohr            $lines[] = '|' . implode('|', $cellTexts) . '|';
267*3dabe4e0SAndreas Gohr        }
268*3dabe4e0SAndreas Gohr        $text = implode("\n", $lines);
269*3dabe4e0SAndreas Gohr        if ($text === '') return;
270*3dabe4e0SAndreas Gohr        $this->callWriter->writeCall(['cdata', [$text], $pos]);
271*3dabe4e0SAndreas Gohr    }
272*3dabe4e0SAndreas Gohr
273*3dabe4e0SAndreas Gohr    /**
274*3dabe4e0SAndreas Gohr     * Assemble the canonical DokuWiki table-instruction sequence.
275*3dabe4e0SAndreas Gohr     *
276*3dabe4e0SAndreas Gohr     * `tabletbody_open` / `tabletbody_close` are emitted only when there
277*3dabe4e0SAndreas Gohr     * are body rows. Suppressing them for empty-body tables (spec 205)
278*3dabe4e0SAndreas Gohr     * matches the spec's "<thead> only, no <tbody>" expectation without
279*3dabe4e0SAndreas Gohr     * any state-tracking on the renderer side.
280*3dabe4e0SAndreas Gohr     *
281*3dabe4e0SAndreas Gohr     * @param array $headerRow trimmed header row, one cell per column
282*3dabe4e0SAndreas Gohr     * @param array $bodyRows trimmed body rows, each padded or truncated
283*3dabe4e0SAndreas Gohr     *                        to $cols
284*3dabe4e0SAndreas Gohr     * @param array $alignments per-column alignment from the delimiter
285*3dabe4e0SAndreas Gohr     *                          row; each entry is 'left' / 'center' /
286*3dabe4e0SAndreas Gohr     *                          'right' / null
287*3dabe4e0SAndreas Gohr     * @param int $cols column count derived from the delimiter row
288*3dabe4e0SAndreas Gohr     * @param int $startPos source position of the table's start
289*3dabe4e0SAndreas Gohr     * @param int $endPos source position of the table's end
290*3dabe4e0SAndreas Gohr     * @return array the canonical DokuWiki table call sequence ready for
291*3dabe4e0SAndreas Gohr     *               the outer call writer
292*3dabe4e0SAndreas Gohr     */
293*3dabe4e0SAndreas Gohr    protected function buildOutput(
294*3dabe4e0SAndreas Gohr        array $headerRow,
295*3dabe4e0SAndreas Gohr        array $bodyRows,
296*3dabe4e0SAndreas Gohr        array $alignments,
297*3dabe4e0SAndreas Gohr        int $cols,
298*3dabe4e0SAndreas Gohr        int $startPos,
299*3dabe4e0SAndreas Gohr        int $endPos
300*3dabe4e0SAndreas Gohr    ): array {
301*3dabe4e0SAndreas Gohr        $out = [];
302*3dabe4e0SAndreas Gohr        $out[] = ['table_open', [$cols, 1 + count($bodyRows), $startPos], $startPos];
303*3dabe4e0SAndreas Gohr        $out[] = ['tablethead_open', [], $startPos];
304*3dabe4e0SAndreas Gohr        $out[] = ['tablerow_open', [], $startPos];
305*3dabe4e0SAndreas Gohr        foreach ($headerRow as $i => $cell) {
306*3dabe4e0SAndreas Gohr            $out[] = ['tableheader_open', [1, $alignments[$i], 1], $startPos];
307*3dabe4e0SAndreas Gohr            foreach ($cell as $c) $out[] = $c;
308*3dabe4e0SAndreas Gohr            $out[] = ['tableheader_close', [], $startPos];
309*3dabe4e0SAndreas Gohr        }
310*3dabe4e0SAndreas Gohr        $out[] = ['tablerow_close', [], $startPos];
311*3dabe4e0SAndreas Gohr        $out[] = ['tablethead_close', [], $startPos];
312*3dabe4e0SAndreas Gohr
313*3dabe4e0SAndreas Gohr        if ($bodyRows) {
314*3dabe4e0SAndreas Gohr            $out[] = ['tabletbody_open', [], $startPos];
315*3dabe4e0SAndreas Gohr            foreach ($bodyRows as $row) {
316*3dabe4e0SAndreas Gohr                $out[] = ['tablerow_open', [], $startPos];
317*3dabe4e0SAndreas Gohr                foreach ($row as $i => $cell) {
318*3dabe4e0SAndreas Gohr                    $out[] = ['tablecell_open', [1, $alignments[$i], 1], $startPos];
319*3dabe4e0SAndreas Gohr                    foreach ($cell as $c) $out[] = $c;
320*3dabe4e0SAndreas Gohr                    $out[] = ['tablecell_close', [], $startPos];
321*3dabe4e0SAndreas Gohr                }
322*3dabe4e0SAndreas Gohr                $out[] = ['tablerow_close', [], $startPos];
323*3dabe4e0SAndreas Gohr            }
324*3dabe4e0SAndreas Gohr            $out[] = ['tabletbody_close', [], $startPos];
325*3dabe4e0SAndreas Gohr        }
326*3dabe4e0SAndreas Gohr        $out[] = ['table_close', [$endPos], $endPos];
327*3dabe4e0SAndreas Gohr        return $out;
328*3dabe4e0SAndreas Gohr    }
329*3dabe4e0SAndreas Gohr}
330