• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / texy / 21501721037

30 Jan 2026 02:00AM UTC coverage: 91.159% (-1.3%) from 92.426%
21501721037

push

github

dg
wip

2681 of 2941 relevant lines covered (91.16%)

0.91 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.31
/src/Texy/Modules/TableModule.php
1
<?php
2

3
/**
4
 * This file is part of the Texy! (https://texy.nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
declare(strict_types=1);
9

10
namespace Texy\Modules;
11

12
use Texy;
13
use Texy\Modifier;
14
use Texy\Nodes\ContentNode;
15
use Texy\Nodes\TableCellNode;
16
use Texy\Nodes\TableNode;
17
use Texy\Nodes\TableRowNode;
18
use Texy\ParseContext;
19
use Texy\Patterns;
20
use Texy\Position;
21
use Texy\Regexp;
22
use Texy\Syntax;
23
use function count, ltrim, rtrim, str_contains, strlen;
24

25

26
/**
27
 * Table module.
28
 */
29
final class TableModule extends Texy\Module
30
{
31
        private bool $disableTables = false;
32

33

34
        public function __construct(
1✔
35
                private Texy\Texy $texy,
36
        ) {
37
        }
1✔
38

39

40
        public function beforeParse(string &$text): void
1✔
41
        {
42
                $this->texy->registerBlockPattern(
1✔
43
                        $this->parseTable(...),
1✔
44
                        '~^
45
                                (?:' . Patterns::MODIFIER_HV . '\n)? # modifier (1)
1✔
46
                                \|                                   # table start
47
                                .*                                   # content
48
                        $~mU',
49
                        Syntax::Table,
1✔
50
                );
51
        }
1✔
52

53

54
        /**
55
         * Parses tables.
56
         * @param  array<?string>  $matches
57
         * @param  array<?int>  $offsets
58
         */
59
        public function parseTable(ParseContext $context, array $matches, array $offsets): ?TableNode
1✔
60
        {
61
                if ($this->disableTables) {
1✔
62
                        return null;
1✔
63
                }
64

65
                [, $mMod] = $matches;
1✔
66

67
                $startOffset = $offsets[0];
1✔
68

69
                $context->getBlockParser()->moveBackward();
1✔
70

71
                $rows = [];
1✔
72
                $isHead = false;
1✔
73
                /** @var array<int, array{node: TableCellNode, text: string}> $prevRow */
74
                $prevRow = [];
1✔
75
                $colModifier = [];
1✔
76
                $colCounter = 0;
1✔
77
                /** @var \SplObjectStorage<TableCellNode, array{text: string, offset: int}> $cellTexts */
78
                $cellTexts = new \SplObjectStorage;
1✔
79

80
                while (true) {
1✔
81
                        $lineMatches = null;
1✔
82
                        $lineOffsets = null;
1✔
83
                        if ($context->getBlockParser()->next('~^ \| ([=-]) [+|=-]{2,} $~Um', $lineMatches, $lineOffsets)) {
1✔
84
                                $isHead = !$isHead;
1✔
85
                                $prevRow = [];
1✔
86
                                continue;
1✔
87
                        }
88

89
                        if ($context->getBlockParser()->next('~^ ( \| ) (.*) (?: | \| [ \t]* ' . Patterns::MODIFIER_HV . '?)$~U', $lineMatches, $lineOffsets)) {
1✔
90
                                // smarter head detection: if first row is followed by separator line, it's a head row
91
                                if (count($rows) === 0 && !$isHead && $context->getBlockParser()->next('~^ \| [=-] [+|=-]{2,} $~Um', $foo)) {
1✔
92
                                        $isHead = true;
1✔
93
                                        $context->getBlockParser()->moveBackward();
1✔
94
                                }
95

96
                                [, , $mContent, $mRowMod] = $lineMatches;
1✔
97
                                $lineBaseOffset = $lineOffsets[2] ?? $lineOffsets[0]; // offset of content after first |
1✔
98

99
                                $cells = [];
1✔
100
                                $originalContent = $mContent;
1✔
101
                                $content = str_replace('\|', "\x13", $mContent);
1✔
102
                                $content = Regexp::replace($content, '~(\[[^]]*)\|~', "$1\x13");
1✔
103

104
                                $col = 0;
1✔
105
                                $lastCell = null;
1✔
106
                                $cellOffset = 0; // position within $content
1✔
107

108
                                foreach (explode('|', $content) as $cellIndex => $cell) {
1✔
109
                                        $originalCell = $cell;
1✔
110
                                        $cell = strtr($cell, "\x13", '|');
1✔
111
                                        $cellAbsoluteOffset = $lineBaseOffset + $cellOffset;
1✔
112

113
                                        // rowSpan: ^ at end of cell or cell is just ^
114
                                        if (isset($prevRow[$col]) && ($m = Regexp::match($cell, '~\^[ \t]*$|\*??(.*)[ \t]+\^$~AU'))) {
1✔
115
                                                $prevRow[$col]['node']->rowspan++;
1✔
116
                                                $cellText = $m[1] ?? '';
1✔
117
                                                // Append text to the cell above
118
                                                $data = $cellTexts[$prevRow[$col]['node']];
1✔
119
                                                $data['text'] .= "\n" . $cellText;
1✔
120
                                                $cellTexts[$prevRow[$col]['node']] = $data;
1✔
121
                                                $col += $prevRow[$col]['node']->colspan;
1✔
122
                                                $lastCell = null;
1✔
123
                                                $cellOffset += strlen($originalCell) + 1; // +1 for |
1✔
124
                                                continue;
1✔
125
                                        }
126

127
                                        // colSpan: empty cell extends previous cell
128
                                        if ($cell === '' && $lastCell !== null) {
1✔
129
                                                $lastCell->colspan++;
1✔
130
                                                unset($prevRow[$col]);
1✔
131
                                                $col++;
1✔
132
                                                $cellOffset += strlen($originalCell) + 1;
1✔
133
                                                continue;
1✔
134
                                        }
135

136
                                        // common cell
137
                                        $cellMatches = Regexp::match($cell, '~
1✔
138
                                                ( \*?? )                          # head mark (1)
139
                                                [ \t]*
140
                                                ' . Patterns::MODIFIER_HV . '??   # modifier (2)
1✔
141
                                                (.*)                              # content (3)
142
                                                ' . Patterns::MODIFIER_HV . '?    # modifier (4)
1✔
143
                                                [ \t]*
144
                                        $~AU', captureOffset: true);
1✔
145

146
                                        if ($cellMatches) {
1✔
147
                                                $mHead = $cellMatches[1][0];
1✔
148
                                                $mModCol = $cellMatches[2][0];
1✔
149
                                                $mCellContent = $cellMatches[3][0];
1✔
150
                                                $mCellContentOffset = $cellMatches[3][1];
1✔
151
                                                $mCellMod = $cellMatches[4][0];
1✔
152

153
                                                $cellIsHeader = $isHead || ($mHead === '*');
1✔
154

155
                                                // column modifier inheritance
156
                                                if ($mModCol) {
1✔
157
                                                        $colModifier[$col] = Modifier::parse($mModCol);
×
158
                                                }
159
                                                $cellMod = isset($colModifier[$col]) ? clone $colModifier[$col] : new Modifier;
1✔
160
                                                $cellMod->setProperties($mCellMod);
1✔
161

162
                                                // Calculate absolute offset of cell content
163
                                                $contentAbsoluteOffset = $cellAbsoluteOffset + $mCellContentOffset;
1✔
164

165
                                                // Create cell node - text will be parsed later
166
                                                $lastCell = new TableCellNode(new ContentNode, 1, 1, $cellIsHeader, $cellMod);
1✔
167
                                                $cells[] = $lastCell;
1✔
168
                                                $cellTexts[$lastCell] = ['text' => $mCellContent, 'offset' => $contentAbsoluteOffset];
1✔
169
                                                $prevRow[$col] = ['node' => $lastCell, 'text' => $mCellContent];
1✔
170
                                                $col++;
1✔
171
                                        }
172

173
                                        $cellOffset += strlen($originalCell) + 1; // +1 for |
1✔
174
                                }
175

176
                                // even up with empty cells
177
                                while ($col < $colCounter) {
1✔
178
                                        $cellMod = isset($colModifier[$col]) ? clone $colModifier[$col] : new Modifier;
1✔
179
                                        $emptyCell = new TableCellNode(new ContentNode, 1, 1, $isHead, $cellMod);
1✔
180
                                        $cells[] = $emptyCell;
1✔
181
                                        $cellTexts[$emptyCell] = ['text' => '', 'offset' => 0];
1✔
182
                                        $prevRow[$col] = ['node' => $emptyCell, 'text' => ''];
1✔
183
                                        $col++;
1✔
184
                                }
185

186
                                $colCounter = $col;
1✔
187

188
                                if ($cells) {
1✔
189
                                        $rowMod = Modifier::parse($mRowMod);
1✔
190
                                        $rows[] = new TableRowNode($cells, $isHead, $rowMod);
1✔
191
                                } else {
192
                                        // redundant row - decrement rowspan
193
                                        foreach ($prevRow as $item) {
1✔
194
                                                $item['node']->rowspan--;
1✔
195
                                        }
196
                                }
197

198
                                continue;
1✔
199
                        }
200

201
                        break;
1✔
202
                }
203

204
                if (!$rows) {
1✔
205
                        return null;
×
206
                }
207

208
                // Parse cell text content after rowspan/colspan is determined
209
                foreach ($rows as $row) {
1✔
210
                        foreach ($row->cells as $cell) {
1✔
211
                                if (isset($cellTexts[$cell])) {
1✔
212
                                        $data = $cellTexts[$cell];
1✔
213
                                        $text = rtrim((string) $data['text']);
1✔
214
                                        $baseOffset = $data['offset'];
1✔
215

216
                                        if (str_contains($text, "\n")) {
1✔
217
                                                // multiline - parse as block (disable nested tables)
218
                                                $this->disableTables = true;
1✔
219
                                                $cell->content->children = $context->parseBlock(Texy\Helpers::outdent($text), $baseOffset)->children;
1✔
220
                                                $this->disableTables = false;
1✔
221
                                        } else {
222
                                                // single line - parse as inline
223
                                                $trimmed = ltrim($text);
1✔
224
                                                $trimOffset = strlen($text) - strlen($trimmed);
1✔
225
                                                $cell->content->children = $context->parseInline($trimmed, $baseOffset + $trimOffset)->children;
1✔
226
                                        }
227

228
                                        // empty cell gets &nbsp;
229
                                        if ($cell->content->children === []) {
1✔
230
                                                $cell->content->children = [new Texy\Nodes\TextNode("\u{A0}")];
1✔
231
                                        }
232
                                }
233
                        }
234
                }
235

236
                return new TableNode(
1✔
237
                        $rows,
1✔
238
                        Modifier::parse($mMod),
1✔
239
                        new Position($startOffset, strlen($matches[0])),
1✔
240
                );
241
        }
242
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc