• 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

98.53
/src/Texy/Modules/HtmlOutputModule.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, Regexp};
12

13

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

22
        /** @var string[] */
23
        public array $preserveSpaces = ['textarea', 'pre', 'script', 'code', 'samp', 'kbd'];
24

25
        /** base indent level */
26
        public int $baseIndent = 0;
27

28
        /** wrap width, doesn't include indent space */
29
        public int $lineWrap = 80;
30

31
        /** indent space counter */
32
        private int $space = 0;
33

34
        /** @var array<string, int> */
35
        private array $tagUsed = [];
36

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

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

43

44
        public function __construct(Texy\Texy $texy)
45
        {
46
                $this->texy = $texy;
1 ✔
47
                $texy->addHandler('postProcess', $this->postProcess(...));
1 ✔
48
        }
1 ✔
49

50

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

61
                // special "base content"
62
                $dtd = $texy->getDTD();
1 ✔
63
                $this->baseDTD = $dtd['div'][1] + $dtd['html'][1] /* + $dtd['head'][1] */ + $dtd['body'][1] + ['html' => 1];
1 ✔
64

65
                // wellform and reformat
66
                $s = Regexp::replace(
1 ✔
67
                        $s . '</end/>',
1 ✔
68
                        '#([^<]*+)<(?:(!--.*--)|(/?)([a-z][a-z0-9._:-]*)(|[ \n].*)\s*(/?))>()#Uis',
1 ✔
69
                        $this->cb(...),
1 ✔
70
                );
71

72
                // empty out stack
73
                foreach ($this->tagStack as $item) {
1 ✔
74
                        $s .= $item['close'];
1 ✔
75
                }
76

77
                // right trim
78
                $s = Regexp::replace($s, "#[\t ]+(\n|\r|$)#", '$1'); // right trim
1 ✔
79

80
                // join double \r to single \n
81
                $s = str_replace("\r\r", "\n", $s);
1 ✔
82
                $s = strtr($s, "\r", "\n");
1 ✔
83

84
                // greedy chars
85
                $s = Regexp::replace($s, '#\x07 *#', '');
1 ✔
86
                // back-tabs
87
                $s = Regexp::replace($s, '#\t? *\x08#', '');
1 ✔
88

89
                // line wrap
90
                if ($this->lineWrap > 0) {
1 ✔
91
                        $s = Regexp::replace(
1 ✔
92
                                $s,
93
                                '#^(\t*)(.*)$#m',
1 ✔
94
                                $this->wrap(...),
1 ✔
95
                        );
96
                }
97
        }
1 ✔
98

99

100
        /**
101
         * Callback function: <tag> | </tag> | ....
102
         * @param  string[]  $matches
103
         */
104
        private function cb(array $matches): string
105
        {
106
                // html tag
107
                [, $mText, $mComment, $mEnd, $mTag, $mAttr, $mEmpty] = $matches;
1 ✔
108
                // [1] => text
109
                // [1] => !-- comment --
110
                // [2] => /
111
                // [3] => TAG
112
                // [4] => ... (attributes)
113
                // [5] => / (empty)
114

115
                $s = '';
1 ✔
116

117
                // phase #1 - stuff between tags
118
                if ($mText !== '') {
1 ✔
119
                        $item = reset($this->tagStack);
1 ✔
120
                        if ($item && !isset($item['dtdContent'][HtmlElement::InnerText])) {  // text not allowed?
1 ✔
121

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

125
                        } else {
126
                                $s = Regexp::replace($mText, '#[ \n]+#', ' '); // otherwise shrink multiple spaces
1 ✔
127
                        }
128
                }
129

130
                // phase #2 - HTML comment
131
                if ($mComment) {
1 ✔
132
                        return $s . '<' . Texy\Helpers::freezeSpaces($mComment) . '>';
1 ✔
133
                }
134

135
                // phase #3 - HTML tag
136
                $mEmpty = $mEmpty || isset(HtmlElement::$emptyElements[$mTag]);
1 ✔
137
                if ($mEmpty && $mEnd) { // bad tag; /end/
1 ✔
138
                        return $s;
1 ✔
139
                } elseif ($mEnd) {
1 ✔
140
                        return $s . $this->processEndTag($mTag);
1 ✔
141
                } else {
142
                        return $this->processStartTag($mTag, $mEmpty, $mAttr, $s);
1 ✔
143
                }
144
        }
145

146

147
        private function processStartTag(string $tag, bool $empty, string $attr, string $s): string
148
        {
149
                $dtdContent = $this->baseDTD;
1 ✔
150
                $dtd = $this->texy->getDTD();
1 ✔
151

152
                if (!isset($dtd[$tag])) {
1 ✔
153
                        // unknown (non-html) tag
154
                        $allowed = true;
1 ✔
155
                        $item = reset($this->tagStack);
1 ✔
156
                        if ($item) {
1 ✔
157
                                $dtdContent = $item['dtdContent'];
1 ✔
158
                        }
159
                } else {
160
                        $s .= $this->closeOptionalTags($tag, $dtdContent);
1 ✔
161

162
                        // is tag allowed in this content?
163
                        $allowed = isset($dtdContent[$tag]);
1 ✔
164

165
                        // check deep element prohibitions
166
                        if ($allowed && isset(HtmlElement::$prohibits[$tag])) {
1 ✔
167
                                foreach (HtmlElement::$prohibits[$tag] as $pTag) {
1 ✔
168
                                        if (!empty($this->tagUsed[$pTag])) {
1 ✔
169
                                                $allowed = false;
1 ✔
170
                                                break;
1 ✔
171
                                        }
172
                                }
173
                        }
174
                }
175

176
                // empty elements se neukladaji do zasobniku
177
                if ($empty) {
1 ✔
178
                        if (!$allowed) {
1 ✔
179
                                return $s;
×
180
                        }
181

182
                        $indent = $this->indent && !array_intersect(array_keys($this->tagUsed, filter_value: true), $this->preserveSpaces);
1 ✔
183

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

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

191
                        } else {
192
                                return $s . '<' . $tag . $attr . '>';
1 ✔
193
                        }
194
                }
195

196
                $open = null;
1 ✔
197
                $close = null;
1 ✔
198
                $indent = 0;
1 ✔
199

200
                if ($allowed) {
1 ✔
201
                        $open = '<' . $tag . $attr . '>';
1 ✔
202

203
                        // receive new content
204
                        if ($tagDTD = $dtd[$tag] ?? null) {
1 ✔
205
                                if (isset($tagDTD[1][HtmlElement::InnerTransparent])) {
1 ✔
206
                                        $dtdContent += $tagDTD[1];
1 ✔
207
                                        unset($dtdContent[HtmlElement::InnerTransparent]);
1 ✔
208
                                } else {
209
                                        $dtdContent = $tagDTD[1];
1 ✔
210
                                }
211
                        }
212

213
                        // format output
214
                        if ($this->indent && !isset(HtmlElement::$inlineElements[$tag])) {
1 ✔
215
                                $close = "\x08" . '</' . $tag . '>' . "\n" . str_repeat("\t", $this->space);
1 ✔
216
                                $s .= "\n" . str_repeat("\t", $this->space++) . $open . "\x07";
1 ✔
217
                                $indent = 1;
1 ✔
218
                        } else {
219
                                $close = '</' . $tag . '>';
1 ✔
220
                                $s .= $open;
1 ✔
221
                        }
222

223
                        // TODO: problematic formatting of select / options, object / params
224
                }
225

226
                // open tag, put to stack, increase counter
227
                $item = [
1 ✔
228
                        'tag' => $tag,
1 ✔
229
                        'open' => $open,
1 ✔
230
                        'close' => $close,
1 ✔
231
                        'dtdContent' => $dtdContent,
1 ✔
232
                        'indent' => $indent,
1 ✔
233
                ];
234
                array_unshift($this->tagStack, $item);
1 ✔
235
                $tmp = &$this->tagUsed[$tag];
1 ✔
236
                $tmp++;
1 ✔
237

238
                return $s;
1 ✔
239
        }
240

241

242
        private function processEndTag(string $tag): string
243
        {
244
                // has start tag?
245
                if (empty($this->tagUsed[$tag])) {
1 ✔
246
                        return '';
1 ✔
247
                }
248

249
                // autoclose tags
250
                $tmp = [];
1 ✔
251
                $back = true;
1 ✔
252
                $s = '';
1 ✔
253
                foreach ($this->tagStack as $i => $item) {
1 ✔
254
                        $itemTag = $item['tag'];
1 ✔
255
                        $s .= $item['close'];
1 ✔
256
                        $this->space -= $item['indent'];
1 ✔
257
                        $this->tagUsed[$itemTag]--;
1 ✔
258
                        $back = $back && isset(HtmlElement::$inlineElements[$itemTag]);
1 ✔
259
                        unset($this->tagStack[$i]);
1 ✔
260
                        if ($itemTag === $tag) {
1 ✔
261
                                break;
1 ✔
262
                        }
263

264
                        array_unshift($tmp, $item);
1 ✔
265
                }
266

267
                if (!$back || !$tmp) {
1 ✔
268
                        return $s;
1 ✔
269
                }
270

271
                // allowed-check (nejspis neni ani potreba)
272
                $item = reset($this->tagStack);
1 ✔
273
                $dtdContent = $item ? $item['dtdContent'] : $this->baseDTD;
1 ✔
274
                if (!isset($dtdContent[$tmp[0]['tag']])) {
1 ✔
275
                        return $s;
×
276
                }
277

278
                // autoopen tags
279
                foreach ($tmp as $item) {
1 ✔
280
                        $s .= $item['open'];
1 ✔
281
                        $this->space += $item['indent'];
1 ✔
282
                        $this->tagUsed[$item['tag']]++;
1 ✔
283
                        array_unshift($this->tagStack, $item);
1 ✔
284
                }
285

286
                return $s;
1 ✔
287
        }
288

289

290
        /** @param  array<string, int>  $dtdContent */
291
        private function closeOptionalTags(string $tag, array &$dtdContent): string
292
        {
293
                $s = '';
1 ✔
294
                foreach ($this->tagStack as $i => $item) {
1 ✔
295
                        // is tag allowed here?
296
                        $dtdContent = $item['dtdContent'];
1 ✔
297
                        if (isset($dtdContent[$tag])) {
1 ✔
298
                                break;
1 ✔
299
                        }
300

301
                        $itemTag = $item['tag'];
1 ✔
302

303
                        // auto-close hidden, optional and inline tags
304
                        if (
305
                                $item['close']
1 ✔
306
                                && (
307
                                        !isset(HtmlElement::$optionalEnds[$itemTag])
1 ✔
308
                                        && !isset(HtmlElement::$inlineElements[$itemTag])
1 ✔
309
                                )
310
                        ) {
311
                                break;
1 ✔
312
                        }
313

314
                        // close it
315
                        $s .= $item['close'];
1 ✔
316
                        $this->space -= $item['indent'];
1 ✔
317
                        $this->tagUsed[$itemTag]--;
1 ✔
318
                        unset($this->tagStack[$i]);
1 ✔
319
                        $dtdContent = $this->baseDTD;
1 ✔
320
                }
321

322
                return $s;
1 ✔
323
        }
324

325

326
        /**
327
         * Callback function: wrap lines.
328
         * @param  string[]  $m
329
         */
330
        private function wrap(array $m): string
331
        {
332
                [, $space, $s] = $m;
1 ✔
333
                return $space . wordwrap($s, $this->lineWrap, "\n" . $space);
1 ✔
334
        }
335
}
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