• 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

94.94
/src/Texy/Modules/ParagraphModule.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;
15
use Texy\Nodes\ContentNode;
16
use Texy\Nodes\LineBreakNode;
17
use Texy\Nodes\ParagraphNode;
18
use Texy\Nodes\TextNode;
19
use Texy\Output\Html;
20
use Texy\ParseContext;
21
use Texy\Patterns;
22
use Texy\Regexp;
23
use function str_contains;
24

25

26
/**
27
 * Processes paragraphs and handles line breaks.
28
 */
29
final class ParagraphModule extends Texy\Module
30
{
31
        public function __construct(
1✔
32
                private Texy\Texy $texy,
33
        ) {
34
        }
1✔
35

36

37
        /**
38
         * Parse text into paragraphs (split by blank lines).
39
         * @return array<ParagraphNode>
40
         */
41
        public function parseText(ParseContext $context, string $text, int $baseOffset = 0): array
1✔
42
        {
43
                $parts = Regexp::split($text, '~(\n{2,})~', captureOffset: true, skipEmpty: true);
1✔
44
                $res = [];
1✔
45
                foreach ($parts ?: [] as $partInfo) {
1✔
46
                        // With captureOffset, each part is [content, offset]
47
                        if (is_array($partInfo)) {
1✔
48
                                [$part, $partOffset] = $partInfo;
1✔
49
                                $partOffset += $baseOffset;
1✔
50
                        } else {
51
                                $part = $partInfo;
×
52
                                $partOffset = $baseOffset;
×
53
                        }
54

55
                        $trimmed = trim($part);
1✔
56
                        if ($trimmed === '') {
1✔
57
                                continue;
1✔
58
                        }
59

60
                        // Calculate offset after leading whitespace trim
61
                        $leadingTrim = strlen($part) - strlen(ltrim($part));
1✔
62
                        $contentOffset = $partOffset + $leadingTrim;
1✔
63

64
                        // Text starting with known block element - parse without soft line breaks
65
                        if ($this->startsWithBlockElement($trimmed)) {
1✔
66
                                $node = $this->parseBlockHtml($context, $trimmed, $contentOffset);
1✔
67
                        } else {
68
                                $node = $this->parseParagraph($context, $trimmed, $contentOffset);
1✔
69
                                // Check if parsed content contains block-level HTML tags
70
                                if ($this->containsBlockHtmlTag($node->content->children)) {
1✔
71
                                        $node->blockHtml = true;
×
72
                                }
73
                        }
74

75
                        if ($this->isEmptyParagraph($node)) {
1✔
76
                                continue;
1✔
77
                        }
78

79
                        $res[] = $node;
1✔
80
                }
81

82
                return $res;
1✔
83
        }
84

85

86
        /**
87
         * Check if text starts with a known block-level HTML element.
88
         */
89
        private function startsWithBlockElement(string $text): bool
1✔
90
        {
91
                if (!preg_match('~^<([a-z][a-z0-9]*)\b~i', $text, $m)) {
1✔
92
                        return false;
1✔
93
                }
94
                // Not in inline elements = block element
95
                return !isset(Html\Element::$inlineElements[strtolower($m[1])]);
1✔
96
        }
97

98

99
        /**
100
         * Parse text that starts with block HTML element (no soft line break processing).
101
         */
102
        private function parseBlockHtml(ParseContext $context, string $text, int $baseOffset = 0): ParagraphNode
1✔
103
        {
104
                $content = $context->parseInline($text, $baseOffset);
1✔
105
                $node = new ParagraphNode($content);
1✔
106
                $node->blockHtml = true;
1✔
107
                return $node;
1✔
108
        }
109

110

111
        /**
112
         * Check if content contains a block-level HtmlTagNode.
113
         * Block = any HtmlTagNode that is NOT an inline element.
114
         * @param  array<Texy\Node>  $content
115
         */
116
        private function containsBlockHtmlTag(array $content): bool
1✔
117
        {
118
                foreach ($content as $node) {
1✔
119
                        if ($node instanceof Nodes\HtmlTagNode && !$node->closing) {
1✔
120
                                $tagName = strtolower($node->name);
1✔
121
                                // Not an inline element = block element (includes custom elements)
122
                                if (!isset(Html\Element::$inlineElements[$tagName])) {
1✔
123
                                        return true;
×
124
                                }
125
                        }
126
                }
127
                return false;
1✔
128
        }
129

130

131
        /**
132
         * Check if paragraph contains only whitespace.
133
         */
134
        private function isEmptyParagraph(ParagraphNode $node): bool
1✔
135
        {
136
                foreach ($node->content->children as $child) {
1✔
137
                        if ($child instanceof TextNode) {
1✔
138
                                if (trim($child->content) !== '') {
1✔
139
                                        return false;
1✔
140
                                }
141
                        } else {
142
                                return false;
1✔
143
                        }
144
                }
145
                return true;
1✔
146
        }
147

148

149
        private function parseParagraph(ParseContext $context, string $text, int $baseOffset = 0): ParagraphNode
1✔
150
        {
151
                // Extract modifier from paragraph
152
                $modifier = null;
1✔
153
                if ($mx = Regexp::match($text, '~' . Patterns::MODIFIER_H . '(?= \n | \z)~sUm', captureOffset: true)) {
1✔
154
                        [$mMod] = $mx[1];
1✔
155
                        $text = trim(substr_replace($text, '', $mx[0][1], strlen($mx[0][0])));
1✔
156
                        if ($text !== '') {
1✔
157
                                $modifier = Modifier::parse($mMod);
1✔
158
                        }
159
                }
160

161
                // Process line breaks - note: this changes text length, positions become approximate
162
                if ($this->texy->mergeLines) {
1✔
163
                        $text = Regexp::replace($text, '~\n\ +(?=\S)~', "\r");
1✔
164
                        $text = Regexp::replace($text, '~\n~', ' ');
1✔
165
                } else {
166
                        $text = Regexp::replace($text, '~\n~', "\r");
1✔
167
                }
168

169
                $content = $context->parseInline($text, $baseOffset);
1✔
170

171
                return new ParagraphNode(
1✔
172
                        new ContentNode($this->expandLineBreaks($content->children)),
1✔
173
                        $modifier,
174
                );
175
        }
176

177

178
        /**
179
         * Expand \r markers in TextNode content into LineBreakNode.
180
         * @param  array<Texy\Node>  $nodes
181
         * @return array<Texy\Node>
182
         */
183
        private function expandLineBreaks(array $nodes): array
1✔
184
        {
185
                $result = [];
1✔
186
                foreach ($nodes as $node) {
1✔
187
                        if ($node instanceof TextNode && str_contains($node->content, "\r")) {
1✔
188
                                foreach (explode("\r", $node->content) as $i => $part) {
1✔
189
                                        if ($i > 0) {
1✔
190
                                                $result[] = new LineBreakNode;
1✔
191
                                        }
192
                                        if ($part !== '') {
1✔
193
                                                $result[] = new TextNode($part, $node->position);
1✔
194
                                        }
195
                                }
196
                        } elseif ($node instanceof Nodes\PhraseNode) {
1✔
197
                                $node->content->children = $this->expandLineBreaks($node->content->children);
1✔
198
                                $result[] = $node;
1✔
199
                        } elseif ($node instanceof Nodes\LinkNode) {
1✔
200
                                $node->content->children = $this->expandLineBreaks($node->content->children);
1✔
201
                                $result[] = $node;
1✔
202
                        } else {
203
                                $result[] = $node;
1✔
204
                        }
205
                }
206
                return $result;
1✔
207
        }
208
}
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