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

dg / texy / 39601969498

05 Oct 2026 06:36AM UTC coverage: 95.456% (-0.9%) from 96.335%
39601969498

push

github

dg
examples: Figure as Definition List lives in one directory

The demo sat in "Figure as Definition List" while its sample and image
were in "figure as definition list", and the readme linked the latter;
on a case-sensitive filesystem the demo did not find its sample.

3508 of 3675 relevant lines covered (95.46%)

0.95 hits per line

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

95.49
/src/Texy/Modules/ImageModule.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\{Compat, Helpers, Modifier, Node, NodeHelpers, NodeTraverser, Patterns, Range, Regexp, Syntax};
12
use Texy\Nodes\{ContentNode, DocumentNode, FigureNode, ImageDefinitionNode, ImageNode, LinkNode};
13
use Texy\Parsing\ParseContext;
14
use function count, strlen;
15

16

17
/**
18
 * Processes image and figure (image with caption) syntax.
19
 */
20
final class ImageModule extends Texy\Module
21
{
22
        /** the [* url .(title) alignment ] part, shared by the image and figure syntaxes */
23
        private const ImagePattern = '
24
                        \[\* [ \t]*+                      # opening bracket with asterisk
25
                        ([^\n]{1,1000})                   # URLs (1)
26
                        ' . Patterns::Modifier . '?       # modifier (2)
27
                        [ \t]*+
28
                        ( \* | (?<! < ) > | < )           # alignment (3)
29
                        ]';
30

31
        /** the optional :LINK (or :: self link) suffix */
32
        private const LinkPattern = '
33
                        (?:
34
                                :(' . Patterns::LinkUrl . ' | : ) # link or just colon (4)
35
                        )??';
36

37
        /** @var array<string, ImageDefinitionNode> collected image definitions */
38
        private array $definitions = [];
39

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

43

44
        public function __construct(
1 ✔
45
                private Texy\Texy $texy,
46
        ) {
47
                $texy->allowed[Syntax::ImageDefinition] = true;
1 ✔
48
                $texy->addPass($this->resolveReferences(...));
1 ✔
49
        }
1 ✔
50

51

52
        public function beforeParse(): void
53
        {
54
                // [*image*]:LINK
55
                $this->texy->addLineSyntax(
1 ✔
56
                        Syntax::Image,
1 ✔
57
                        '~' . self::ImagePattern . self::LinkPattern . '
1 ✔
58
                        ~Ux',
59
                        $this->parseImage(...),
1 ✔
60
                );
61

62
                // [*ref*]: url .(title)[class]{style}
63
                $this->texy->addBlockSyntax(
1 ✔
64
                        Syntax::ImageDefinition,
1 ✔
65
                        '~^
66
                                \[\*                              # opening [*
67
                                ( [^\n]{1,100} )                  # reference (1)
68
                                \*]                               # closing *]
69
                                : [ \t]+
70
                                (.{1,1000})                       # URL (2)
71
                                [ \t]*
72
                                ' . Patterns::Modifier . '?       # modifier (3)
1 ✔
73
                                \s*
74
                        $~mUx',
75
                        $this->parseDefinition(...),
1 ✔
76
                );
77

78
                // [*image*]:link *** caption
79
                $this->texy->addBlockSyntax(
1 ✔
80
                        Syntax::Figure,
1 ✔
81
                        '~^
82
                                (?>' . self::ImagePattern . ')' . self::LinkPattern . '
1 ✔
83
                                (?:
84
                                        [ \t]++ \*\*\* [ \t]++        # separator
85
                                        (.{0,2000})                   # caption (5)
86
                                )?
87
                                ' . Patterns::ModifierHAlign . '?     # modifier (6)
1 ✔
88
                        $~mUx',
89
                        $this->parseFigure(...),
1 ✔
90
                );
91
        }
1 ✔
92

93

94
        /**
95
         * Parses [*image*]: urls .(title)[class]{style}
96
         * @param  array{string, string, string, ?string}  $matches
97
         * @param  array{int, int, int, ?int}  $offsets
98
         */
99
        public function parseDefinition(ParseContext $context, array $matches, array $offsets): ImageDefinitionNode
100
        {
101
                [, $mRef, $mURLs, $mMod] = $matches;
1 ✔
102
                $parsed = $this->parseImageContent($mURLs);
1 ✔
103
                $modifier = Modifier::parse($mMod, $offsets[3] ?? null);
1 ✔
104

105
                return new ImageDefinitionNode(
1 ✔
106
                        trim($mRef),
1 ✔
107
                        $parsed['url'],
1 ✔
108
                        $parsed['width'],
1 ✔
109
                        $parsed['height'],
1 ✔
110
                        $modifier,
111
                        new Range($offsets[0], strlen($matches[0])),
1 ✔
112
                );
113
        }
114

115

116
        /**
117
         * Parses [* small.jpg 80x13 .(alternative text)[class]{style}>]:LINK
118
         * @param  array{string, string, ?string, string, ?string}  $matches
119
         * @param  array{int, int, ?int, int, ?int}  $offsets
120
         */
121
        public function parseImage(ParseContext $context, array $matches, array $offsets): ImageNode|LinkNode
122
        {
123
                [, $mURLs, $mMod, $mAlign, $mLink] = $matches;
1 ✔
124
                $parsed = $this->parseImageContent($mURLs, $offsets[1]);
1 ✔
125
                $modifier = Modifier::parse($mMod . $mAlign);
1 ✔
126

127
                // ImageNode source span is "[* ... <alignment>]"
128
                $imageNode = new ImageNode(
1 ✔
129
                        $parsed['url'],
1 ✔
130
                        $parsed['width'],
1 ✔
131
                        $parsed['height'],
1 ✔
132
                        $modifier,
133
                        new Range($offsets[0], $offsets[3] + strlen($mAlign) + 1 - $offsets[0]), // 1 = "]"
1 ✔
134
                        urlRange: $parsed['urlRange'],
1 ✔
135
                );
136

137
                return $mLink
1 ✔
138
                        ? $this->wrapInLink(
1 ✔
139
                                $imageNode,
1 ✔
140
                                $mLink,
141
                                $offsets[0],
1 ✔
142
                                $offsets[4] ?? throw new \LogicException('Link without group 4.'),
1 ✔
143
                                Syntax::Image,
1 ✔
144
                        )
145
                        : $imageNode;
1 ✔
146
        }
147

148

149
        /**
150
         * Parses [*image*]:link *** caption .(title)[class]{style}>.
151
         * @param  array{string, string, ?string, string, ?string, ?string, ?string}  $matches
152
         * @param  array{int, int, ?int, int, ?int, ?int, ?int}  $offsets
153
         */
154
        public function parseFigure(ParseContext $context, array $matches, array $offsets): ?FigureNode
155
        {
156
                [, $mURLs, $mImgMod, $mAlign, $mLink, $mContent, $mMod] = $matches;
1 ✔
157

158
                $parsed = $this->parseImageContent($mURLs, $offsets[1]);
1 ✔
159
                $modifier = Modifier::parse($mImgMod . $mAlign);
1 ✔
160

161
                if ($parsed['url'] === null) {
1 ✔
162
                        return null;
×
163
                }
164

165
                // ImageNode source span is "[* ... <alignment>]"
166
                $imageNode = new ImageNode(
1 ✔
167
                        $parsed['url'],
1 ✔
168
                        $parsed['width'],
1 ✔
169
                        $parsed['height'],
1 ✔
170
                        $modifier,
171
                        new Range($offsets[0], $offsets[3] + strlen($mAlign) + 1 - $offsets[0]), // 1 = "]"
1 ✔
172
                        urlRange: $parsed['urlRange'],
1 ✔
173
                );
174

175
                $image = $mLink
1 ✔
176
                        ? $this->wrapInLink(
1 ✔
177
                                $imageNode,
1 ✔
178
                                $mLink,
179
                                $offsets[0],
1 ✔
180
                                $offsets[4] ?? throw new \LogicException('Link without group 4.'),
1 ✔
181
                                Syntax::Figure,
1 ✔
182
                        )
183
                        : $imageNode;
1 ✔
184

185
                // Parse caption as inline content
186
                $caption = null;
1 ✔
187
                $mContent = trim($mContent ?? '');
1 ✔
188
                if ($mContent !== '') {
1 ✔
189
                        $caption = $context->parseInline($mContent, $offsets[5] ?? $offsets[0]);
1 ✔
190
                }
191

192
                return new FigureNode(
1 ✔
193
                        $image,
194
                        $caption,
195
                        Modifier::parse($mMod, $offsets[6] ?? null),
1 ✔
196
                        new Range($offsets[0], strlen($matches[0])),
1 ✔
197
                );
198
        }
199

200

201
        /** Wraps the image written at $offset in the link written after it at $linkOffset. */
202
        private function wrapInLink(ImageNode $imageNode, string $mLink, int $offset, int $linkOffset, string $syntax): LinkNode
203
        {
204
                // ':' links to the image itself; a reference like [ref] or [*img*] is recognized by LinkReferenceModule
205
                $isSelf = $mLink === ':';
1 ✔
206
                return new LinkNode(
1 ✔
207
                        url: $isSelf ? $imageNode->url : Helpers::removeSoftHyphens($mLink),
1 ✔
208
                        content: new ContentNode([$imageNode]),
1 ✔
209
                        range: new Range($offset, $linkOffset + strlen($mLink) - $offset),
1 ✔
210
                        isImageLink: $isSelf,
211
                        syntax: $syntax,
212
                        urlRange: new Range($linkOffset, strlen($mLink)),
1 ✔
213
                );
214
        }
215

216

217
        /**
218
         * Parse image content: "image.jpg 100x200"; $offset is where the content is written.
219
         * @return array{url: ?string, width: ?int, height: ?int, urlRange: ?Range}
220
         */
221
        public function parseImageContent(string $content, ?int $offset = null): array
222
        {
223
                $parts = explode('|', $content);
1 ✔
224
                $main = $written = trim($parts[0]);
1 ✔
225

226
                if (count($parts) > 1) {
1 ✔
227
                        trigger_error(
1 ✔
228
                                "Image syntax with '|' or '||' inside brackets is deprecated. Use [* image *]:url for linked images.",
1 ✔
229
                                E_USER_DEPRECATED,
1 ✔
230
                        );
231
                }
232

233
                $url = Helpers::removeSoftHyphens($main);
1 ✔
234
                $width = $height = null;
1 ✔
235

236
                // Parse dimensions: "image.jpg 100x200" or "image.jpg 100X200" (asMax)
237
                if ($m = Regexp::match($main, '~^(.*)\ (\d+|\?)\ *[xX]\ *(\d+|\?)\ *$~U')) {
1 ✔
238
                        /** @var array{string, string, string, string} $m */
239
                        $url = $written = trim($m[1]);
1 ✔
240
                        $width = $m[2] === '?' ? null : (int) $m[2];
1 ✔
241
                        $height = $m[3] === '?' ? null : (int) $m[3];
1 ✔
242
                }
243

244
                // Check URL
245
                if (!$this->texy->urlPolicy->isImageAllowed($url)) {
1 ✔
246
                        $url = null;
×
247
                }
248

249
                return [
250
                        'url' => $url,
1 ✔
251
                        'width' => $width,
1 ✔
252
                        'height' => $height,
1 ✔
253
                        'urlRange' => $offset === null ? null : new Range($offset + strlen($parts[0]) - strlen(ltrim($parts[0])), strlen($written)),
1 ✔
254
                ];
255
        }
256

257

258
        /**
259
         * Adds a user-defined image definition (persists across process() calls).
260
         */
261
        public function addDefinition(
262
                string $name,
263
                string $url,
264
                ?int $width = null,
265
                ?int $height = null,
266
                ?string $alt = null,
267
        ): void
268
        {
269
                $modifier = $alt !== null ? Modifier::parse('(' . $alt . ')') : null;
1 ✔
270
                $this->userDefinitions[Helpers::toLower($name)] = new ImageDefinitionNode($name, $url, $width, $height, $modifier);
1 ✔
271
        }
1 ✔
272

273

274
        /**
275
         * Resolve image references in the document.
276
         */
277
        public function resolveReferences(DocumentNode $doc): void
278
        {
279
                // Start with user-defined definitions
280
                $this->definitions = $this->userDefinitions;
1 ✔
281

282
                // Pass 1: Collect document definitions (overwrites user-defined)
283
                foreach (NodeHelpers::find($doc, ImageDefinitionNode::class) as $node) {
1 ✔
284
                        $this->definitions[Helpers::toLower($node->reference)] = $node;
1 ✔
285
                }
286

287
                // Pass 2: Resolve ImageNode references (NodeTraverser visits all nodes including FigureNode.image)
288
                (new NodeTraverser)->traverse($doc, function (Node $node): ?int {
1 ✔
289
                        if ($node instanceof ImageNode) {
1 ✔
290
                                $this->resolveImageNode($node);
1 ✔
291
                        } elseif (
292
                                $node instanceof LinkNode
1 ✔
293
                                && ($imageNode = $node->content->children[0] ?? null) instanceof ImageNode
1 ✔
294
                        ) {
295
                                // LinkNode wrapping ImageNode - resolve image and possibly the link URL
296
                                $imageKey = $imageNode->url !== null ? Helpers::toLower(trim($imageNode->url)) : null;
1 ✔
297
                                $linkKey = $node->url !== null ? Helpers::toLower(trim($node->url)) : null;
1 ✔
298

299
                                // Resolve the image
300
                                $this->resolveImageNode($imageNode);
1 ✔
301

302
                                // If link URL matches the original image reference (from :: syntax), resolve it too
303
                                if ($imageKey !== null && $linkKey === $imageKey && isset($this->definitions[$imageKey])) {
1 ✔
304
                                        $node->url = $this->definitions[$imageKey]->url;
1 ✔
305
                                }
306
                        }
307
                        return null;
1 ✔
308
                });
1 ✔
309
        }
1 ✔
310

311

312
        private function resolveImageNode(ImageNode $node): void
313
        {
314
                if ($node->url === null) {
1 ✔
315
                        return;
×
316
                }
317

318
                $key = Helpers::toLower(trim($node->url));
1 ✔
319
                if (!isset($this->definitions[$key])) {
1 ✔
320
                        return;
1 ✔
321
                }
322

323
                $def = $this->definitions[$key];
1 ✔
324

325
                $node->ref = trim($node->url); // keep the written reference name for analyzers
1 ✔
326
                $node->url = $def->url;
1 ✔
327
                $node->width ??= $def->width;
1 ✔
328
                $node->height ??= $def->height;
1 ✔
329

330
                // Merge modifier from definition if node doesn't have one
331
                if ($def->modifier && !$node->modifier) {
1 ✔
332
                        $node->modifier = clone $def->modifier;
×
333
                } elseif ($def->modifier) {
1 ✔
334
                        // Merge: node modifier takes precedence, but inherit missing values from definition
335
                        if ($node->modifier->title === null && $def->modifier->title !== null) {
1 ✔
336
                                $node->modifier->title = $def->modifier->title;
1 ✔
337
                        }
338
                }
339
        }
1 ✔
340

341

342
        /**
343
         * Get image definition by name (for LinkReferenceModule to resolve [*img*] links).
344
         */
345
        public function getDefinition(string $name): ?ImageDefinitionNode
346
        {
347
                return $this->definitions[Helpers::toLower($name)] ?? null;
1 ✔
348
        }
349

350

351
        /**
352
         * @deprecated use $texy->htmlOutput->imageRoot etc. instead
353
         */
354
        public function &__get(string $name): mixed
355
        {
356
                return Compat\Legacy::ref($this->texy, Compat\Legacy::OfModule['imageModule'], '$texy->imageModule', $name, 'read');
×
357
        }
358

359

360
        /**
361
         * @deprecated use $texy->htmlOutput->imageRoot etc. instead
362
         */
363
        public function __set(string $name, mixed $value): void
364
        {
365
                Compat\Legacy::set($this->texy, Compat\Legacy::OfModule['imageModule'], '$texy->imageModule', $name, $value);
1 ✔
366
        }
1 ✔
367

368

369
        public function __isset(string $name): bool
370
        {
371
                return Compat\Legacy::isSet($this->texy, Compat\Legacy::OfModule['imageModule'], $name);
×
372
        }
373
}
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