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

dg / texy / 38492668891

14 Oct 2026 12:52AM UTC coverage: 92.573% (-0.5%) from 93.115%
38492668891

push

github

dg
added upgrading.md

2306 of 2491 relevant lines covered (92.57%)

0.93 hits per line

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

90.32
/src/Texy/Modules/TableModule.php
1
<?php declare(strict_types=1);
2

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

8
namespace Texy\Modules;
9

10
use Texy;
11
use Texy\{HtmlElement, Modifier, Patterns, Regexp};
12

13

14
/**
15
 * Processes table syntax with headers, colspan, and rowspan support.
16
 */
17
final class TableModule extends Texy\Module
18
{
19
        /** @deprecated */
20
        public ?string $oddClass = null;
21

22
        /** @deprecated */
23
        public ?string $evenClass = null;
24
        private ?bool $disableTables = null;
25

26

27
        public function __construct(Texy\Texy $texy)
28
        {
29
                $this->texy = $texy;
1 ✔
30

31
                $texy->registerBlockPattern(
1 ✔
32
                        $this->patternTable(...),
1 ✔
33
                        '#^(?:' . Patterns::MODIFIER_HV . '\n)?' // .{color: red}
34
                        . '\|.*()$#mU', // | ....
1 ✔
35
                        'table',
1 ✔
36
                );
37
        }
1 ✔
38

39

40
        /**
41
         * Callback for:.
42
         *
43
         * .(title)[class]{style}>
44
         * |------------------
45
         * | xxx | xxx | xxx | .(..){..}[..]
46
         * |------------------
47
         * | aa | bb | cc |
48
         * @param  string[]  $matches
49
         */
50
        public function patternTable(Texy\BlockParser $parser, array $matches): ?HtmlElement
51
        {
52
                if ($this->disableTables) {
1 ✔
53
                        return null;
1 ✔
54
                }
55

56
                [, $mMod] = $matches;
1 ✔
57
                // [1] => .(title)[class]{style}<>_
58

59
                $texy = $this->texy;
1 ✔
60

61
                $el = new HtmlElement('table');
1 ✔
62
                $mod = new Modifier($mMod);
1 ✔
63
                $mod->decorate($texy, $el);
1 ✔
64

65
                $parser->moveBackward();
1 ✔
66

67
                if ($parser->next('#^\|(\#|\=){2,}(?![|\#=+])(.+)\1*\|?\ *' . Patterns::MODIFIER_H . '?()$#Um', $matches)) {
1 ✔
68
                        [, , $mContent, $mMod] = $matches;
×
69
                        // [1] => # / =
70
                        // [2] => ....
71
                        // [3] => .(title)[class]{style}<>
72

73
                        $caption = $el->create('caption');
×
74
                        $mod = new Modifier($mMod);
×
75
                        $mod->decorate($texy, $caption);
×
76
                        $caption->parseLine($texy, $mContent);
×
77
                }
78

79
                $isHead = false;
1 ✔
80
                $colModifier = [];
1 ✔
81
                $prevRow = []; // rowSpan building helper
1 ✔
82
                $rowCounter = 0;
1 ✔
83
                $colCounter = 0;
1 ✔
84
                $elPart = null;
1 ✔
85

86
                while (true) {
1 ✔
87
                        if ($parser->next('#^\|([=-])[+|=-]{2,}$#Um', $matches)) { // line
1 ✔
88
                                $isHead = !$isHead;
1 ✔
89
                                $prevRow = [];
1 ✔
90
                                continue;
1 ✔
91
                        }
92

93
                        if ($parser->next('#^\|(.*)(?:|\|[\ \t]*' . Patterns::MODIFIER_HV . '?)()$#U', $matches)) {
1 ✔
94
                                // smarter head detection
95
                                if ($rowCounter === 0 && !$isHead && $parser->next('#^\|[=-][+|=-]{2,}$#Um', $foo)) {
1 ✔
96
                                        $isHead = true;
1 ✔
97
                                        $parser->moveBackward();
1 ✔
98
                                }
99

100
                                if ($elPart === null) {
1 ✔
101
                                        $elPart = $el->create($isHead ? 'thead' : 'tbody');
1 ✔
102

103
                                } elseif (!$isHead && $elPart->getName() === 'thead') {
1 ✔
104
                                        $this->finishPart($elPart);
1 ✔
105
                                        $elPart = $el->create('tbody');
1 ✔
106
                                }
107

108
                                [, $mContent, $mMod] = $matches;
1 ✔
109
                                // [1] => ....
110
                                // [2] => .(title)[class]{style}<>_
111

112
                                $elRow = $this->processRow($mContent, $mMod, $isHead, $texy, $prevRow, $colModifier, $colCounter, $rowCounter);
1 ✔
113

114
                                if ($elRow->count()) {
1 ✔
115
                                        $elPart->add($elRow);
1 ✔
116
                                        $rowCounter++;
1 ✔
117
                                } else { // redundant row
118
                                        foreach ($prevRow as $elCell) {
1 ✔
119
                                                $elCell->rowSpan--;
1 ✔
120
                                        }
121
                                }
122

123
                                continue;
1 ✔
124
                        }
125

126
                        break;
1 ✔
127
                }
128

129
                if ($elPart === null) { // invalid table
1 ✔
130
                        return null;
×
131
                }
132

133
                if ($elPart->getName() === 'thead') {
1 ✔
134
                        // thead is optional, tbody is required
135
                        $elPart->setName('tbody');
×
136
                }
137

138
                $this->finishPart($elPart);
1 ✔
139

140
                // event listener
141
                $texy->invokeHandlers('afterTable', [$parser, $el, $mod]);
1 ✔
142

143
                return $el;
1 ✔
144
        }
145

146

147
        /**
148
         * @param  array<int, TableCellElement>  $prevRow
149
         * @param  array<int, Modifier|null>  $colModifier
150
         */
151
        private function processRow(
152
                string $content,
153
                string $mMod,
154
                bool $isHead,
155
                Texy\Texy $texy,
156
                array &$prevRow,
157
                array &$colModifier,
158
                int &$colCounter,
159
                int $rowCounter,
160
        ): HtmlElement
161
        {
162
                $elRow = new HtmlElement('tr');
1 ✔
163
                $mod = new Modifier($mMod);
1 ✔
164
                $mod->decorate($texy, $elRow);
1 ✔
165

166
                $rowClass = $rowCounter % 2 === 0 ? $this->oddClass : $this->evenClass;
1 ✔
167
                if ($rowClass && !isset($mod->classes[$this->oddClass ?? '']) && !isset($mod->classes[$this->evenClass ?? ''])) {
1 ✔
168
                        settype($elRow->attrs['class'], 'array');
×
169
                        $elRow->attrs['class'][] = $rowClass;
×
170
                }
171

172
                $col = 0;
1 ✔
173
                $elCell = null;
1 ✔
174

175
                // special escape sequence \|
176
                $content = str_replace('\|', "\x13", $content);
1 ✔
177
                $content = Regexp::replace($content, '#(\[[^\]]*)\|#', "$1\x13"); // HACK: support for [..|..]
1 ✔
178

179
                foreach (explode('|', $content) as $cell) {
1 ✔
180
                        $cell = strtr($cell, "\x13", '|');
1 ✔
181
                        // rowSpan
182
                        if (isset($prevRow[$col]) && ($matches = Regexp::match($cell, '#\^[\ \t]*$|\*??(.*)[\ \t]+\^$#AU'))) {
1 ✔
183
                                $prevRow[$col]->rowSpan++;
1 ✔
184
                                $cell = $matches[1] ?? '';
1 ✔
185
                                $prevRow[$col]->text .= "\n" . $cell;
1 ✔
186
                                $col += $prevRow[$col]->colSpan;
1 ✔
187
                                $elCell = null;
1 ✔
188
                                continue;
1 ✔
189
                        }
190

191
                        // colSpan
192
                        if ($cell === '' && $elCell) {
1 ✔
193
                                $elCell->colSpan++;
1 ✔
194
                                unset($prevRow[$col]);
1 ✔
195
                                $col++;
1 ✔
196
                                continue;
1 ✔
197
                        }
198

199
                        // common cell
200
                        if ($elCell = $this->processCell($cell, $colModifier[$col], $isHead, $texy)) {
1 ✔
201
                                $elRow->add($elCell);
1 ✔
202
                                $prevRow[$col] = $elCell;
1 ✔
203
                                $col++;
1 ✔
204
                        }
205
                }
206

207
                // even up with empty cells
208
                while ($col < $colCounter) {
1 ✔
209
                        $elCell = new TableCellElement;
1 ✔
210
                        $elCell->setName($isHead ? 'th' : 'td');
1 ✔
211
                        if (isset($colModifier[$col])) {
1 ✔
212
                                $colModifier[$col]->decorate($texy, $elCell);
×
213
                        }
214

215
                        $elRow->add($elCell);
1 ✔
216
                        $prevRow[$col] = $elCell;
1 ✔
217
                        $col++;
1 ✔
218
                }
219

220
                $colCounter = $col;
1 ✔
221
                return $elRow;
1 ✔
222
        }
223

224

225
        private function processCell(
226
                string $cell,
227
                ?Modifier &$cellModifier,
228
                bool $isHead,
229
                Texy\Texy $texy,
230
        ): ?TableCellElement
231
        {
232
                $matches = Regexp::match($cell, '#(\*??)[\ \t]*' . Patterns::MODIFIER_HV . '??(.*)' . Patterns::MODIFIER_HV . '?[\ \t]*()$#AU');
1 ✔
233
                if (!$matches) {
1 ✔
234
                        return null;
×
235
                }
236

237
                [, $mHead, $mModCol, $mContent, $mMod] = $matches;
1 ✔
238
                // [1] => * ^
239
                // [2] => .(title)[class]{style}<>_
240
                // [3] => ....
241
                // [4] => .(title)[class]{style}<>_
242

243
                if ($mModCol) {
1 ✔
244
                        $cellModifier = new Modifier($mModCol);
×
245
                }
246

247
                $mod = $cellModifier ? clone $cellModifier : new Modifier;
1 ✔
248
                $mod->setProperties($mMod);
1 ✔
249

250
                $elCell = new TableCellElement;
1 ✔
251
                $elCell->setName($isHead || ($mHead === '*') ? 'th' : 'td');
1 ✔
252
                $mod->decorate($texy, $elCell);
1 ✔
253
                $elCell->text = $mContent;
1 ✔
254
                return $elCell;
1 ✔
255
        }
256

257

258
        /**
259
         * Parse text in all cells.
260
         */
261
        private function finishPart(HtmlElement $elPart): void
262
        {
263
                foreach ($elPart->getChildren() as $elRow) {
1 ✔
264
                        assert($elRow instanceof HtmlElement);
265
                        foreach ($elRow->getChildren() as $elCell) {
1 ✔
266
                                assert($elCell instanceof TableCellElement);
267
                                if ($elCell->colSpan > 1) {
1 ✔
268
                                        $elCell->attrs['colspan'] = $elCell->colSpan;
1 ✔
269
                                }
270

271
                                if ($elCell->rowSpan > 1) {
1 ✔
272
                                        $elCell->attrs['rowspan'] = $elCell->rowSpan;
1 ✔
273
                                }
274

275
                                $text = rtrim((string) $elCell->text);
1 ✔
276
                                if (str_contains($text, "\n")) {
1 ✔
277
                                        // multiline parse as block
278
                                        // HACK: disable tables
279
                                        $this->disableTables = true;
1 ✔
280
                                        $elCell->parseBlock($this->texy, Texy\Helpers::outdent($text));
1 ✔
281
                                        $this->disableTables = false;
1 ✔
282
                                } else {
283
                                        $elCell->parseLine($this->texy, ltrim($text));
1 ✔
284
                                }
285

286
                                if ($elCell->getText() === '') {
1 ✔
287
                                        $elCell->setText("\u{A0}"); // &nbsp;
1 ✔
288
                                }
289
                        }
290
                }
291
        }
1 ✔
292
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc