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

dg / texy / 21345344909

26 Jan 2026 03:32AM UTC coverage: 92.382% (-0.4%) from 92.744%
21345344909

push

github

dg
HtmlElement: removed toHtml() & toText()

18 of 19 new or added lines in 5 files covered. (94.74%)

149 existing lines in 21 files now uncovered.

2401 of 2599 relevant lines covered (92.38%)

0.92 hits per line

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

95.8
/src/Texy/Modules/HtmlOutputModule.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\HtmlElement;
14
use Texy\Regexp;
15
use function array_intersect, array_keys, array_unshift, max, reset, rtrim, str_repeat, str_replace, strtr, wordwrap;
16

17

18
/**
19
 * Formats and validates HTML output (well-forming, indentation, line wrapping).
20
 */
21
final class HtmlOutputModule extends Texy\Module
22
{
23
        /** indent HTML code? */
24
        public bool $indent = true;
25

26
        /** @var string[] */
27
        public array $preserveSpaces = ['textarea', 'pre', 'script', 'code', 'samp', 'kbd'];
28

29
        /** base indent level */
30
        public int $baseIndent = 0;
31

32
        /** wrap width, doesn't include indent space */
33
        public int $lineWrap = 80;
34

35
        /** indent space counter */
36
        private int $space = 0;
37

38
        /** @var array<string, int> */
39
        private array $tagUsed = [];
40

41
        /** @var array<int, array{tag: string, open: ?string, close: ?string, dtdContent: array<string, int>, indent: int}> */
42
        private array $tagStack = [];
43

44
        /** @var array<string, int>  content DTD used, when context is not defined */
45
        private array $baseDTD = [];
46

47

48
        public function __construct(Texy\Texy $texy)
1✔
49
        {
50
                $this->texy = $texy;
1✔
51
                $texy->addHandler('postProcess', $this->postProcess(...));
1✔
52
        }
1✔
53

54

55
        /**
56
         * Converts <strong><em> ... </strong> ... </em>.
57
         * into <strong><em> ... </em></strong><em> ... </em>
58
         */
59
        private function postProcess(Texy\Texy $texy, string &$s): void
1✔
60
        {
61
                $this->space = $this->baseIndent;
1✔
62
                $this->tagStack = [];
1✔
63
                $this->tagUsed = [];
1✔
64

65
                // special "base content"
66
                $dtd = $texy->getDTD();
1✔
67
                $this->baseDTD = $dtd['div'][1] + $dtd['html'][1] /*+ $dtd['head'][1]*/ + $dtd['body'][1] + ['html' => 1];
1✔
68

69
                // wellform and reformat
70
                $s = Regexp::replace(
1✔
71
                        $s . '</end/>',
1✔
72
                        '~
1✔
73
                                ( [^<]*+ )
74
                                < (?: (!--.*--) | (/?) ([a-z][a-z0-9._:-]*) (|[ \n].*) \s* (/?) ) >
75
                        ~Uis',
76
                        $this->cb(...),
1✔
77
                );
78

79
                // empty out stack
80
                foreach ($this->tagStack as $item) {
1✔
81
                        $s .= $item['close'];
1✔
82
                }
83

84
                // right trim
85
                $s = Regexp::replace($s, '~[\t ]+(\n|\r|$)~', '$1'); // right trim
1✔
86

87
                // join double \r to single \n
88
                $s = str_replace("\r\r", "\n", $s);
1✔
89
                $s = strtr($s, "\r", "\n");
1✔
90

91
                // greedy chars
92
                $s = Regexp::replace($s, '~\x07\ *~', '');
1✔
93
                // back-tabs
94
                $s = Regexp::replace($s, '~\t?\ *\x08~', '');
1✔
95

96
                // line wrap
97
                if ($this->lineWrap > 0) {
1✔
98
                        $s = Regexp::replace(
1✔
99
                                $s,
1✔
100
                                '~^(\t*)(.*)$~m',
1✔
101
                                $this->wrap(...),
1✔
102
                        );
103
                }
104
        }
1✔
105

106

107
        /**
108
         * Callback function: <tag> | </tag> | ....
109
         * @param  string[]  $matches
110
         */
111
        private function cb(array $matches): string
1✔
112
        {
113
                // html tag
114
                [, $mText, $mComment, $mEnd, $mTag, $mAttr, $mEmpty] = $matches;
1✔
115
                // [1] => text
116
                // [1] => !-- comment --
117
                // [2] => /
118
                // [3] => TAG
119
                // [4] => ... (attributes)
120
                // [5] => / (empty)
121

122
                $s = '';
1✔
123

124
                // phase #1 - stuff between tags
125
                if ($mText !== '') {
1✔
126
                        $item = reset($this->tagStack);
1✔
127
                        if ($item && !isset($item['dtdContent'][HtmlElement::InnerText])) {  // text not allowed?
1✔
128

129
                        } elseif (array_intersect(array_keys($this->tagUsed, filter_value: true, strict: false), $this->preserveSpaces)) { // inside pre & textarea preserve spaces
1✔
130
                                $s = Texy\Helpers::freezeSpaces($mText);
1✔
131

132
                        } else {
133
                                $s = Regexp::replace($mText, '~[ \n]+~', ' '); // otherwise shrink multiple spaces
1✔
134
                        }
135
                }
136

137
                // phase #2 - HTML comment
138
                if ($mComment) {
1✔
139
                        return $s . '<' . Texy\Helpers::freezeSpaces($mComment) . '>';
1✔
140
                }
141

142
                // phase #3 - HTML tag
143
                $mEmpty = $mEmpty || isset(HtmlElement::$emptyElements[$mTag]);
1✔
144
                if ($mEmpty && $mEnd) { // bad tag; /end/
1✔
145
                        return $s;
1✔
146
                } elseif ($mEnd) {
1✔
147
                        return $s . $this->processEndTag($mTag);
1✔
148
                } else {
149
                        return $this->processStartTag($mTag, $mEmpty, $mAttr, $s);
1✔
150
                }
151
        }
152

153

154
        private function processStartTag(string $tag, bool $empty, string $attr, string $s): string
1✔
155
        {
156
                $dtdContent = $this->baseDTD;
1✔
157
                $dtd = $this->texy->getDTD();
1✔
158

159
                if (!isset($dtd[$tag])) {
1✔
160
                        // unknown (non-html) tag
161
                        $allowed = true;
×
162
                        $item = reset($this->tagStack);
×
163
                        if ($item) {
×
UNCOV
164
                                $dtdContent = $item['dtdContent'];
×
165
                        }
166
                } else {
167
                        $s .= $this->closeOptionalTags($tag, $dtdContent);
1✔
168

169
                        // is tag allowed in this content?
170
                        $allowed = isset($dtdContent[$tag]);
1✔
171

172
                        // check deep element prohibitions
173
                        if ($allowed && isset(HtmlElement::$prohibits[$tag])) {
1✔
174
                                foreach (HtmlElement::$prohibits[$tag] as $pTag) {
1✔
175
                                        if (!empty($this->tagUsed[$pTag])) {
1✔
176
                                                $allowed = false;
1✔
177
                                                break;
1✔
178
                                        }
179
                                }
180
                        }
181
                }
182

183
                // empty elements se neukladaji do zasobniku
184
                if ($empty) {
1✔
185
                        if (!$allowed) {
1✔
UNCOV
186
                                return $s;
×
187
                        }
188

189
                        $indent = $this->indent && !array_intersect(array_keys($this->tagUsed, filter_value: true, strict: false), $this->preserveSpaces);
1✔
190

191
                        if ($indent && $tag === 'br') { // formatting exception
1✔
192
                                return rtrim($s) . '<' . $tag . $attr . ">\n" . str_repeat("\t", max(0, $this->space - 1)) . "\x07";
1✔
193

194
                        } elseif ($indent && !isset(HtmlElement::$inlineElements[$tag])) {
1✔
195
                                $space = "\r" . str_repeat("\t", $this->space);
1✔
196
                                return $s . $space . '<' . $tag . $attr . '>' . $space;
1✔
197

198
                        } else {
199
                                return $s . '<' . $tag . $attr . '>';
1✔
200
                        }
201
                }
202

203
                $open = null;
1✔
204
                $close = null;
1✔
205
                $indent = 0;
1✔
206

207
                if ($allowed) {
1✔
208
                        $open = '<' . $tag . $attr . '>';
1✔
209

210
                        // receive new content
211
                        if ($tagDTD = $dtd[$tag] ?? null) {
1✔
212
                                if (isset($tagDTD[1][HtmlElement::InnerTransparent])) {
1✔
213
                                        $dtdContent += $tagDTD[1];
1✔
214
                                        unset($dtdContent[HtmlElement::InnerTransparent]);
1✔
215
                                } else {
216
                                        $dtdContent = $tagDTD[1];
1✔
217
                                }
218
                        }
219

220
                        // format output
221
                        if ($this->indent && !isset(HtmlElement::$inlineElements[$tag])) {
1✔
222
                                $close = "\x08" . '</' . $tag . '>' . "\n" . str_repeat("\t", $this->space);
1✔
223
                                $s .= "\n" . str_repeat("\t", $this->space++) . $open . "\x07";
1✔
224
                                $indent = 1;
1✔
225
                        } else {
226
                                $close = '</' . $tag . '>';
1✔
227
                                $s .= $open;
1✔
228
                        }
229

230
                        // TODO: problematic formatting of select / options, object / params
231
                }
232

233
                // open tag, put to stack, increase counter
234
                $item = [
1✔
235
                        'tag' => $tag,
1✔
236
                        'open' => $open,
1✔
237
                        'close' => $close,
1✔
238
                        'dtdContent' => $dtdContent,
1✔
239
                        'indent' => $indent,
1✔
240
                ];
241
                array_unshift($this->tagStack, $item);
1✔
242
                $tmp = &$this->tagUsed[$tag];
1✔
243
                $tmp++;
1✔
244

245
                return $s;
1✔
246
        }
247

248

249
        private function processEndTag(string $tag): string
1✔
250
        {
251
                // has start tag?
252
                if (empty($this->tagUsed[$tag])) {
1✔
253
                        return '';
1✔
254
                }
255

256
                // autoclose tags
257
                $tmp = [];
1✔
258
                $back = true;
1✔
259
                $s = '';
1✔
260
                foreach ($this->tagStack as $i => $item) {
1✔
261
                        $itemTag = $item['tag'];
1✔
262
                        $s .= $item['close'];
1✔
263
                        $this->space -= $item['indent'];
1✔
264
                        $this->tagUsed[$itemTag]--;
1✔
265
                        $back = $back && isset(HtmlElement::$inlineElements[$itemTag]);
1✔
266
                        unset($this->tagStack[$i]);
1✔
267
                        if ($itemTag === $tag) {
1✔
268
                                break;
1✔
269
                        }
270

271
                        array_unshift($tmp, $item);
1✔
272
                }
273

274
                if (!$back || !$tmp) {
1✔
275
                        return $s;
1✔
276
                }
277

278
                // allowed-check (nejspis neni ani potreba)
279
                $item = reset($this->tagStack);
1✔
280
                $dtdContent = $item ? $item['dtdContent'] : $this->baseDTD;
1✔
281
                if (!isset($dtdContent[$tmp[0]['tag']])) {
1✔
UNCOV
282
                        return $s;
×
283
                }
284

285
                // autoopen tags
286
                foreach ($tmp as $item) {
1✔
287
                        $s .= $item['open'];
1✔
288
                        $this->space += $item['indent'];
1✔
289
                        $this->tagUsed[$item['tag']]++;
1✔
290
                        array_unshift($this->tagStack, $item);
1✔
291
                }
292

293
                return $s;
1✔
294
        }
295

296

297
        /** @param  array<string, int>  $dtdContent */
298
        private function closeOptionalTags(string $tag, array &$dtdContent): string
1✔
299
        {
300
                $s = '';
1✔
301
                foreach ($this->tagStack as $i => $item) {
1✔
302
                        // is tag allowed here?
303
                        $dtdContent = $item['dtdContent'];
1✔
304
                        if (isset($dtdContent[$tag])) {
1✔
305
                                break;
1✔
306
                        }
307

308
                        $itemTag = $item['tag'];
1✔
309

310
                        // auto-close hidden, optional and inline tags
311
                        if (
312
                                $item['close']
1✔
313
                                && (
314
                                        !isset(HtmlElement::$optionalEnds[$itemTag])
1✔
315
                                        && !isset(HtmlElement::$inlineElements[$itemTag])
1✔
316
                                )
317
                        ) {
318
                                break;
1✔
319
                        }
320

321
                        // close it
322
                        $s .= $item['close'];
1✔
323
                        $this->space -= $item['indent'];
1✔
324
                        $this->tagUsed[$itemTag]--;
1✔
325
                        unset($this->tagStack[$i]);
1✔
326
                        $dtdContent = $this->baseDTD;
1✔
327
                }
328

329
                return $s;
1✔
330
        }
331

332

333
        /**
334
         * Callback function: wrap lines.
335
         * @param  string[]  $m
336
         */
337
        private function wrap(array $m): string
1✔
338
        {
339
                [, $space, $s] = $m;
1✔
340
                return $space . wordwrap($s, $this->lineWrap, "\n" . $space);
1✔
341
        }
342
}
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