@kirigami/php-prepros 1.2.0 → 1.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +491 -12
- package/index.d.ts +101 -31
- package/index.js +1 -1
- package/package.json +1 -1
- package/src/imagebatch.php +67 -0
- package/src/libraries/aliases.inc.php +836 -0
- package/src/libraries/curl.class.php +15 -3
- package/src/libraries/fs.class.php +161 -6
- package/src/libraries/html.class.php +69 -0
- package/src/libraries/img.class.php +19 -4
- package/src/libraries/ld.class.php +804 -0
- package/src/libraries/md.class.php +26 -3
- package/src/libraries/md.plugins.php +7 -1
- package/src/libraries/prepros.class.php +119 -5
- package/src/libraries/prepros.plugins.php +32 -6
- package/src/libraries/std.class.php +3 -0
- package/src/libraries/str.class.php +1 -1
- package/src/prepros.js +80 -5
- package/src/prepros.php +9 -2
- package/src/runenv.php +5 -2
- package/src/utils.inc.php +11 -1
|
@@ -7,18 +7,30 @@ if(!boolval(PREPROS::$config->network ?? false)) STD::error("CURL Error: Network
|
|
|
7
7
|
|
|
8
8
|
class CURL {
|
|
9
9
|
|
|
10
|
+
// Mimics a Google Chrome (stable) browser on Windows 11, desktop, requesting
|
|
11
|
+
// a top-level document. Windows 11 still reports "Windows NT 10.0" in the UA
|
|
12
|
+
// string; the real version is only exposed through the Sec-CH-UA-Platform-Version
|
|
13
|
+
// client hint ("15.0.0"). Accept advertises every image format modern Chrome
|
|
14
|
+
// supports, AVIF included.
|
|
10
15
|
const HEADERS = [
|
|
11
|
-
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
|
12
|
-
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.
|
|
16
|
+
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
17
|
+
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
|
|
13
18
|
'Accept-Language: fr-CA,fr;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
14
|
-
|
|
19
|
+
// Accept-Encoding is intentionally left to CURLOPT_ENCODING so curl only
|
|
20
|
+
// advertises what it can actually transparently decode.
|
|
21
|
+
'Sec-Ch-Ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"',
|
|
15
22
|
'Sec-Ch-Ua-Mobile: ?0',
|
|
16
23
|
'Sec-Ch-Ua-Platform: "Windows"',
|
|
24
|
+
'Sec-Ch-Ua-Platform-Version: "15.0.0"',
|
|
25
|
+
'Sec-Ch-Ua-Arch: "x86"',
|
|
26
|
+
'Sec-Ch-Ua-Bitness: "64"',
|
|
27
|
+
'Sec-Ch-Ua-Full-Version-List: "Google Chrome";v="131.0.6778.86", "Chromium";v="131.0.6778.86", "Not_A Brand";v="24.0.0.0"',
|
|
17
28
|
'Sec-Fetch-Dest: document',
|
|
18
29
|
'Sec-Fetch-Mode: navigate',
|
|
19
30
|
'Sec-Fetch-Site: none',
|
|
20
31
|
'Sec-Fetch-User: ?1',
|
|
21
32
|
'Upgrade-Insecure-Requests: 1',
|
|
33
|
+
'Priority: u=0, i',
|
|
22
34
|
];
|
|
23
35
|
|
|
24
36
|
|
|
@@ -48,6 +48,126 @@ class FS
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Lists the immediate child pages of the calling template.
|
|
53
|
+
*
|
|
54
|
+
* Scans the directories directly below the folder of the file that
|
|
55
|
+
* called this function, keeps the ones that hold an `_index.php`, parses
|
|
56
|
+
* that file's first PHPDOC block via {@see FS::phpFileInfo()} and returns
|
|
57
|
+
* the pages ordered by `@position` ascending (a missing `@position`
|
|
58
|
+
* counts as 999999), then by folder name.
|
|
59
|
+
*
|
|
60
|
+
* Only usable while a render is in progress (`PREPROS::$file` set).
|
|
61
|
+
*
|
|
62
|
+
* @param string $anchor Path whose folder is scanned; defaults to the page
|
|
63
|
+
* currently being rendered (`PREPROS::$file`), so it
|
|
64
|
+
* works the same called straight from a template, from
|
|
65
|
+
* a layout partial, or from a helper function.
|
|
66
|
+
* @return object[] One `stdClass` per child: the parsed PHPDOC
|
|
67
|
+
* of its `_index.php`, plus a `->file` key
|
|
68
|
+
* holding that file's absolute path.
|
|
69
|
+
* @throws \Exception When called outside of a render.
|
|
70
|
+
*/
|
|
71
|
+
public static function getChildren(string $anchor = ''): array
|
|
72
|
+
{
|
|
73
|
+
if (PREPROS::$file === '') throw new Exception('FS::getChildren() can only be called during a render.');
|
|
74
|
+
if (!$anchor) $anchor = PREPROS::$file;
|
|
75
|
+
if (!$anchor || !$dir = realpath(pathinfo($anchor, PATHINFO_DIRNAME))) return [];
|
|
76
|
+
|
|
77
|
+
$children = [];
|
|
78
|
+
foreach (glob($dir . '/*', GLOB_ONLYDIR) as $subdir) {
|
|
79
|
+
$index = $subdir . '/_index.php';
|
|
80
|
+
if (!is_file($index)) continue;
|
|
81
|
+
$info = FS::phpFileInfo($index) ?: new stdClass;
|
|
82
|
+
$info = clone $info;
|
|
83
|
+
$info->file = realpath($index);
|
|
84
|
+
$position = (isset($info->position) && is_numeric($info->position)) ? (int) $info->position : 999999;
|
|
85
|
+
$children[] = ['position' => $position, 'name' => pathinfo($subdir, PATHINFO_BASENAME), 'info' => $info];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
usort($children, fn($a, $b) => ($a['position'] <=> $b['position']) ?: strnatcasecmp($a['name'], $b['name']));
|
|
89
|
+
|
|
90
|
+
return array_column($children, 'info');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Builds the breadcrumb trail of the calling page.
|
|
96
|
+
*
|
|
97
|
+
* Only runs when the calling file opts in with `@breadcrumb true` (or
|
|
98
|
+
* `@breadcrumb 1`) in its first PHPDOC block; otherwise returns `[]`.
|
|
99
|
+
*
|
|
100
|
+
* Starting from the folder *above* the caller's own folder (the current
|
|
101
|
+
* page is never part of its own trail), it walks the parent directories
|
|
102
|
+
* upward, collecting the `_index.php` of each one via
|
|
103
|
+
* {@see FS::phpFileInfo()}. The walk stops at the source root, or at the
|
|
104
|
+
* first ancestor `_index.php` that does not carry an active `@breadcrumb`
|
|
105
|
+
* tag — that page is a pure separator and is left out of the result.
|
|
106
|
+
* Directories with no `_index.php` are skipped without breaking the chain.
|
|
107
|
+
*
|
|
108
|
+
* Only usable while a render is in progress (`PREPROS::$file` set).
|
|
109
|
+
*
|
|
110
|
+
* @param string $anchor Path the trail is walked from; defaults to the page
|
|
111
|
+
* currently being rendered (`PREPROS::$file`), so it
|
|
112
|
+
* works the same called straight from a template, from
|
|
113
|
+
* a layout partial, or from a helper function.
|
|
114
|
+
* @return object[] One `stdClass` per ancestor page, ordered from
|
|
115
|
+
* the top-most ancestor down to the nearest
|
|
116
|
+
* parent: the parsed PHPDOC of its `_index.php`,
|
|
117
|
+
* plus a `->file` key holding that file's
|
|
118
|
+
* absolute path.
|
|
119
|
+
* @throws \Exception When called outside of a render.
|
|
120
|
+
*/
|
|
121
|
+
public static function getBreadcrumb(string $anchor = ''): array
|
|
122
|
+
{
|
|
123
|
+
if (PREPROS::$file === '') throw new Exception('FS::getBreadcrumb() can only be called during a render.');
|
|
124
|
+
if (!$anchor) $anchor = PREPROS::$file;
|
|
125
|
+
if (!$anchor || !$dir = realpath(pathinfo($anchor, PATHINFO_DIRNAME))) return [];
|
|
126
|
+
|
|
127
|
+
$self = FS::phpFileInfo($anchor) ?: new stdClass;
|
|
128
|
+
if (!self::truthy($self->breadcrumb ?? null)) return [];
|
|
129
|
+
|
|
130
|
+
if (!$root = realpath(PREPROS::$config->root)) return [];
|
|
131
|
+
$root = rtrim(str_replace('\\', '/', $root), '/');
|
|
132
|
+
$dir = rtrim(str_replace('\\', '/', $dir), '/');
|
|
133
|
+
|
|
134
|
+
$trail = [];
|
|
135
|
+
while (true) {
|
|
136
|
+
$parent = str_replace('\\', '/', dirname($dir));
|
|
137
|
+
if ($parent === $dir) break; // filesystem root
|
|
138
|
+
$dir = $parent;
|
|
139
|
+
if (strncmp($dir . '/', $root . '/', strlen($root) + 1) !== 0) break; // above the source root
|
|
140
|
+
|
|
141
|
+
$index = $dir . '/_index.php';
|
|
142
|
+
if (is_file($index)) {
|
|
143
|
+
$info = FS::phpFileInfo($index) ?: new stdClass;
|
|
144
|
+
if (!self::truthy($info->breadcrumb ?? null)) break; // separator page: stop, exclude it
|
|
145
|
+
$info = clone $info;
|
|
146
|
+
$info->file = realpath($index);
|
|
147
|
+
$trail[] = $info;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if ($dir === $root) break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return array_reverse($trail);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Loose truthiness test for a PHPDOC tag value (`true` / `1` / `yes` /
|
|
159
|
+
* `on`, case-insensitive). PHPDOC values always come through as strings,
|
|
160
|
+
* but bool/int are handled too for callers that pass a raw value.
|
|
161
|
+
*/
|
|
162
|
+
private static function truthy(mixed $v): bool
|
|
163
|
+
{
|
|
164
|
+
if (is_bool($v)) return $v;
|
|
165
|
+
if (is_int($v)) return $v === 1;
|
|
166
|
+
if (!is_string($v)) return false;
|
|
167
|
+
return in_array(strtolower(trim($v)), ['1', 'true', 'yes', 'on'], true);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
51
171
|
public static function phpFileInfo(string $file): object|bool
|
|
52
172
|
{
|
|
53
173
|
static $files = [];
|
|
@@ -62,17 +182,52 @@ class FS
|
|
|
62
182
|
}
|
|
63
183
|
}
|
|
64
184
|
if (empty($block)) return new stdClass;
|
|
65
|
-
|
|
66
|
-
else {
|
|
67
|
-
$info = [];
|
|
68
|
-
foreach ($m[1] as $k => $v) $info[trim($v)] = trim($m[2][$k]);
|
|
69
|
-
$files[$file] = (object)$info;
|
|
70
|
-
}
|
|
185
|
+
$files[$file] = (object) self::parseDocBlock($block);
|
|
71
186
|
}
|
|
72
187
|
return $files[$file];
|
|
73
188
|
}
|
|
74
189
|
|
|
75
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Parses the `@tag value` lines of a PHPDOC block into a `tag => value` map.
|
|
193
|
+
*
|
|
194
|
+
* Only lines whose first non-whitespace character (past an optional `*`
|
|
195
|
+
* gutter) is an `@` start a tag — so an `@word` dropped mid-sentence in the
|
|
196
|
+
* block's prose is ignored and can't clobber a real tag. A tag's value may
|
|
197
|
+
* wrap onto the following *indented* (hanging-indent) continuation lines,
|
|
198
|
+
* up to the next `@tag`, a blank line, a flush-left prose line, or the end
|
|
199
|
+
* of the block; continuation lines are joined with a single space.
|
|
200
|
+
*
|
|
201
|
+
* @param string $block Raw `/** … */` doc-comment text.
|
|
202
|
+
* @return array<string,string>
|
|
203
|
+
*/
|
|
204
|
+
private static function parseDocBlock(string $block): array
|
|
205
|
+
{
|
|
206
|
+
$info = [];
|
|
207
|
+
$current = null;
|
|
208
|
+
|
|
209
|
+
foreach (preg_split('/\r\n|\r|\n/', $block) as $line) {
|
|
210
|
+
// Drop the opening `/**`, a ` * ` gutter, and the closing ` */`.
|
|
211
|
+
$line = preg_replace('#^\s*/\*\*+#', '', $line);
|
|
212
|
+
$line = preg_replace('#\s*\*/\s*$#', '', $line);
|
|
213
|
+
$line = preg_replace('#^[ \t]*\*[ \t]?#', '', $line, 1);
|
|
214
|
+
|
|
215
|
+
if (preg_match('/^[ \t]*@([A-Za-z0-9_]+)[ \t]*(.*)$/', $line, $m)) {
|
|
216
|
+
$current = trim($m[1]);
|
|
217
|
+
$info[$current] = trim($m[2]);
|
|
218
|
+
} elseif (trim($line) === '') {
|
|
219
|
+
$current = null; // blank line ends a value
|
|
220
|
+
} elseif ($current !== null && preg_match('/^[ \t]/', $line)) {
|
|
221
|
+
$info[$current] = trim($info[$current] . ' ' . trim($line)); // hanging indent → continuation
|
|
222
|
+
} else {
|
|
223
|
+
$current = null; // flush-left prose ends a value
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return $info;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
76
231
|
public static function rmdir(string $dir, bool $removeSelf = true): bool
|
|
77
232
|
{
|
|
78
233
|
if (!file_exists($dir)) return true;
|
|
@@ -28,21 +28,35 @@ class HTML
|
|
|
28
28
|
];
|
|
29
29
|
|
|
30
30
|
private const RAW = ['script', 'style'];
|
|
31
|
+
// Whitespace-significant elements: their text content is emitted byte for
|
|
32
|
+
// byte, never re-indented or collapsed (a code block must keep its line
|
|
33
|
+
// breaks and leading spaces; a <textarea> its exact value).
|
|
34
|
+
private const VERBATIM = ['pre', 'textarea'];
|
|
31
35
|
private const VOID = [
|
|
32
36
|
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
|
|
33
37
|
'link', 'meta', 'source', 'track', 'wbr',
|
|
34
38
|
];
|
|
35
39
|
|
|
40
|
+
/** Verbatim blocks stashed during a format() run, keyed by placeholder. */
|
|
41
|
+
private static array $verbatim = [];
|
|
42
|
+
|
|
36
43
|
public static function format(string $html): string
|
|
37
44
|
{
|
|
38
45
|
$dom = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);
|
|
39
46
|
|
|
47
|
+
self::$verbatim = [];
|
|
40
48
|
$output = '';
|
|
41
49
|
foreach ($dom->childNodes as $node) {
|
|
42
50
|
$output .= static::renderNode($node, 0);
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
// Collapse runs of blank lines — but not inside <pre>/<textarea>, whose
|
|
54
|
+
// content was swapped out for a placeholder above.
|
|
45
55
|
$output = preg_replace('/\n{3,}/', "\n\n", $output);
|
|
56
|
+
if (self::$verbatim) {
|
|
57
|
+
$output = strtr($output, self::$verbatim);
|
|
58
|
+
self::$verbatim = [];
|
|
59
|
+
}
|
|
46
60
|
|
|
47
61
|
return rtrim($output) . "\n";
|
|
48
62
|
}
|
|
@@ -69,6 +83,44 @@ class HTML
|
|
|
69
83
|
return "{$pad}<{$tag}{$attrs}>\n";
|
|
70
84
|
}
|
|
71
85
|
|
|
86
|
+
// `<pre><code>` (a fenced code block): re-indent the code to this
|
|
87
|
+
// element's depth so the HTML source stays readable. The exact leading
|
|
88
|
+
// run added here is stripped again before display — at build time by
|
|
89
|
+
// @kirigami/plugin-highlight, otherwise by the small de-indent script
|
|
90
|
+
// Kirigami injects (prepros.head). Relative indentation is preserved.
|
|
91
|
+
if ($tag === 'pre') {
|
|
92
|
+
$code = self::soleCodeChild($node);
|
|
93
|
+
if ($code !== null) {
|
|
94
|
+
$childPad = str_repeat(' ', ($depth + 1) * self::INDENT);
|
|
95
|
+
$lines = explode("\n", $code->innerHTML);
|
|
96
|
+
|
|
97
|
+
$min = PHP_INT_MAX;
|
|
98
|
+
foreach ($lines as $line) {
|
|
99
|
+
if (trim($line) === '') continue;
|
|
100
|
+
$min = min($min, strlen($line) - strlen(ltrim($line, ' ')));
|
|
101
|
+
}
|
|
102
|
+
$min = $min === PHP_INT_MAX ? 0 : $min;
|
|
103
|
+
|
|
104
|
+
$body = implode("\n", array_map(
|
|
105
|
+
static fn(string $line) => trim($line) === '' ? '' : $childPad . substr($line, $min),
|
|
106
|
+
$lines
|
|
107
|
+
));
|
|
108
|
+
$token = "\x01VERB" . count(self::$verbatim) . "\x01";
|
|
109
|
+
self::$verbatim[$token] = "\n" . rtrim($body, "\n") . "\n{$pad}";
|
|
110
|
+
return "{$pad}<pre><code" . static::renderAttrs($code) . ">{$token}</code></pre>\n";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Whitespace-significant: emit the inner HTML exactly as parsed, so a
|
|
115
|
+
// bare <pre>/<textarea> keeps its line breaks and indentation. Only the
|
|
116
|
+
// opening tag is padded to the current depth.
|
|
117
|
+
if (in_array($tag, self::VERBATIM, true)) {
|
|
118
|
+
/** @var DOMElement $node */
|
|
119
|
+
$token = "\x01VERB" . count(self::$verbatim) . "\x01";
|
|
120
|
+
self::$verbatim[$token] = $node->innerHTML;
|
|
121
|
+
return "{$pad}<{$tag}{$attrs}>" . $token . "</{$tag}>\n";
|
|
122
|
+
}
|
|
123
|
+
|
|
72
124
|
if ($isRaw) {
|
|
73
125
|
/** @var DOMElement $node */
|
|
74
126
|
$inner = trim($node->innerHTML, "\n\r");
|
|
@@ -190,6 +242,23 @@ class HTML
|
|
|
190
242
|
return true;
|
|
191
243
|
}
|
|
192
244
|
|
|
245
|
+
// The lone <code> child of a <pre> (a fenced code block), or null when the
|
|
246
|
+
// <pre> holds anything else — raw text, several elements, markup to keep.
|
|
247
|
+
private static function soleCodeChild(Dom\Node $node): ?Dom\Node
|
|
248
|
+
{
|
|
249
|
+
$code = null;
|
|
250
|
+
foreach ($node->childNodes as $child) {
|
|
251
|
+
if ($child->nodeType === XML_TEXT_NODE) {
|
|
252
|
+
if (trim($child->nodeValue) !== '') return null;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if ($code !== null || $child->nodeType !== XML_ELEMENT_NODE) return null;
|
|
256
|
+
if (strtolower($child->nodeName) !== 'code') return null;
|
|
257
|
+
$code = $child;
|
|
258
|
+
}
|
|
259
|
+
return $code;
|
|
260
|
+
}
|
|
261
|
+
|
|
193
262
|
private static function renderText(Dom\Node $node, int $depth): string
|
|
194
263
|
{
|
|
195
264
|
$text = trim($node->nodeValue);
|
|
@@ -147,6 +147,13 @@ class IMG
|
|
|
147
147
|
|
|
148
148
|
public function resize(int $width, int $height = 0, bool $cover = false)
|
|
149
149
|
{
|
|
150
|
+
// Never upscale. The AVIF encoder (and, less visibly, the others) can
|
|
151
|
+
// choke on an enlarged raster, and blowing pixels up gains nothing —
|
|
152
|
+
// so a target larger than the source is clamped down to it rather than
|
|
153
|
+
// throwing an opaque encode error. Ask for sizes <= the source.
|
|
154
|
+
if ($width > $this->width) $width = $this->width;
|
|
155
|
+
if ($height > $this->height) $height = $this->height;
|
|
156
|
+
|
|
150
157
|
$srcRatio = $this->width / $this->height;
|
|
151
158
|
|
|
152
159
|
if (!$height) {
|
|
@@ -397,17 +404,25 @@ class IMG
|
|
|
397
404
|
}
|
|
398
405
|
|
|
399
406
|
|
|
400
|
-
|
|
407
|
+
/**
|
|
408
|
+
* Encodes the current image to $dest, the format taken from the file
|
|
409
|
+
* extension (jpg/jpeg, png, gif, webp, avif).
|
|
410
|
+
*
|
|
411
|
+
* $quality (0-100) applies to the lossy formats (jpg, webp, avif); it is
|
|
412
|
+
* ignored for png (whose second arg is a 0-9 compression level) and gif.
|
|
413
|
+
* When null, each format keeps its own default (82 for jpg/webp/avif).
|
|
414
|
+
*/
|
|
415
|
+
public function save(string $dest, ?int $quality = null): self
|
|
401
416
|
{
|
|
402
417
|
$ext = strtolower(pathinfo($dest, PATHINFO_EXTENSION));
|
|
403
418
|
$dir = pathinfo($dest, PATHINFO_DIRNAME);
|
|
404
419
|
if (!is_dir($dir) && !@mkdir($dir, 0777, true)) throw new Exception("Invalid destination.");
|
|
405
420
|
$ok = match ($ext) {
|
|
406
|
-
'jpg', 'jpeg' => imagejpeg($this->im, $dest, 82),
|
|
421
|
+
'jpg', 'jpeg' => imagejpeg($this->im, $dest, $quality ?? 82),
|
|
407
422
|
'png' => imagepng($this->im, $dest, 6),
|
|
408
423
|
'gif' => imagegif($this->im, $dest),
|
|
409
|
-
'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, 82) : false),
|
|
410
|
-
'avif' => (function_exists('imageavif') ? $this->encodeAvif($this->im, $dest, 82) : false),
|
|
424
|
+
'webp' => (function_exists('imagewebp') ? imagewebp($this->im, $dest, $quality ?? 82) : false),
|
|
425
|
+
'avif' => (function_exists('imageavif') ? $this->encodeAvif($this->im, $dest, $quality ?? 82) : false),
|
|
411
426
|
default => throw new Exception("Invalid output file type.")
|
|
412
427
|
};
|
|
413
428
|
if (!$ok) throw new Exception("Failed to encode image as '{$ext}'.");
|