• 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

80.28
/src/Texy/Modules/HtmlModule.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, Patterns};
12
use function count, is_array, is_string;
13

14

15
/**
16
 * Processes HTML tags and comments in input text.
17
 */
18
final class HtmlModule extends Texy\Module
19
{
20
        /** pass HTML comments to output? */
21
        public bool $passComment = true;
22

23

24
        public function __construct(Texy\Texy $texy)
25
        {
26
                $this->texy = $texy;
1 ✔
27

28
                $texy->addHandler('htmlComment', $this->solveComment(...));
1 ✔
29
                $texy->addHandler('htmlTag', $this->solveTag(...));
1 ✔
30

31
                $texy->registerLinePattern(
1 ✔
32
                        $this->patternTag(...),
1 ✔
33
                        '#<(/?)([a-z][a-z0-9_:-]{0,50})((?:\s++[a-z0-9\_:-]++|=\s*+"[^"'
34
                        . Patterns::MARK
35
                        . ']*+"|=\s*+\'[^\''
36
                        . Patterns::MARK
37
                        . ']*+\'|=[^\s>'
38
                        . Patterns::MARK
39
                        . ']++)*)\s*+(/?)>#isu',
1 ✔
40
                        'html/tag',
1 ✔
41
                );
42

43
                $texy->registerLinePattern(
1 ✔
44
                        $this->patternComment(...),
1 ✔
45
                        '#<!--([^' . Patterns::MARK . ']*?)-->#is',
1 ✔
46
                        'html/comment',
1 ✔
47
                );
48
        }
1 ✔
49

50

51
        /**
52
         * Callback for: <!-- comment -->.
53
         * @param  string[]  $matches
54
         */
55
        public function patternComment(Texy\LineParser $parser, array $matches): HtmlElement|string|null
56
        {
57
                [, $mComment] = $matches;
1 ✔
58
                return $this->texy->invokeAroundHandlers('htmlComment', $parser, [$mComment]);
1 ✔
59
        }
60

61

62
        /**
63
         * Callback for: <tag attr="...">.
64
         * @param  string[]  $matches
65
         */
66
        public function patternTag(Texy\LineParser $parser, array $matches): ?string
67
        {
68
                [, $mEnd, $mTag, $mAttr, $mEmpty] = $matches;
1 ✔
69
                // [1] => /
70
                // [2] => tag
71
                // [3] => attributes
72
                // [4] => /
73

74
                $isStart = $mEnd !== '/';
1 ✔
75
                $isEmpty = $mEmpty === '/';
1 ✔
76
                if (!$isEmpty && str_ends_with($mAttr, '/')) { // uvizlo v $mAttr?
1 ✔
77
                        $mAttr = substr($mAttr, 0, -1);
×
78
                        $isEmpty = true;
×
79
                }
80

81
                // error - can't close empty element
82
                if ($isEmpty && !$isStart) {
1 ✔
83
                        return null;
×
84
                }
85

86
                // error - end element with atttrs
87
                $mAttr = trim(strtr($mAttr, "\n", ' '));
1 ✔
88
                if ($mAttr && !$isStart) {
1 ✔
89
                        return null;
1 ✔
90
                }
91

92
                $el = new HtmlElement($mTag);
1 ✔
93
                if ($isStart) {
1 ✔
94
                        $el->attrs = $this->parseAttributes($mAttr);
1 ✔
95
                }
96

97
                $res = $this->texy->invokeAroundHandlers('htmlTag', $parser, [$el, $isStart, $isEmpty]);
1 ✔
98

99
                if ($res instanceof HtmlElement) {
1 ✔
100
                        return $this->texy->protect($isStart ? $res->startTag() : $res->endTag(), $res->getContentType());
1 ✔
101
                }
102

103
                return $res;
1 ✔
104
        }
105

106

107
        /**
108
         * Finish invocation.
109
         */
110
        private function solveTag(
111
                Texy\HandlerInvocation $invocation,
112
                HtmlElement $el,
113
                bool $isStart,
114
                ?bool $forceEmpty = null,
115
        ): ?HtmlElement
116
        {
117
                $texy = $this->texy;
1 ✔
118

119
                // tag & attibutes
120
                $allowedTags = $texy->allowedTags; // speed-up
1 ✔
121
                if (!$allowedTags) {
1 ✔
122
                        return null; // all tags are disabled
1 ✔
123
                }
124

125
                // convert case
126
                $name = $el->getName();
1 ✔
127
                assert($name !== null);
128
                $lower = strtolower($name);
1 ✔
129
                if (isset($texy->getDTD()[$lower]) || $name === strtoupper($name)) {
1 ✔
130
                        // complete UPPER convert to lower
131
                        $name = $lower;
1 ✔
132
                        $el->setName($name);
1 ✔
133
                }
134

135
                if (is_array($allowedTags)) {
1 ✔
136
                        if (!isset($allowedTags[$name])) {
1 ✔
137
                                return null;
1 ✔
138
                        }
139
                } else { // allowedTags === Texy\Texy::ALL
140
                        if ($forceEmpty) {
1 ✔
141
                                $el->setName($name, empty: true);
1 ✔
142
                        }
143
                }
144

145
                // end tag? we are finished
146
                if (!$isStart) {
1 ✔
147
                        return $el;
1 ✔
148
                }
149

150
                $this->applyAttrs($el->attrs, is_array($allowedTags) ? $allowedTags[$name] : $texy::ALL);
1 ✔
151
                $this->applyClasses($el->attrs, $texy->getAllowedProps()[0]);
1 ✔
152
                $this->applyStyles($el->attrs, $texy->getAllowedProps()[1]);
1 ✔
153
                if (!$this->validateAttrs($el, $texy)) {
1 ✔
154
                        return null;
1 ✔
155
                }
156

157
                $el->validateAttrs($texy->getDTD());
1 ✔
158

159
                return $el;
1 ✔
160
        }
161

162

163
        /**
164
         * Finish invocation.
165
         */
166
        private function solveComment(Texy\HandlerInvocation $invocation, string $content): string
167
        {
168
                if (!$this->passComment) {
1 ✔
169
                        return '';
×
170
                }
171

172
                // sanitize comment
173
                $content = Texy\Regexp::replace($content, '#-{2,}#', ' - ');
1 ✔
174
                $content = trim($content, '-');
1 ✔
175

176
                return $this->texy->protect('<!--' . $content . '-->', Texy\Texy::CONTENT_MARKUP);
1 ✔
177
        }
178

179

180
        /**
181
         * @param  array<string, array<string|int|bool>|string|int|bool|null>  $attrs
182
         * @param  bool|string[]  $allowedAttrs
183
         */
184
        private function applyAttrs(array &$attrs, bool|array $allowedAttrs): void
185
        {
186
                if (!$allowedAttrs) {
1 ✔
187
                        $attrs = [];
1 ✔
188

189
                } elseif (is_array($allowedAttrs)) {
1 ✔
190
                        // skip disabled
191
                        $allowedAttrs = array_flip($allowedAttrs);
1 ✔
192
                        foreach ($attrs as $key => $foo) {
1 ✔
193
                                if (!isset($allowedAttrs[$key])) {
1 ✔
194
                                        unset($attrs[$key]);
×
195
                                }
196
                        }
197
                }
198
        }
1 ✔
199

200

201
        /**
202
         * @param  array<string, string|int|bool|array<string|int|bool>|null>  $attrs
203
         * @param  array<string, int>|bool  $allowedClasses
204
         */
205
        private function applyClasses(array &$attrs, bool|array $allowedClasses): void
206
        {
207
                if (!isset($attrs['class'])) {
1 ✔
208
                } elseif (is_array($allowedClasses)) {
1 ✔
209
                        $attrs['class'] = is_string($attrs['class']) ? explode(' ', $attrs['class']) : (array) $attrs['class'];
×
210
                        foreach ($attrs['class'] as $key => $value) {
×
211
                                if (!isset($allowedClasses[$value])) {
×
212
                                        unset($attrs['class'][$key]); // id & class are case-sensitive
×
213
                                }
214
                        }
215
                } elseif ($allowedClasses !== Texy\Texy::ALL) {
1 ✔
216
                        $attrs['class'] = null;
×
217
                }
218

219
                if (!isset($attrs['id'])) {
1 ✔
220
                } elseif (is_array($allowedClasses)) {
1 ✔
221
                        if (!is_string($attrs['id']) || !isset($allowedClasses['#' . $attrs['id']])) {
×
222
                                $attrs['id'] = null;
×
223
                        }
224

225
                } elseif ($allowedClasses !== Texy\Texy::ALL) {
1 ✔
226
                        $attrs['id'] = null;
×
227
                }
228
        }
1 ✔
229

230

231
        /**
232
         * @param  array<string, string|int|bool|array<string|int|bool>|null>  $attrs
233
         * @param  array<string, int>|bool  $allowedStyles
234
         */
235
        private function applyStyles(array &$attrs, bool|array $allowedStyles): void
236
        {
237
                if (!isset($attrs['style'])) {
1 ✔
238
                } elseif (is_array($allowedStyles)) {
1 ✔
239
                        if (is_string($attrs['style'])) {
×
240
                                $parts = explode(';', $attrs['style']);
×
241
                                $attrs['style'] = [];
×
242
                                foreach ($parts as $value) {
×
243
                                        if (count($pair = explode(':', $value, 2)) === 2) {
×
244
                                                $attrs['style'][trim($pair[0])] = trim($pair[1]);
×
245
                                        }
246
                                }
247
                        } else {
248
                                $attrs['style'] = (array) $attrs['style'];
×
249
                        }
250

251
                        foreach ($attrs['style'] as $key => $value) {
×
252
                                if (!isset($allowedStyles[strtolower((string) $key)])) { // CSS is case-insensitive
×
253
                                        unset($attrs['style'][$key]);
×
254
                                }
255
                        }
256
                } elseif ($allowedStyles !== Texy\Texy::ALL) {
1 ✔
257
                        $attrs['style'] = null;
×
258
                }
259
        }
1 ✔
260

261

262
        private function validateAttrs(HtmlElement $el, Texy\Texy $texy): bool
263
        {
264
                foreach (['src', 'href', 'name', 'id'] as $attr) {
1 ✔
265
                        if (isset($el->attrs[$attr])) {
1 ✔
266
                                $el->attrs[$attr] = is_string($el->attrs[$attr])
1 ✔
267
                                        ? trim($el->attrs[$attr])
1 ✔
268
                                        : '';
×
269
                                if ($el->attrs[$attr] === '') {
1 ✔
270
                                        unset($el->attrs[$attr]);
×
271
                                }
272
                        }
273
                }
274

275
                $name = $el->getName();
1 ✔
276
                if ($name === 'img') {
1 ✔
277
                        if (!isset($el->attrs['src'])) {
1 ✔
278
                                return false;
×
279
                        }
280

281
                        assert(is_string($el->attrs['src']));
282
                        if (!$texy->checkURL($el->attrs['src'], $texy::FILTER_IMAGE)) {
1 ✔
283
                                return false;
×
284
                        }
285

286
                        $texy->summary['images'][] = $el->attrs['src'];
1 ✔
287

288
                } elseif ($name === 'a') {
1 ✔
289
                        if (!isset($el->attrs['href']) && !isset($el->attrs['name']) && !isset($el->attrs['id'])) {
1 ✔
290
                                return false;
1 ✔
291
                        }
292

293
                        if (isset($el->attrs['href'])) {
1 ✔
294
                                assert(is_string($el->attrs['href']));
295
                                if ($texy->linkModule->forceNoFollow && str_contains($el->attrs['href'], '//')) {
1 ✔
296
                                        settype($el->attrs['rel'], 'array');
1 ✔
297
                                        $el->attrs['rel'][] = 'nofollow';
1 ✔
298
                                }
299

300
                                if (!$texy->checkURL($el->attrs['href'], $texy::FILTER_ANCHOR)) {
1 ✔
301
                                        return false;
1 ✔
302
                                }
303

304
                                $texy->summary['links'][] = $el->attrs['href'];
1 ✔
305
                        }
306

307
                } elseif (preg_match('#^h[1-6]#i', $name ?? '')) {
1 ✔
308
                        $texy->headingModule->TOC[] = [
1 ✔
309
                                'el' => $el,
1 ✔
310
                                'level' => (int) substr($name, 1),
1 ✔
311
                                'type' => 'html',
1 ✔
312
                        ];
313
                }
314

315
                return true;
1 ✔
316
        }
317

318

319
        /** @return array<string, string|bool> */
320
        private function parseAttributes(string $attrs): array
321
        {
322
                $matches = $res = [];
1 ✔
323
                preg_match_all(
1 ✔
324
                        '#([a-z0-9\_:-]+)\s*(?:=\s*(\'[^\']*\'|"[^"]*"|[^\'"\s]+))?()#isu',
1 ✔
325
                        $attrs,
326
                        $matches,
327
                        PREG_SET_ORDER,
1 ✔
328
                );
329

330
                foreach ($matches as $m) {
1 ✔
331
                        $key = strtolower($m[1]);
1 ✔
332
                        $value = $m[2];
1 ✔
333
                        if ($value === '') {
1 ✔
334
                                $res[$key] = true;
1 ✔
335
                        } elseif ($value[0] === '\'' || $value[0] === '"') {
1 ✔
336
                                $res[$key] = Texy\Helpers::unescapeHtml(substr($value, 1, -1));
1 ✔
337
                        } else {
338
                                $res[$key] = Texy\Helpers::unescapeHtml($value);
1 ✔
339
                        }
340
                }
341

342
                return $res;
1 ✔
343
        }
344
}
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