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

dg / texy / 30792106735

03 Aug 2026 06:40AM UTC coverage: 94.771% (+0.09%) from 94.677%
30792106735

push

github

dg
Engine: the text decoder is injectable

InlineParser hardwired Helpers::decodeEntities() into every TextNode it
created, which put a content policy inside the parsing mechanics: what
counts as display text belongs to the language, not to the engine. It is
now a Closure(string): string taken by the Engine constructor, with the
old behaviour as the default, so Texy is unaffected and a language where
"&" is not an entity can pass fn($s) => $s. A custom decoder takes
over the control-character sanitization the default performs.

7 of 7 new or added lines in 2 files covered. (100.0%)

99 existing lines in 18 files now uncovered.

3498 of 3691 relevant lines covered (94.77%)

0.95 hits per line

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

95.62
/src/Texy/Modules/ImageModule.php
1
<?php declare(strict_types=1);
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
namespace Texy\Modules;
9

10
use Texy;
11
use Texy\Compat;
12
use Texy\Helpers;
13
use Texy\Modifier;
14
use Texy\Node;
15
use Texy\NodeHelpers;
16
use Texy\Nodes\ContentNode;
17
use Texy\Nodes\DocumentNode;
18
use Texy\Nodes\FigureNode;
19
use Texy\Nodes\ImageDefinitionNode;
20
use Texy\Nodes\ImageNode;
21
use Texy\Nodes\LinkNode;
22
use Texy\NodeTraverser;
23
use Texy\Parsing\ParseContext;
24
use Texy\Patterns;
25
use Texy\Range;
26
use Texy\Regexp;
27
use Texy\Syntax;
28
use function count, strlen;
29

30

31
/**
32
 * Processes image and figure (image with caption) syntax.
33
 */
34
final class ImageModule extends Texy\Module
35
{
36
        /** the [* url .(title) alignment ] part, shared by the image and figure syntaxes */
37
        private const ImagePattern = '
38
                        \[\* [ \t]*+                      # opening bracket with asterisk
39
                        ([^\n]{1,1000})                   # URLs (1)
40
                        ' . Patterns::Modifier . '?       # modifier (2)
41
                        [ \t]*+
42
                        ( \* | (?<! < ) > | < )           # alignment (3)
43
                        ]';
44

45
        /** the optional :LINK (or :: self link) suffix */
46
        private const LinkPattern = '
47
                        (?:
48
                                :(' . Patterns::LinkUrl . ' | : ) # link or just colon (4)
49
                        )??';
50

51
        /** @var array<string, ImageDefinitionNode> collected image definitions */
52
        private array $definitions = [];
53

54
        /** @var array<string, ImageDefinitionNode> user-defined definitions (persist across process() calls) */
55
        private array $userDefinitions = [];
56

57

58
        public function __construct(
1✔
59
                private Texy\Texy $texy,
60
        ) {
61
                $texy->allowed[Syntax::ImageDefinition] = true;
1✔
62
                $texy->addPass($this->resolveReferences(...));
1✔
63
        }
1✔
64

65

66
        public function beforeParse(): void
67
        {
68
                // [*image*]:LINK
69
                $this->texy->addLineSyntax(
1✔
70
                        Syntax::Image,
1✔
71
                        '~' . self::ImagePattern . self::LinkPattern . '
1✔
72
                        ~Ux',
73
                        $this->parseImage(...),
1✔
74
                );
75

76
                // [*ref*]: url .(title)[class]{style}
77
                $this->texy->addBlockSyntax(
1✔
78
                        Syntax::ImageDefinition,
1✔
79
                        '~^
80
                                \[\*                              # opening [*
81
                                ( [^\n]{1,100} )                  # reference (1)
82
                                \*]                               # closing *]
83
                                : [ \t]+
84
                                (.{1,1000})                       # URL (2)
85
                                [ \t]*
86
                                ' . Patterns::Modifier . '?       # modifier (3)
1✔
87
                                \s*
88
                        $~mUx',
89
                        $this->parseDefinition(...),
1✔
90
                );
91

92
                // [*image*]:link *** caption
93
                $this->texy->addBlockSyntax(
1✔
94
                        Syntax::Figure,
1✔
95
                        '~^
96
                                (?>' . self::ImagePattern . ')' . self::LinkPattern . '
1✔
97
                                (?:
98
                                        [ \t]++ \*\*\* [ \t]++        # separator
99
                                        (.{0,2000})                   # caption (5)
100
                                )?
101
                                ' . Patterns::ModifierHAlign . '?     # modifier (6)
1✔
102
                        $~mUx',
103
                        $this->parseFigure(...),
1✔
104
                );
105
        }
1✔
106

107

108
        /**
109
         * Parses [*image*]: urls .(title)[class]{style}
110
         * @param  array{string, string, string, ?string}  $matches
111
         * @param  array{int, int, int, ?int}  $offsets
112
         */
113
        public function parseDefinition(ParseContext $context, array $matches, array $offsets): ImageDefinitionNode
1✔
114
        {
115
                [, $mRef, $mURLs, $mMod] = $matches;
1✔
116
                $parsed = $this->parseImageContent($mURLs);
1✔
117
                $modifier = Modifier::parse($mMod, $offsets[3] ?? null);
1✔
118

119
                return new ImageDefinitionNode(
1✔
120
                        trim($mRef),
1✔
121
                        $parsed['url'],
1✔
122
                        $parsed['width'],
1✔
123
                        $parsed['height'],
1✔
124
                        $modifier,
125
                        new Range($offsets[0], strlen($matches[0])),
1✔
126
                );
127
        }
128

129

130
        /**
131
         * Parses [* small.jpg 80x13 .(alternative text)[class]{style}>]:LINK
132
         * @param  array{string, string, ?string, string, ?string}  $matches
133
         * @param  array{int, int, ?int, int, ?int}  $offsets
134
         */
135
        public function parseImage(ParseContext $context, array $matches, array $offsets): ImageNode|LinkNode
1✔
136
        {
137
                [, $mURLs, $mMod, $mAlign, $mLink] = $matches;
1✔
138
                $parsed = $this->parseImageContent($mURLs);
1✔
139
                $modifier = Modifier::parse($mMod . $mAlign);
1✔
140
                $range = new Range($offsets[0], strlen($matches[0]));
1✔
141

142
                $imageNode = new ImageNode(
1✔
143
                        $parsed['url'],
1✔
144
                        $parsed['width'],
1✔
145
                        $parsed['height'],
1✔
146
                        $modifier,
147
                        $range,
148
                );
149

150
                return $mLink
1✔
151
                        ? $this->wrapInLink($imageNode, $mLink, $range)
1✔
152
                        : $imageNode;
1✔
153
        }
154

155

156
        /**
157
         * Parses [*image*]:link *** caption .(title)[class]{style}>.
158
         * @param  array{string, string, ?string, string, ?string, ?string, ?string}  $matches
159
         * @param  array{int, int, ?int, int, ?int, ?int, ?int}  $offsets
160
         */
161
        public function parseFigure(ParseContext $context, array $matches, array $offsets): ?FigureNode
1✔
162
        {
163
                [, $mURLs, $mImgMod, $mAlign, $mLink, $mContent, $mMod] = $matches;
1✔
164

165
                $parsed = $this->parseImageContent($mURLs);
1✔
166
                $modifier = Modifier::parse($mImgMod . $mAlign);
1✔
167

168
                if ($parsed['url'] === null) {
1✔
UNCOV
169
                        return null;
×
170
                }
171

172
                // ImageNode source span is "[* ... <alignment>]"
173
                $imageEnd = $offsets[3] + strlen($mAlign) + 1; // 1 = "]"
1✔
174
                $imageNode = new ImageNode(
1✔
175
                        $parsed['url'],
1✔
176
                        $parsed['width'],
1✔
177
                        $parsed['height'],
1✔
178
                        $modifier,
179
                        new Range($offsets[0], $imageEnd - $offsets[0]),
1✔
180
                );
181

182
                $image = $mLink
1✔
183
                        ? $this->wrapInLink(
1✔
184
                                $imageNode,
1✔
185
                                $mLink,
186
                                new Range($offsets[0], $offsets[4] + strlen($mLink) - $offsets[0]),
1✔
187
                        )
188
                        : $imageNode;
1✔
189

190
                // Parse caption as inline content
191
                $caption = null;
1✔
192
                $mContent = trim($mContent ?? '');
1✔
193
                if ($mContent !== '') {
1✔
194
                        $caption = $context->parseInline($mContent, $offsets[5] ?? $offsets[0]);
1✔
195
                }
196

197
                return new FigureNode(
1✔
198
                        $image,
1✔
199
                        $caption,
200
                        Modifier::parse($mMod, $offsets[6] ?? null),
1✔
201
                        new Range($offsets[0], strlen($matches[0])),
1✔
202
                );
203
        }
204

205

206
        private function wrapInLink(ImageNode $imageNode, string $mLink, Range $range): LinkNode
1✔
207
        {
208
                if ($mLink === ':') {
1✔
209
                        // Link to the image itself
210
                        $linkUrl = $imageNode->url;
1✔
211
                        $isImageLink = true;
1✔
212
                } else {
213
                        // Direct URL or reference like [ref] or [*img*]
214
                        $linkUrl = Helpers::removeSoftHyphens($mLink);
1✔
215
                        // Image reference [*...*] → uses imageRoot
216
                        $len = strlen($mLink);
1✔
217
                        $isImageLink = $len > 4 && $mLink[0] === '[' && $mLink[1] === '*'
1✔
218
                                && $mLink[$len - 1] === ']' && $mLink[$len - 2] === '*';
1✔
219
                }
220

221
                return new LinkNode(
1✔
222
                        url: $linkUrl,
1✔
223
                        content: new ContentNode([$imageNode]),
1✔
224
                        range: $range,
225
                        isImageLink: $isImageLink,
226
                );
227
        }
228

229

230
        /**
231
         * Parse image content: "image.jpg 100x200"
232
         * @return array{url: ?string, width: ?int, height: ?int}
233
         */
234
        public function parseImageContent(string $content): array
1✔
235
        {
236
                $parts = explode('|', $content);
1✔
237
                $main = trim($parts[0]);
1✔
238

239
                if (count($parts) > 1) {
1✔
240
                        trigger_error("Image syntax with '|' or '||' inside brackets is deprecated. Use [* image *]:url for linked images.", E_USER_DEPRECATED);
1✔
241
                }
242

243
                $url = Helpers::removeSoftHyphens($main);
1✔
244
                $width = $height = null;
1✔
245

246
                // Parse dimensions: "image.jpg 100x200" or "image.jpg 100X200" (asMax)
247
                if ($m = Regexp::match($main, '~^(.*)\ (\d+|\?)\ *[xX]\ *(\d+|\?)\ *$~U')) {
1✔
248
                        /** @var array{string, string, string, string} $m */
249
                        $url = trim($m[1]);
1✔
250
                        $width = $m[2] === '?' ? null : (int) $m[2];
1✔
251
                        $height = $m[3] === '?' ? null : (int) $m[3];
1✔
252
                }
253

254
                // Check URL
255
                if (!$this->texy->urlPolicy->isImageAllowed($url)) {
1✔
UNCOV
256
                        $url = null;
×
257
                }
258

259
                return [
260
                        'url' => $url,
1✔
261
                        'width' => $width,
1✔
262
                        'height' => $height,
1✔
263
                ];
264
        }
265

266

267
        /**
268
         * Adds a user-defined image definition (persists across process() calls).
269
         */
270
        public function addDefinition(
1✔
271
                string $name,
272
                string $url,
273
                ?int $width = null,
274
                ?int $height = null,
275
                ?string $alt = null,
276
        ): void
277
        {
278
                $modifier = $alt !== null ? Modifier::parse('(' . $alt . ')') : null;
1✔
279
                $this->userDefinitions[Helpers::toLower($name)] = new ImageDefinitionNode($name, $url, $width, $height, $modifier);
1✔
280
        }
1✔
281

282

283
        /**
284
         * Resolve image references in the document.
285
         * Called via afterParse handler.
286
         */
287
        public function resolveReferences(DocumentNode $doc): void
1✔
288
        {
289
                // Start with user-defined definitions
290
                $this->definitions = $this->userDefinitions;
1✔
291

292
                // Pass 1: Collect document definitions (overwrites user-defined)
293
                foreach (NodeHelpers::find($doc, ImageDefinitionNode::class) as $node) {
1✔
294
                        $this->definitions[Helpers::toLower($node->reference)] = $node;
1✔
295
                }
296

297
                // Pass 2: Resolve ImageNode references (NodeTraverser visits all nodes including FigureNode.image)
298
                (new NodeTraverser)->traverse($doc, function (Node $node): ?int {
1✔
299
                        if ($node instanceof ImageNode) {
1✔
300
                                $this->resolveImageNode($node);
1✔
301
                        } elseif (
302
                                $node instanceof LinkNode
1✔
303
                                && ($imageNode = $node->content->children[0] ?? null) instanceof ImageNode
1✔
304
                        ) {
305
                                // LinkNode wrapping ImageNode - resolve image and possibly the link URL
306
                                $imageKey = $imageNode->url !== null ? Helpers::toLower(trim($imageNode->url)) : null;
1✔
307
                                $linkKey = $node->url !== null ? Helpers::toLower(trim($node->url)) : null;
1✔
308

309
                                // Resolve the image
310
                                $this->resolveImageNode($imageNode);
1✔
311

312
                                // If link URL matches the original image reference (from :: syntax), resolve it too
313
                                if ($imageKey !== null && $linkKey === $imageKey && isset($this->definitions[$imageKey])) {
1✔
314
                                        $node->url = $this->definitions[$imageKey]->url;
1✔
315
                                }
316
                        }
317
                        return null;
1✔
318
                });
1✔
319
        }
1✔
320

321

322
        private function resolveImageNode(ImageNode $node): void
1✔
323
        {
324
                if ($node->url === null) {
1✔
UNCOV
325
                        return;
×
326
                }
327

328
                $key = Helpers::toLower(trim($node->url));
1✔
329
                if (!isset($this->definitions[$key])) {
1✔
330
                        return;
1✔
331
                }
332

333
                $def = $this->definitions[$key];
1✔
334

335
                $node->ref = trim($node->url); // keep the written reference name for analyzers
1✔
336
                $node->url = $def->url;
1✔
337
                $node->width ??= $def->width;
1✔
338
                $node->height ??= $def->height;
1✔
339

340
                // Merge modifier from definition if node doesn't have one
341
                if ($def->modifier && !$node->modifier) {
1✔
UNCOV
342
                        $node->modifier = clone $def->modifier;
×
343
                } elseif ($def->modifier) {
1✔
344
                        // Merge: node modifier takes precedence, but inherit missing values from definition
345
                        if ($node->modifier->title === null && $def->modifier->title !== null) {
1✔
346
                                $node->modifier->title = $def->modifier->title;
1✔
347
                        }
348
                }
349
        }
1✔
350

351

352
        /**
353
         * Get image definition by name (for LinkReferenceModule to resolve [*img*] links).
354
         */
355
        public function getDefinition(string $name): ?ImageDefinitionNode
1✔
356
        {
357
                return $this->definitions[Helpers::toLower($name)] ?? null;
1✔
358
        }
359

360

361
        /**
362
         * @deprecated use $texy->htmlOutput->imageRoot etc. instead
363
         */
364
        public function &__get(string $name): mixed
365
        {
UNCOV
366
                return Compat\Legacy::ref($this->texy, Compat\Legacy::OfModule['imageModule'], '$texy->imageModule', $name, 'read');
×
367
        }
368

369

370
        /**
371
         * @deprecated use $texy->htmlOutput->imageRoot etc. instead
372
         */
373
        public function __set(string $name, mixed $value): void
1✔
374
        {
375
                Compat\Legacy::set($this->texy, Compat\Legacy::OfModule['imageModule'], '$texy->imageModule', $name, $value);
1✔
376
        }
1✔
377

378

379
        public function __isset(string $name): bool
380
        {
UNCOV
381
                return Compat\Legacy::isSet($this->texy, Compat\Legacy::OfModule['imageModule'], $name);
×
382
        }
383
}
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