@wxxb789/anc 0.0.1 → 0.0.3

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.
@@ -0,0 +1,1426 @@
1
+ /**
2
+ * The single producer-owned media decision: which bytes a referenced image
3
+ * publishes, and under what public URL.
4
+ *
5
+ * Goal 0009's contract in one sentence: **an image is published because a
6
+ * published note references it, at a URL its content digest names, as the exact
7
+ * bytes the author wrote.** Goal 0011 keeps that authorization and identity
8
+ * boundary but changes which bytes are emitted for an ordinary JPEG or 8-bit
9
+ * PNG: a smaller WebP candidate may replace them. Everything in this module
10
+ * serves the publication decision and nothing else is allowed to make it.
11
+ *
12
+ * ## What is deliberately *not* here
13
+ *
14
+ * - **No author-facing resize, crop, or metadata scrub.** The encoder may
15
+ * remove metadata only on a transcode, where orientation is applied first.
16
+ * Passthrough originals keep their EXIF and GPS tags. Do not describe
17
+ * passthrough as scrubbing metadata; it is not.
18
+ * - **SVG is a source, never an output.** Goal 0012 accepts a static,
19
+ * self-contained SVG as build-only input: `svg-policy.ts` decides whether it is
20
+ * in the subset and `svg-render.ts` turns it into lossless WebP. The XML never
21
+ * reaches a public file. `looksLikeSvg` is the cheap signature that selects
22
+ * that path; it is not the policy.
23
+ * - **One encoder decision.** `media-codec.ts` owns the WebP policy; this module
24
+ * owns format identity, structural completeness, dimensions, resolution and
25
+ * URL naming.
26
+ *
27
+ * ## Public identity is the digest, and the name is decoration
28
+ *
29
+ * The public path is `/_assets/<sha256>-<name>`, where `<name>` is the source
30
+ * basename with a canonical extension. Two source files with identical bytes
31
+ * therefore publish **one** asset (the digest collides and the first name in
32
+ * deterministic order wins), and unchanged bytes republish the same URL on every
33
+ * build. What a consumer may rely on is the digest prefix; the name after it
34
+ * exists so a reader who saves an image gets a filename that says something, and
35
+ * so an author grepping their own built site can find the picture they wrote.
36
+ *
37
+ * The name is percent-encoded and bounded before it enters a URL, because it is
38
+ * the one part of the path the author controls and a Markdown destination, an
39
+ * HTML attribute, a URL path segment, and a static host's file lookup do not all
40
+ * agree about spaces, `(`, `#`, or non-ASCII.
41
+ *
42
+ * ## One resolver, two tiers, no basename fallback
43
+ *
44
+ * {@link resolveImageTarget} resolves an image reference **relative to the
45
+ * writing note, then relative to the content root**, and it stops there. It
46
+ * deliberately does *not* reproduce `resolveLink`'s five tiers: a note link
47
+ * resolves against every file's basename because Obsidian does, and the same
48
+ * behaviour applied to an image would turn `![draft](chart.png)` written beside
49
+ * `chart.png` into the `chart.png` in some unrelated directory the moment the
50
+ * note-relative file was renamed. A broken image is a build failure, so the
51
+ * resolver must be able to say "nothing named that" rather than resolve to the
52
+ * nearest plausible file.
53
+ */
54
+ /**
55
+ * The formats, and the extension each one publishes under.
56
+ *
57
+ * The extension is **derived from the emitted bytes rather than from the source
58
+ * filename**, because a static host decides `Content-Type` from the extension.
59
+ * `photo.jpeg` and `photo.jpg` are the same format and both publish as `.jpg`;
60
+ * a PNG misnamed `diagram.jpg` publishes as `.webp` when the policy keeps its
61
+ * candidate, or `.png` when it is retained. A static host never serves
62
+ * `image/jpeg` for bytes that are not JPEG.
63
+ */
64
+ const IMAGE_FORMATS = {
65
+ png: { extension: '.png', mime: 'image/png' },
66
+ jpeg: { extension: '.jpg', mime: 'image/jpeg' },
67
+ gif: { extension: '.gif', mime: 'image/gif' },
68
+ webp: { extension: '.webp', mime: 'image/webp' },
69
+ avif: { extension: '.avif', mime: 'image/avif' },
70
+ };
71
+ /** The extension a format publishes under, dot included. */
72
+ export function imageExtension(format) {
73
+ return IMAGE_FORMATS[format].extension;
74
+ }
75
+ /** The MIME type a format publishes as. */
76
+ export function imageMimeType(format) {
77
+ return IMAGE_FORMATS[format].mime;
78
+ }
79
+ /** The format a canonical published extension names, or `undefined`. */
80
+ export function formatForExtension(extension) {
81
+ const wanted = extension.toLowerCase();
82
+ for (const [format, info] of Object.entries(IMAGE_FORMATS)) {
83
+ if (info.extension === wanted)
84
+ return format;
85
+ }
86
+ return undefined;
87
+ }
88
+ /** The public directory every emitted image lands in. */
89
+ export const ASSET_DIRECTORY = '_assets';
90
+ /** Prefix of every published asset URL. */
91
+ export const ASSET_URL_PREFIX = `/${ASSET_DIRECTORY}/`;
92
+ /**
93
+ * Longest name suffix an emitted asset may carry, in UTF-8 bytes.
94
+ *
95
+ * A published filename is `<64 hex>-<name>`, so 65 bytes plus this must stay
96
+ * under the 255-byte name limit every common filesystem enforces, including
97
+ * with room for the host's own temporary suffixes. The name is decoration —
98
+ * truncating it changes nothing a digest-keyed identity depends on — so cutting
99
+ * it is always safe and refusing an image because its author wrote a long name
100
+ * would not be.
101
+ */
102
+ export const ASSET_NAME_MAX_BYTES = 120;
103
+ /** A digest as this module and `schema.ts` both spell it. */
104
+ export const ASSET_DIGEST = /^[0-9a-f]{64}$/;
105
+ /** The parser for a published asset URL. See {@link parseAssetUrl}. */
106
+ const ASSET_URL = /^\/_assets\/([0-9a-f]{64})-(.+)$/;
107
+ /** Anything a URL parser would treat as an absolute scheme. */
108
+ const HAS_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:/;
109
+ function byteLength(text) {
110
+ return new TextEncoder().encode(text).length;
111
+ }
112
+ /**
113
+ * Cut a string to a UTF-8 byte budget without splitting a character.
114
+ *
115
+ * Code points rather than UTF-16 units, for the reason the task-list label bound
116
+ * gives: `slice` on a surrogate pair leaves half a character, which a reader and
117
+ * a filesystem both see as a replacement glyph. A code point that does not fit is
118
+ * dropped whole, so the result is always a prefix of the input in bytes.
119
+ */
120
+ function truncateBytes(text, budget) {
121
+ let used = 0;
122
+ let result = '';
123
+ for (const character of text) {
124
+ const size = byteLength(character);
125
+ if (used + size > budget)
126
+ break;
127
+ used += size;
128
+ result += character;
129
+ }
130
+ return result;
131
+ }
132
+ /**
133
+ * The name part of a published asset, from a source basename.
134
+ *
135
+ * Deterministic, bounded, and safe in a URL path segment and on disk:
136
+ *
137
+ * - control and format characters are removed (a filename may legally contain a
138
+ * newline or a bidi override, and neither belongs in a public path);
139
+ * - `/` and `\` become `_`, so the name can never introduce a path segment;
140
+ * - a stem that is empty, or only dots, becomes `image`;
141
+ * - the source's own extension is replaced by the format's canonical one;
142
+ * - the stem is truncated to {@link ASSET_NAME_MAX_BYTES}.
143
+ */
144
+ export function assetFileName(sourceName, format) {
145
+ const extension = imageExtension(format);
146
+ const dot = sourceName.lastIndexOf('.');
147
+ const rawStem = dot > 0 ? sourceName.slice(0, dot) : sourceName;
148
+ const cleaned = rawStem
149
+ .replace(/[\p{Cc}\p{Cf}]+/gu, '')
150
+ .replace(/[\\/]+/gu, '_')
151
+ .trim();
152
+ const stem = cleaned === '' || /^\.+$/.test(cleaned) ? 'image' : cleaned;
153
+ const budget = Math.max(1, ASSET_NAME_MAX_BYTES - byteLength(extension));
154
+ return `${truncateBytes(stem, budget)}${extension}`;
155
+ }
156
+ /**
157
+ * Percent-encode one asset name for a URL path segment.
158
+ *
159
+ * `encodeURIComponent` leaves `!'()*` unescaped, and all four are hostile in at
160
+ * least one of the contexts this string lands in: `(` and `)` close a Markdown
161
+ * destination early, `'` is an attribute delimiter a host may quote with, and
162
+ * `*` is a glob metacharacter for anything that matches published paths. So those
163
+ * are escaped too, and what remains is unreserved characters and `%XX` only —
164
+ * which is why {@link parseAssetUrl} can reject every alternative spelling.
165
+ */
166
+ function encodeAssetName(name) {
167
+ return encodeURIComponent(name).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
168
+ }
169
+ /**
170
+ * Whether a decoded name is one this build could have emitted.
171
+ *
172
+ * The extension must be the **canonical** one, not merely a supported format:
173
+ * `.jpeg` and `.PNG` are real spellings of real formats, and accepting them would
174
+ * give one asset several URLs. The parser rejects every non-canonical spelling,
175
+ * so "the file this URL names" has exactly one answer.
176
+ */
177
+ function isAssetFileName(name) {
178
+ if (name === '' || name === '.' || name === '..')
179
+ return false;
180
+ if (/[\\/]/.test(name) || /[\p{Cc}\p{Cf}]/u.test(name))
181
+ return false;
182
+ if (byteLength(name) > ASSET_NAME_MAX_BYTES)
183
+ return false;
184
+ const dot = name.lastIndexOf('.');
185
+ if (dot <= 0)
186
+ return false;
187
+ const format = formatForExtension(name.slice(dot));
188
+ return format !== undefined && imageExtension(format) === name.slice(dot);
189
+ }
190
+ /**
191
+ * The public URL for one asset: its digest, then its name.
192
+ *
193
+ * Throws on a digest that is not the shape this module and `schema.ts` agree
194
+ * on, because a caller passing anything else is a producer defect rather than a
195
+ * user's mistake, and a malformed identity is the one thing a content-addressed
196
+ * URL cannot tolerate.
197
+ */
198
+ export function assetUrlFor(digest, fileName) {
199
+ if (!ASSET_DIGEST.test(digest)) {
200
+ throw new Error(`an asset digest must be 64 lowercase hex characters, got ${JSON.stringify(digest)}`);
201
+ }
202
+ if (!isAssetFileName(fileName)) {
203
+ throw new Error(`not a publishable asset name: ${JSON.stringify(fileName)}`);
204
+ }
205
+ return `${ASSET_URL_PREFIX}${digest}-${encodeAssetName(fileName)}`;
206
+ }
207
+ /**
208
+ * The digest and on-disk name a published asset URL names, or `undefined`.
209
+ *
210
+ * Canonical form only: the name must re-encode to exactly the bytes in the URL,
211
+ * so `%41` is not an acceptable spelling of `A` and one asset cannot be reached
212
+ * through two paths. This is what lets the copy step and the output inventory
213
+ * treat "the file named by this URL" as a single, unambiguous question.
214
+ */
215
+ export function parseAssetUrl(url) {
216
+ const match = ASSET_URL.exec(url);
217
+ if (match === null)
218
+ return undefined;
219
+ const encoded = match[2];
220
+ let fileName;
221
+ try {
222
+ fileName = decodeURIComponent(encoded);
223
+ }
224
+ catch {
225
+ return undefined;
226
+ }
227
+ if (!isAssetFileName(fileName) || encodeAssetName(fileName) !== encoded)
228
+ return undefined;
229
+ return { digest: match[1], fileName };
230
+ }
231
+ /** Whether a URL names the asset namespace at all, valid or not. */
232
+ export function isAssetUrl(url) {
233
+ return url.startsWith(ASSET_URL_PREFIX);
234
+ }
235
+ /* ------------------------------------------------------------------------- *
236
+ * Raw image sources
237
+ * ------------------------------------------------------------------------- */
238
+ /** The schemes the producer must report instead of leaving to the sanitizer. */
239
+ const REFUSED_IMAGE_SCHEMES = {
240
+ file: true,
241
+ javascript: true,
242
+ vbscript: true,
243
+ };
244
+ /**
245
+ * Classify one raw-HTML `<img src>`: remote, refused, or local.
246
+ *
247
+ * **One classifier, two callers.** `scripts/resolve-links.ts` uses it to decide
248
+ * whether a raw `<img>` is an `html-form` failure, and `src/lib/markdown.ts`
249
+ * uses it to decide whether the renderer may skip the published-asset check.
250
+ * Before this function the producer tested the raw bytes while the renderer
251
+ * stripped whitespace first, so `<img src=" https://example.test/pic.png">`
252
+ * failed the build but rendered unchanged: one URL, two answers.
253
+ *
254
+ * The rule is the renderer's (and `sanitize-html`'s `launder`): remove **every**
255
+ * character at or below U+0020, then a URL is **remote** when it begins with an
256
+ * RFC 3986 scheme or with a protocol-relative `//` (or the `\\` spelling the
257
+ * renderer already accepts). Within that set, `file:`, `javascript:` and
258
+ * `vbscript:` are **refused** rather than remote: the producer must report them
259
+ * as the local/script forms Goal 0009 names, while the renderer still treats
260
+ * them as non-local, so the sanitizer keeps deciding their fate and removes the
261
+ * attribute exactly as before. Everything else — a bare relative path, an
262
+ * all-whitespace value, the empty string — is **local**.
263
+ *
264
+ * The refused set is a deny list rather than an allow list. Other schemes
265
+ * (`ftp:`, `mailto:`) were accepted by the producer before this function and
266
+ * are still accepted here; the renderer's scheme policy already removes them
267
+ * from the page, and turning that into a new build failure would widen this
268
+ * fix past the local/script forms the goal names.
269
+ */
270
+ export function classifyImageSource(source) {
271
+ // The value `sanitize-html`'s `launder` reads: every character at or below
272
+ // U+0020 removed. A filter rather than a control-character range in a regex
273
+ // keeps the raw control classes banned by lint out of this file.
274
+ const cleaned = Array.from(source, (character) => (character <= ' ' ? '' : character)).join('');
275
+ const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(cleaned)?.[1]?.toLowerCase();
276
+ const remote = scheme !== undefined || /^[/\\]{2}/.test(cleaned);
277
+ if (!remote)
278
+ return 'local';
279
+ if (scheme !== undefined && REFUSED_IMAGE_SCHEMES[scheme] === true)
280
+ return 'refused';
281
+ return 'remote';
282
+ }
283
+ /* ------------------------------------------------------------------------- *
284
+ * Format detection
285
+ * ------------------------------------------------------------------------- */
286
+ function matchesAt(bytes, offset, text) {
287
+ if (bytes.length < offset + text.length)
288
+ return false;
289
+ for (let index = 0; index < text.length; index += 1) {
290
+ if (bytes[offset + index] !== text.charCodeAt(index))
291
+ return false;
292
+ }
293
+ return true;
294
+ }
295
+ /**
296
+ * The raster format the bytes actually are, or `undefined`.
297
+ *
298
+ * Signature bytes, not the extension. Goal 0009 must serve the *intended* content
299
+ * type, and every static host infers that from the extension, so a file whose
300
+ * name disagrees with its bytes has to be identified by the bytes to publish
301
+ * under an extension that is true. It is also what makes the SVG refusal total:
302
+ * an SVG named `diagram.png` is not a PNG.
303
+ *
304
+ * The signatures are the formats' own, and the function is deliberately not a
305
+ * validator — a truncated PNG is detected as PNG here. Structural completeness
306
+ * is a separate gate, {@link assertCompleteContainer}, which runs before the
307
+ * decoder can turn a partial container into pixels.
308
+ */
309
+ export function sniffImageFormat(bytes) {
310
+ if (bytes.length >= 8 && bytes[0] === 0x89 && matchesAt(bytes, 1, 'PNG'))
311
+ return 'png';
312
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)
313
+ return 'jpeg';
314
+ if (matchesAt(bytes, 0, 'GIF87a') || matchesAt(bytes, 0, 'GIF89a'))
315
+ return 'gif';
316
+ if (matchesAt(bytes, 0, 'RIFF') && matchesAt(bytes, 8, 'WEBP'))
317
+ return 'webp';
318
+ // AVIF is ISO BMFF: a `ftyp` box whose major or compatible brand is `avif` or
319
+ // `avis`. Some encoders write `mif1` as the major brand and `avif` among the
320
+ // compatible ones, so the brand list is scanned rather than only offset 8.
321
+ if (bytes.length >= 16 && matchesAt(bytes, 4, 'ftyp')) {
322
+ const size = u32be(bytes, 0);
323
+ if (size >= 16 && size <= 256 && size <= bytes.length) {
324
+ for (let offset = 8; offset + 4 <= size; offset += 4) {
325
+ if (matchesAt(bytes, offset, 'avif') || matchesAt(bytes, offset, 'avis'))
326
+ return 'avif';
327
+ }
328
+ }
329
+ }
330
+ return undefined;
331
+ }
332
+ /**
333
+ * Whether the first 1024 bytes contain an SVG root element.
334
+ *
335
+ * Consulted *after* {@link sniffImageFormat} declined, to select Goal 0012's SVG
336
+ * path rather than "unsupported type". Like the raster sniffers this is a
337
+ * signature test, not a validator: it looks for `<svg>` (optionally namespaced) at
338
+ * any position in the bounded head, so an XML declaration, comments, a DOCTYPE, or
339
+ * processing instructions in the prologue still reach the policy and are refused
340
+ * there with their own diagnostics instead of being misreported as a plain text
341
+ * file. A UTF-16 document does not match this byte signature and is reported as an
342
+ * unsupported file. It must not grow into a parser — the policy module owns that.
343
+ */
344
+ export function looksLikeSvg(bytes) {
345
+ const head = new TextDecoder('utf-8').decode(bytes.subarray(0, 1024));
346
+ return /<(?:[A-Za-z_][\w.-]*:)?svg(?=[\s/>])/i.test(head);
347
+ }
348
+ function u16be(bytes, offset) {
349
+ return (bytes[offset] << 8) | bytes[offset + 1];
350
+ }
351
+ function u16le(bytes, offset) {
352
+ return bytes[offset] | (bytes[offset + 1] << 8);
353
+ }
354
+ function u24le(bytes, offset) {
355
+ return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16);
356
+ }
357
+ function u32be(bytes, offset) {
358
+ return ((bytes[offset] << 24) | (bytes[offset + 1] << 16) | (bytes[offset + 2] << 8) | bytes[offset + 3]) >>> 0;
359
+ }
360
+ function u32le(bytes, offset) {
361
+ return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
362
+ }
363
+ /**
364
+ * Whether a value is a positive whole pixel count.
365
+ *
366
+ * Shared by the header readers here and the artifact-contract check in
367
+ * `schema.ts`, so “a usable dimension” has one definition rather than two
368
+ * spellings that can drift.
369
+ */
370
+ export function isPositiveDimension(value) {
371
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
372
+ }
373
+ function positive(width, height) {
374
+ return isPositiveDimension(width) && isPositiveDimension(height) ? { width, height } : undefined;
375
+ }
376
+ function tiffU16(bytes, offset, littleEndian) {
377
+ return littleEndian ? u16le(bytes, offset) : u16be(bytes, offset);
378
+ }
379
+ function tiffU32(bytes, offset, littleEndian) {
380
+ return littleEndian ? u32le(bytes, offset) : u32be(bytes, offset);
381
+ }
382
+ /**
383
+ * The IFD0 `Orientation` tag from one TIFF/EXIF payload, or `undefined` when the
384
+ * payload has no such tag.
385
+ *
386
+ * A present-but-unreadable tag, a malformed TIFF header, or an IFD that runs out
387
+ * of bounds returns `'malformed'`: the orientation is then unknown, and the
388
+ * caller must emit no dimensions rather than a pair that describes the coded
389
+ * pixels instead of the displayed ones. Only a value of 1 is safe to pass
390
+ * through; every other EXIF value transforms the displayed image.
391
+ */
392
+ function exifOrientation(bytes, start, end) {
393
+ if (start + 8 > end)
394
+ return 'malformed';
395
+ const littleEndian = matchesAt(bytes, start, 'II');
396
+ if (!littleEndian && !matchesAt(bytes, start, 'MM'))
397
+ return 'malformed';
398
+ if (tiffU16(bytes, start + 2, littleEndian) !== 42)
399
+ return 'malformed';
400
+ const ifdOffset = tiffU32(bytes, start + 4, littleEndian);
401
+ if (ifdOffset < 8 || start + ifdOffset + 2 > end)
402
+ return 'malformed';
403
+ const entryCount = tiffU16(bytes, start + ifdOffset, littleEndian);
404
+ const entriesStart = start + ifdOffset + 2;
405
+ if (entriesStart + entryCount * 12 > end)
406
+ return 'malformed';
407
+ for (let index = 0; index < entryCount; index += 1) {
408
+ const entry = entriesStart + index * 12;
409
+ if (tiffU16(bytes, entry, littleEndian) !== 0x0112)
410
+ continue;
411
+ const type = tiffU16(bytes, entry + 2, littleEndian);
412
+ const count = tiffU32(bytes, entry + 4, littleEndian);
413
+ if (type !== 3 || count !== 1)
414
+ return 'malformed';
415
+ return tiffU16(bytes, entry + 8, littleEndian);
416
+ }
417
+ return undefined;
418
+ }
419
+ /** The chunk at one offset, or `undefined` when its length/type header is absent. */
420
+ function pngChunkAt(bytes, offset) {
421
+ if (offset + 8 > bytes.length)
422
+ return undefined;
423
+ const length = u32be(bytes, offset);
424
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
425
+ const dataStart = offset + 8;
426
+ const dataEnd = dataStart + length;
427
+ return { type, dataStart, dataEnd, next: dataEnd + 4 };
428
+ }
429
+ /**
430
+ * PNG: signature, then a 13-byte `IHDR` chunk whose width and height are
431
+ * big-endian.
432
+ *
433
+ * Chunks are also walked for `eXIf` so a stored EXIF orientation can veto the
434
+ * pair. An orientation other than 1 means the browser will display the image
435
+ * rotated or mirrored relative to the IHDR pixels, and omitting the attributes
436
+ * is the only honest fail-open answer.
437
+ */
438
+ function pngDimensions(bytes) {
439
+ // Signature, the IHDR length/type word pair, the 13 data bytes, and its CRC.
440
+ // Stopping at the height would read a chunk the file never finished writing.
441
+ if (bytes.length < 33 || !matchesAt(bytes, 12, 'IHDR'))
442
+ return undefined;
443
+ if (u32be(bytes, 8) !== 13)
444
+ return undefined;
445
+ const dimensions = positive(u32be(bytes, 16), u32be(bytes, 20));
446
+ if (dimensions === undefined)
447
+ return undefined;
448
+ let offset = 8;
449
+ while (offset + 8 <= bytes.length) {
450
+ const chunk = pngChunkAt(bytes, offset);
451
+ if (chunk === undefined)
452
+ break;
453
+ if (chunk.dataEnd > bytes.length) {
454
+ // A truncated metadata chunk is unreadable; a truncated file with no
455
+ // eXIf encountered keeps its IHDR pair, exactly as Goal 0009 publishes it.
456
+ return chunk.type === 'eXIf' ? undefined : dimensions;
457
+ }
458
+ if (chunk.type === 'eXIf') {
459
+ const tiffStart = matchesAt(bytes, chunk.dataStart, 'Exif\0\0') ? chunk.dataStart + 6 : chunk.dataStart;
460
+ const orientation = exifOrientation(bytes, tiffStart, chunk.dataEnd);
461
+ if (orientation === 'malformed' || (orientation !== undefined && orientation !== 1))
462
+ return undefined;
463
+ }
464
+ if (chunk.type === 'IEND')
465
+ break;
466
+ offset = chunk.next;
467
+ }
468
+ return dimensions;
469
+ }
470
+ /**
471
+ * Whether PNG bytes carry an `acTL` chunk, which makes them an animated PNG.
472
+ *
473
+ * Sharp's metadata does not report an APNG frame count (measured: `pages` is
474
+ * absent for a PNG whose `acTL` chunk precedes `IDAT`), so a publisher that
475
+ * inferred animation from `pages` would transcode the first frame and silently
476
+ * drop the animation. The walk is structural — lengths and type names at the
477
+ * same chunk boundaries {@link pngDimensions} uses — not a search for the four
478
+ * bytes inside compressed image data.
479
+ */
480
+ export function isAnimatedPng(bytes) {
481
+ if (bytes.length < 8 || bytes[0] !== 0x89 || !matchesAt(bytes, 1, 'PNG'))
482
+ return false;
483
+ let offset = 8;
484
+ while (offset + 8 <= bytes.length) {
485
+ const chunk = pngChunkAt(bytes, offset);
486
+ if (chunk === undefined || chunk.dataEnd > bytes.length)
487
+ return false;
488
+ if (chunk.type === 'acTL')
489
+ return true;
490
+ // `acTL` must precede the first image data; stopping here also bounds the
491
+ // walk on a malformed length that would otherwise step past the buffer.
492
+ if (chunk.type === 'IDAT' || chunk.type === 'IEND')
493
+ return false;
494
+ offset = chunk.next;
495
+ }
496
+ return false;
497
+ }
498
+ /**
499
+ * JPEG: walk marker segments through the frame header and any EXIF APP1, then
500
+ * stop at the start of scan.
501
+ *
502
+ * The frame header is a length-prefixed segment; its payload is precision,
503
+ * height, width. Markers with no length are skipped. A frame header after SOS is
504
+ * entropy-coded data, not a header, so SOS ends the walk. An EXIF `Orientation`
505
+ * other than 1 makes the coded frame disagree with the displayed image, so the
506
+ * caller gets no attributes rather than a transposed pair.
507
+ */
508
+ function jpegDimensions(bytes) {
509
+ let offset = 2;
510
+ let dimensions;
511
+ while (offset + 2 <= bytes.length) {
512
+ if (bytes[offset] !== 0xff)
513
+ return undefined;
514
+ while (offset < bytes.length && bytes[offset] === 0xff)
515
+ offset += 1;
516
+ if (offset >= bytes.length)
517
+ return undefined;
518
+ const marker = bytes[offset];
519
+ offset += 1;
520
+ if (marker === 0xd8 || marker === 0xd9)
521
+ return undefined;
522
+ // TEM and the restart markers carry no length; skip them without reading one.
523
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
524
+ continue;
525
+ // The entropy-coded scan begins here. Stop before reading the SOS length.
526
+ if (marker === 0xda)
527
+ return dimensions;
528
+ if (offset + 2 > bytes.length)
529
+ return undefined;
530
+ const length = u16be(bytes, offset);
531
+ if (length < 2 || offset + length > bytes.length)
532
+ return undefined;
533
+ const payloadStart = offset + 2;
534
+ const payloadEnd = offset + length;
535
+ if (marker === 0xe1 && payloadStart + 6 <= payloadEnd && matchesAt(bytes, payloadStart, 'Exif\0\0')) {
536
+ const orientation = exifOrientation(bytes, payloadStart + 6, payloadEnd);
537
+ if (orientation === 'malformed' || (orientation !== undefined && orientation !== 1))
538
+ return undefined;
539
+ }
540
+ const isFrame = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
541
+ if (isFrame && dimensions === undefined) {
542
+ if (length < 7)
543
+ return undefined;
544
+ const precision = bytes[offset + 2];
545
+ const height = u16be(bytes, offset + 3);
546
+ const width = u16be(bytes, offset + 5);
547
+ if (precision === 0)
548
+ return undefined;
549
+ dimensions = positive(width, height);
550
+ if (dimensions === undefined)
551
+ return undefined;
552
+ }
553
+ offset += length;
554
+ }
555
+ return dimensions;
556
+ }
557
+ /** GIF: the logical screen descriptor, little-endian, immediately after the header. */
558
+ function gifDimensions(bytes) {
559
+ if (bytes.length < 10)
560
+ return undefined;
561
+ return positive(u16le(bytes, 6), u16le(bytes, 8));
562
+ }
563
+ /**
564
+ * WebP: RIFF chunks name the bitstream and any EXIF metadata.
565
+ *
566
+ * `VP8X` carries the canvas size as 24-bit minus-one fields; `VP8L` packs both
567
+ * minus-one dimensions into 32 bits; `VP8 ` is a lossy keyframe whose 3-byte
568
+ * frame tag, 3-byte start code, and 2+2 dimension fields follow the chunk
569
+ * header. An `EXIF` chunk with an orientation other than 1 vetoes the pair.
570
+ */
571
+ function webpDimensions(bytes) {
572
+ if (bytes.length < 20)
573
+ return undefined;
574
+ let offset = 12;
575
+ let dimensions;
576
+ while (offset + 8 <= bytes.length) {
577
+ const type = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
578
+ const size = u32le(bytes, offset + 4);
579
+ const dataStart = offset + 8;
580
+ const dataEnd = dataStart + size;
581
+ if (dataEnd > bytes.length)
582
+ return undefined;
583
+ if (type === 'EXIF') {
584
+ const tiffStart = matchesAt(bytes, dataStart, 'Exif\0\0') ? dataStart + 6 : dataStart;
585
+ const orientation = exifOrientation(bytes, tiffStart, dataEnd);
586
+ if (orientation === 'malformed' || (orientation !== undefined && orientation !== 1))
587
+ return undefined;
588
+ }
589
+ else if (dimensions === undefined && type === 'VP8X') {
590
+ // Flags, three reserved bytes, then two 24-bit minus-one canvas fields.
591
+ if (size < 10)
592
+ return undefined;
593
+ dimensions = positive(u24le(bytes, dataStart + 4) + 1, u24le(bytes, dataStart + 7) + 1);
594
+ if (dimensions === undefined)
595
+ return undefined;
596
+ }
597
+ else if (dimensions === undefined && type === 'VP8L') {
598
+ // Signature byte, then both minus-one dimensions packed into 32 bits.
599
+ if (size < 5 || bytes[dataStart] !== 0x2f)
600
+ return undefined;
601
+ const packed = u32le(bytes, dataStart + 1);
602
+ dimensions = positive((packed & 0x3fff) + 1, ((packed >>> 14) & 0x3fff) + 1);
603
+ if (dimensions === undefined)
604
+ return undefined;
605
+ }
606
+ else if (dimensions === undefined && type === 'VP8 ') {
607
+ // Three-byte frame tag, three-byte keyframe start code, then 2+2 dimensions.
608
+ if (size < 10 || !matchesAt(bytes, dataStart + 3, '\x9d\x01\x2a'))
609
+ return undefined;
610
+ dimensions = positive(u16le(bytes, dataStart + 6) & 0x3fff, u16le(bytes, dataStart + 8) & 0x3fff);
611
+ if (dimensions === undefined)
612
+ return undefined;
613
+ }
614
+ offset = dataEnd + (size & 1);
615
+ }
616
+ return dimensions;
617
+ }
618
+ /** One ISO BMFF box at `offset`, bounded by `end`. */
619
+ function isoBoxAt(bytes, offset, end) {
620
+ if (offset + 8 > end)
621
+ return undefined;
622
+ let size = u32be(bytes, offset);
623
+ const type = String.fromCharCode(bytes[offset + 4], bytes[offset + 5], bytes[offset + 6], bytes[offset + 7]);
624
+ let header = 8;
625
+ if (size === 1) {
626
+ if (offset + 16 > end)
627
+ return undefined;
628
+ const high = u32be(bytes, offset + 8);
629
+ const low = u32be(bytes, offset + 12);
630
+ size = high * 2 ** 32 + low;
631
+ header = 16;
632
+ }
633
+ else if (size === 0) {
634
+ size = end - offset;
635
+ }
636
+ if (size < header || !Number.isSafeInteger(size) || offset + size > end) {
637
+ return undefined;
638
+ }
639
+ return { type, payload: offset + header, end: offset + size };
640
+ }
641
+ /** Every sibling box from `start` to `end`; `undefined` if one overruns. */
642
+ function isoBoxes(bytes, start, end) {
643
+ const boxes = [];
644
+ let offset = start;
645
+ while (offset < end) {
646
+ const box = isoBoxAt(bytes, offset, end);
647
+ if (box === undefined)
648
+ return undefined;
649
+ boxes.push(box);
650
+ offset = box.end;
651
+ }
652
+ return boxes;
653
+ }
654
+ function avifDimensions(bytes) {
655
+ const top = isoBoxes(bytes, 0, bytes.length);
656
+ if (top === undefined)
657
+ return undefined;
658
+ const metaBoxes = top.filter((box) => box.type === 'meta');
659
+ if (metaBoxes.length !== 1)
660
+ return undefined;
661
+ const meta = metaBoxes[0];
662
+ // `meta` is a full box. Its version and reserved flags describe the box
663
+ // layout, so an unknown version is not a container this reader can trust.
664
+ if (meta.payload + 4 > meta.end || bytes[meta.payload] !== 0)
665
+ return undefined;
666
+ if (bytes[meta.payload + 1] !== 0 || bytes[meta.payload + 2] !== 0 || bytes[meta.payload + 3] !== 0) {
667
+ return undefined;
668
+ }
669
+ const metaChildren = isoBoxes(bytes, meta.payload + 4, meta.end);
670
+ if (metaChildren === undefined)
671
+ return undefined;
672
+ const pitms = metaChildren.filter((box) => box.type === 'pitm');
673
+ if (pitms.length !== 1)
674
+ return undefined;
675
+ const pitm = pitms[0];
676
+ if (pitm.payload + 4 > pitm.end)
677
+ return undefined;
678
+ if (bytes[pitm.payload + 1] !== 0 || bytes[pitm.payload + 2] !== 0 || bytes[pitm.payload + 3] !== 0) {
679
+ return undefined;
680
+ }
681
+ const pitmVersion = bytes[pitm.payload];
682
+ let primaryItem;
683
+ if (pitmVersion === 0) {
684
+ if (pitm.payload + 6 !== pitm.end)
685
+ return undefined;
686
+ primaryItem = u16be(bytes, pitm.payload + 4);
687
+ }
688
+ else if (pitmVersion === 1) {
689
+ if (pitm.payload + 8 !== pitm.end)
690
+ return undefined;
691
+ primaryItem = u32be(bytes, pitm.payload + 4);
692
+ }
693
+ else {
694
+ return undefined;
695
+ }
696
+ if (primaryItem === 0)
697
+ return undefined;
698
+ const iprps = metaChildren.filter((box) => box.type === 'iprp');
699
+ if (iprps.length !== 1)
700
+ return undefined;
701
+ const iprp = iprps[0];
702
+ const iprpChildren = isoBoxes(bytes, iprp.payload, iprp.end);
703
+ if (iprpChildren === undefined)
704
+ return undefined;
705
+ const ipcos = iprpChildren.filter((box) => box.type === 'ipco');
706
+ const ipmas = iprpChildren.filter((box) => box.type === 'ipma');
707
+ if (ipcos.length !== 1 || ipmas.length !== 1)
708
+ return undefined;
709
+ const ipco = ipcos[0];
710
+ const ipma = ipmas[0];
711
+ const properties = isoBoxes(bytes, ipco.payload, ipco.end);
712
+ if (properties === undefined)
713
+ return undefined;
714
+ if (ipma.payload + 4 > ipma.end)
715
+ return undefined;
716
+ const ipmaVersion = bytes[ipma.payload];
717
+ const ipmaFlags = (bytes[ipma.payload + 1] << 16) |
718
+ (bytes[ipma.payload + 2] << 8) |
719
+ bytes[ipma.payload + 3];
720
+ // Version selects the item-ID width; bit 0 selects the 15-bit property-index
721
+ // field. No other flag or version has a layout this reader knows.
722
+ if (ipmaVersion > 1 || (ipmaFlags & ~1) !== 0)
723
+ return undefined;
724
+ let offset = ipma.payload + 4;
725
+ if (offset + 4 > ipma.end)
726
+ return undefined;
727
+ const entryCount = u32be(bytes, offset);
728
+ offset += 4;
729
+ const itemIdWidth = ipmaVersion === 0 ? 2 : 4;
730
+ const associationWidth = (ipmaFlags & 1) === 0 ? 1 : 2;
731
+ let primaryAssociations;
732
+ let primarySeen = false;
733
+ const indices = [];
734
+ for (let entry = 0; entry < entryCount; entry += 1) {
735
+ if (offset + itemIdWidth > ipma.end)
736
+ return undefined;
737
+ const itemId = itemIdWidth === 2 ? u16be(bytes, offset) : u32be(bytes, offset);
738
+ offset += itemIdWidth;
739
+ if (offset + 1 > ipma.end)
740
+ return undefined;
741
+ const associationCount = bytes[offset];
742
+ offset += 1;
743
+ if (offset + associationCount * associationWidth > ipma.end)
744
+ return undefined;
745
+ const associations = [];
746
+ for (let association = 0; association < associationCount; association += 1) {
747
+ const raw = associationWidth === 2 ? u16be(bytes, offset) : bytes[offset];
748
+ offset += associationWidth;
749
+ // The high bit is the essential flag; the rest is the 1-based property
750
+ // index. Index 0 means "no property" and is skipped when resolving.
751
+ const index = associationWidth === 2 ? raw & 0x7fff : raw & 0x7f;
752
+ associations.push(index);
753
+ indices.push(index);
754
+ }
755
+ if (itemId === primaryItem) {
756
+ if (primarySeen)
757
+ return undefined;
758
+ primarySeen = true;
759
+ primaryAssociations = associations;
760
+ }
761
+ }
762
+ if (offset !== ipma.end || primaryAssociations === undefined)
763
+ return undefined;
764
+ if (indices.some((index) => index > properties.length))
765
+ return undefined;
766
+ let ispe;
767
+ let transformed = false;
768
+ for (const index of primaryAssociations) {
769
+ if (index === 0)
770
+ continue;
771
+ const property = properties[index - 1];
772
+ if (property.type === 'irot' || property.type === 'imir') {
773
+ transformed = true;
774
+ }
775
+ else if (property.type === 'ispe') {
776
+ if (ispe !== undefined)
777
+ return undefined;
778
+ ispe = property;
779
+ }
780
+ }
781
+ if (transformed || ispe === undefined)
782
+ return undefined;
783
+ if (ispe.payload + 12 > ispe.end || bytes[ispe.payload] !== 0)
784
+ return undefined;
785
+ return positive(u32be(bytes, ispe.payload + 4), u32be(bytes, ispe.payload + 8));
786
+ }
787
+ /**
788
+ * The intrinsic pixel dimensions a supported raster's header declares.
789
+ *
790
+ * This is header reading, not decoding: it runs on the bytes Goal 0009 already
791
+ * authorized and publishes, so a layout attribute never becomes a reason to
792
+ * fetch a remote URL, load a third-party decoder, or read past the file. A
793
+ * malformed, truncated, or internally inconsistent header returns `undefined`
794
+ * and the image still publishes; the page then omits `width`/`height` rather
795
+ * than guessing, which is the only safe direction for a fallible hint.
796
+ *
797
+ * `format` is the answer {@link sniffImageFormat} already gave for these exact
798
+ * bytes. The type cannot enforce that pairing, so the caller is trusted to pass
799
+ * the format it just sniffed; this function does not sniff again.
800
+ */
801
+ export function imageDimensions(bytes, format) {
802
+ switch (format) {
803
+ case 'png':
804
+ return pngDimensions(bytes);
805
+ case 'jpeg':
806
+ return jpegDimensions(bytes);
807
+ case 'gif':
808
+ return gifDimensions(bytes);
809
+ case 'webp':
810
+ return webpDimensions(bytes);
811
+ case 'avif':
812
+ return avifDimensions(bytes);
813
+ }
814
+ }
815
+ /**
816
+ * Whether an 8-bit PNG's colour model is one WebP lossless carries faithfully.
817
+ *
818
+ * WebP lossless is an 8-bit format. A 16-bit PNG — or any depth other than 8 —
819
+ * would be reduced by the encoder, changing visible values, so the policy
820
+ * publishes those bytes unchanged. The palette and grayscale colour types are
821
+ * still 8-bit samples; converting them to RGB8 preserves every displayed value.
822
+ */
823
+ export function pngCanUseLosslessWebp(bytes) {
824
+ if (bytes.length < 26 || bytes[0] !== 0x89 || !matchesAt(bytes, 1, 'PNG'))
825
+ return false;
826
+ if (u32be(bytes, 8) !== 13 || !matchesAt(bytes, 12, 'IHDR'))
827
+ return false;
828
+ if (bytes[24] !== 8)
829
+ return false;
830
+ return [0, 2, 3, 4, 6].includes(bytes[25]);
831
+ }
832
+ /** The CRC-32 table PNG chunks use, built once. */
833
+ const CRC32_TABLE = (() => {
834
+ const table = new Uint32Array(256);
835
+ for (let index = 0; index < 256; index += 1) {
836
+ let value = index;
837
+ for (let bit = 0; bit < 8; bit += 1) {
838
+ value = (value & 1) === 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
839
+ }
840
+ table[index] = value >>> 0;
841
+ }
842
+ return table;
843
+ })();
844
+ /** CRC-32 over `[start, end)`, as PNG computes it over chunk type and data. */
845
+ function crc32Of(bytes, start, end) {
846
+ let crc = 0xffffffff;
847
+ for (let index = start; index < end; index += 1) {
848
+ crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8);
849
+ }
850
+ return (crc ^ 0xffffffff) >>> 0;
851
+ }
852
+ function failIncomplete(what) {
853
+ throw new Error(`incomplete ${what} container`);
854
+ }
855
+ /**
856
+ * PNG: every chunk fits, each CRC-32 matches, and a zero-length `IEND` is the
857
+ * final chunk with no trailing bytes.
858
+ *
859
+ * This is what makes a truncated PNG a build failure rather than a plausible
860
+ * decode. Measured, libvips decodes a 96×96 PNG with its final 20 bytes missing
861
+ * and reports success; the chunk walk sees a short stream before libvips is
862
+ * asked. APNG still passes this walk: `fdAT` frames are chunks like any other,
863
+ * and their animation semantics remain 0011's disclosed limit.
864
+ */
865
+ function pngComplete(bytes) {
866
+ if (bytes.length < 8 || bytes[0] !== 0x89 || !matchesAt(bytes, 1, 'PNG'))
867
+ failIncomplete('PNG');
868
+ let offset = 8;
869
+ let first = true;
870
+ while (offset < bytes.length) {
871
+ const chunk = pngChunkAt(bytes, offset);
872
+ if (chunk === undefined || chunk.dataEnd > bytes.length || chunk.next > bytes.length) {
873
+ failIncomplete('PNG chunk');
874
+ }
875
+ if (first && (chunk.type !== 'IHDR' || chunk.dataEnd - chunk.dataStart !== 13)) {
876
+ failIncomplete('PNG header');
877
+ }
878
+ first = false;
879
+ if (crc32Of(bytes, offset + 4, chunk.dataEnd) !== u32be(bytes, chunk.dataEnd)) {
880
+ failIncomplete('PNG chunk CRC');
881
+ }
882
+ if (chunk.type === 'IEND') {
883
+ if (chunk.dataEnd !== chunk.dataStart)
884
+ failIncomplete('PNG IEND');
885
+ if (chunk.next !== bytes.length)
886
+ failIncomplete('PNG trailing');
887
+ return;
888
+ }
889
+ offset = chunk.next;
890
+ }
891
+ failIncomplete('PNG without IEND');
892
+ }
893
+ /**
894
+ * GIF: walk the block stream to the trailer and count image descriptors.
895
+ *
896
+ * A truncated GIF often keeps a decodable prefix; libvips then reports fewer
897
+ * pages and `raw()` agrees, so neither is evidence of completeness. The walk
898
+ * refuses a block that does not fit, a missing trailer, a trailer followed by a
899
+ * non-zero byte, and — when the caller supplies the decoder's page count — a
900
+ * descriptor count that disagrees with it.
901
+ */
902
+ function gifComplete(bytes, expectedPages) {
903
+ if (bytes.length < 13)
904
+ failIncomplete('GIF header');
905
+ if (!matchesAt(bytes, 0, 'GIF87a') && !matchesAt(bytes, 0, 'GIF89a'))
906
+ failIncomplete('GIF signature');
907
+ const packed = bytes[10];
908
+ let offset = 13;
909
+ if ((packed & 0x80) !== 0)
910
+ offset += 3 * (1 << ((packed & 0x07) + 1));
911
+ if (offset > bytes.length)
912
+ failIncomplete('GIF colour table');
913
+ let descriptors = 0;
914
+ while (offset < bytes.length) {
915
+ const block = bytes[offset++];
916
+ if (block === 0x3b) {
917
+ for (let index = offset; index < bytes.length; index += 1) {
918
+ if (bytes[index] !== 0)
919
+ failIncomplete('GIF after trailer');
920
+ }
921
+ if (descriptors === 0)
922
+ failIncomplete('GIF without image');
923
+ if (expectedPages !== undefined && descriptors !== expectedPages) {
924
+ failIncomplete('GIF frame stream');
925
+ }
926
+ return;
927
+ }
928
+ if (block === 0x00)
929
+ continue;
930
+ if (block === 0x21) {
931
+ if (offset >= bytes.length)
932
+ failIncomplete('GIF extension');
933
+ offset += 1; // extension label
934
+ while (true) {
935
+ if (offset >= bytes.length)
936
+ failIncomplete('GIF extension data');
937
+ const length = bytes[offset++];
938
+ if (length === 0)
939
+ break;
940
+ if (offset + length > bytes.length)
941
+ failIncomplete('GIF extension data');
942
+ offset += length;
943
+ }
944
+ continue;
945
+ }
946
+ if (block !== 0x2c)
947
+ failIncomplete('GIF block');
948
+ if (offset + 9 > bytes.length)
949
+ failIncomplete('GIF image descriptor');
950
+ const localPacked = bytes[offset + 8];
951
+ offset += 9;
952
+ if ((localPacked & 0x80) !== 0)
953
+ offset += 3 * (1 << ((localPacked & 0x07) + 1));
954
+ if (offset > bytes.length)
955
+ failIncomplete('GIF local colour table');
956
+ if (offset >= bytes.length)
957
+ failIncomplete('GIF image data');
958
+ offset += 1; // LZW minimum code size
959
+ while (true) {
960
+ if (offset >= bytes.length)
961
+ failIncomplete('GIF image data');
962
+ const length = bytes[offset++];
963
+ if (length === 0)
964
+ break;
965
+ if (offset + length > bytes.length)
966
+ failIncomplete('GIF image data');
967
+ offset += length;
968
+ }
969
+ descriptors += 1;
970
+ }
971
+ failIncomplete('GIF without trailer');
972
+ }
973
+ /**
974
+ * JPEG: walk every marker segment and require `EOI` exactly at the end.
975
+ *
976
+ * The scan's entropy-coded bytes are walked with stuffing and restart markers
977
+ * understood, so a `0xFFD9` inside compressed data is not a false end marker.
978
+ * A file missing its final two bytes fails here even when libvips decodes it.
979
+ */
980
+ function jpegComplete(bytes) {
981
+ if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
982
+ failIncomplete('JPEG header');
983
+ let offset = 2;
984
+ while (offset < bytes.length) {
985
+ if (bytes[offset] !== 0xff)
986
+ failIncomplete('JPEG marker');
987
+ while (offset < bytes.length && bytes[offset] === 0xff)
988
+ offset += 1;
989
+ if (offset >= bytes.length)
990
+ failIncomplete('JPEG marker');
991
+ const marker = bytes[offset++];
992
+ if (marker === 0xd9) {
993
+ if (offset !== bytes.length)
994
+ failIncomplete('JPEG trailing');
995
+ return;
996
+ }
997
+ if (marker === 0x00 || marker === 0xd8)
998
+ failIncomplete('JPEG marker');
999
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7))
1000
+ continue;
1001
+ if (offset + 2 > bytes.length)
1002
+ failIncomplete('JPEG segment');
1003
+ const length = u16be(bytes, offset);
1004
+ if (length < 2 || offset + length > bytes.length)
1005
+ failIncomplete('JPEG segment');
1006
+ if (marker === 0xda) {
1007
+ offset += length;
1008
+ let nextSegment = false;
1009
+ while (offset < bytes.length) {
1010
+ if (bytes[offset] !== 0xff) {
1011
+ offset += 1;
1012
+ continue;
1013
+ }
1014
+ let after = offset + 1;
1015
+ while (after < bytes.length && bytes[after] === 0xff)
1016
+ after += 1;
1017
+ if (after >= bytes.length)
1018
+ failIncomplete('JPEG scan');
1019
+ const next = bytes[after];
1020
+ if (next === 0x00 || (next >= 0xd0 && next <= 0xd7)) {
1021
+ offset = after + 1;
1022
+ continue;
1023
+ }
1024
+ if (next === 0xd9) {
1025
+ if (after + 1 !== bytes.length)
1026
+ failIncomplete('JPEG trailing');
1027
+ return;
1028
+ }
1029
+ // Resume at the marker's 0xFF prefix, not at the code byte `after`.
1030
+ // The outer loop's invariant is `bytes[offset] === 0xff`; progressive
1031
+ // JPEGs have several scans, so another DHT/SOS segment follows here.
1032
+ offset = after - 1;
1033
+ nextSegment = true;
1034
+ break;
1035
+ }
1036
+ if (!nextSegment)
1037
+ failIncomplete('JPEG without EOI');
1038
+ continue;
1039
+ }
1040
+ offset += length;
1041
+ }
1042
+ failIncomplete('JPEG without EOI');
1043
+ }
1044
+ /**
1045
+ * WebP: RIFF's size is the whole file less 8 bytes, every chunk fits with its
1046
+ * pad byte, and at least one bitstream chunk is present.
1047
+ */
1048
+ function webpComplete(bytes) {
1049
+ if (bytes.length < 12 || !matchesAt(bytes, 0, 'RIFF') || !matchesAt(bytes, 8, 'WEBP')) {
1050
+ failIncomplete('WebP header');
1051
+ }
1052
+ if (u32le(bytes, 4) + 8 !== bytes.length)
1053
+ failIncomplete('WebP RIFF size');
1054
+ let offset = 12;
1055
+ let image = false;
1056
+ while (offset < bytes.length) {
1057
+ if (offset + 8 > bytes.length)
1058
+ failIncomplete('WebP chunk');
1059
+ const type = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
1060
+ const size = u32le(bytes, offset + 4);
1061
+ const dataEnd = offset + 8 + size;
1062
+ const next = dataEnd + (size & 1);
1063
+ if (dataEnd > bytes.length || next > bytes.length)
1064
+ failIncomplete('WebP chunk');
1065
+ if (type === 'VP8 ' || type === 'VP8L' || type === 'VP8X')
1066
+ image = true;
1067
+ offset = next;
1068
+ }
1069
+ if (offset !== bytes.length || !image)
1070
+ failIncomplete('WebP image chunk');
1071
+ }
1072
+ /** The primary item ID `pitm` names, or `undefined` when the box is unusable. */
1073
+ function avifPrimaryItem(bytes, meta) {
1074
+ const children = isoBoxes(bytes, meta.payload + 4, meta.end);
1075
+ if (children === undefined)
1076
+ return undefined;
1077
+ const pitms = children.filter((box) => box.type === 'pitm');
1078
+ if (pitms.length !== 1)
1079
+ return undefined;
1080
+ const pitm = pitms[0];
1081
+ if (pitm.payload + 4 > pitm.end)
1082
+ return undefined;
1083
+ if (bytes[pitm.payload + 1] !== 0 || bytes[pitm.payload + 2] !== 0 || bytes[pitm.payload + 3] !== 0) {
1084
+ return undefined;
1085
+ }
1086
+ const version = bytes[pitm.payload];
1087
+ if (version === 0) {
1088
+ if (pitm.payload + 6 !== pitm.end)
1089
+ return undefined;
1090
+ return u16be(bytes, pitm.payload + 4);
1091
+ }
1092
+ if (version === 1) {
1093
+ if (pitm.payload + 8 !== pitm.end)
1094
+ return undefined;
1095
+ return u32be(bytes, pitm.payload + 4);
1096
+ }
1097
+ return undefined;
1098
+ }
1099
+ /**
1100
+ * The primary item's `iloc` extents, with absolute file offsets.
1101
+ *
1102
+ * `iloc` versions 0-2 share a layout except for item-ID width and the optional
1103
+ * construction method. Sizes are read as unsigned big-endian fields of the
1104
+ * widths the box declares; an absent field (size 0) contributes zero.
1105
+ * Construction method 0 addresses the file directly; method 1 addresses the
1106
+ * payload of `meta/idat`. An item naming a non-zero data reference points at
1107
+ * data outside this file and is not addressable by this gate.
1108
+ */
1109
+ function avifPrimaryExtents(bytes, meta, primary) {
1110
+ const children = isoBoxes(bytes, meta.payload + 4, meta.end);
1111
+ if (children === undefined)
1112
+ return undefined;
1113
+ const ilocs = children.filter((box) => box.type === 'iloc');
1114
+ if (ilocs.length !== 1)
1115
+ return undefined;
1116
+ const iloc = ilocs[0];
1117
+ if (iloc.payload + 6 > iloc.end)
1118
+ return undefined;
1119
+ const version = bytes[iloc.payload];
1120
+ if (version > 2)
1121
+ return undefined;
1122
+ const offsetSize = bytes[iloc.payload + 4] >>> 4;
1123
+ const lengthSize = bytes[iloc.payload + 4] & 0x0f;
1124
+ const baseSize = bytes[iloc.payload + 5] >>> 4;
1125
+ const indexSize = version === 0 ? 0 : bytes[iloc.payload + 5] & 0x0f;
1126
+ if (offsetSize > 8 || lengthSize > 8 || baseSize > 8 || indexSize > 8)
1127
+ return undefined;
1128
+ let cursor = iloc.payload + 6;
1129
+ const take = (width) => {
1130
+ if (width === 0)
1131
+ return 0;
1132
+ if (cursor + width > iloc.end)
1133
+ return undefined;
1134
+ let value = 0;
1135
+ for (let index = 0; index < width; index += 1)
1136
+ value = value * 256 + bytes[cursor++];
1137
+ return Number.isSafeInteger(value) ? value : undefined;
1138
+ };
1139
+ const countWidth = version === 2 ? 4 : 2;
1140
+ if (cursor + countWidth > iloc.end)
1141
+ return undefined;
1142
+ const itemCount = countWidth === 2 ? u16be(bytes, cursor) : u32be(bytes, cursor);
1143
+ cursor += countWidth;
1144
+ const itemIdWidth = version === 2 ? 4 : 2;
1145
+ const found = [];
1146
+ for (let item = 0; item < itemCount; item += 1) {
1147
+ if (cursor + itemIdWidth > iloc.end)
1148
+ return undefined;
1149
+ const itemId = itemIdWidth === 2 ? u16be(bytes, cursor) : u32be(bytes, cursor);
1150
+ cursor += itemIdWidth;
1151
+ let method = 0;
1152
+ if (version !== 0) {
1153
+ if (cursor + 2 > iloc.end)
1154
+ return undefined;
1155
+ method = u16be(bytes, cursor) & 0x0f;
1156
+ cursor += 2;
1157
+ }
1158
+ if (cursor + 2 > iloc.end)
1159
+ return undefined;
1160
+ const dataReferenceIndex = u16be(bytes, cursor);
1161
+ cursor += 2;
1162
+ if (itemId === primary && dataReferenceIndex !== 0)
1163
+ return undefined;
1164
+ const base = take(baseSize);
1165
+ if (base === undefined || cursor + 2 > iloc.end)
1166
+ return undefined;
1167
+ const extentCount = u16be(bytes, cursor);
1168
+ cursor += 2;
1169
+ // Every item's extents are walked even when it is not the primary one:
1170
+ // stopping early would leave the cursor inside this item's record and make
1171
+ // a valid multi-item `iloc` look truncated.
1172
+ for (let extent = 0; extent < extentCount; extent += 1) {
1173
+ if (version !== 0 && indexSize > 0) {
1174
+ if (cursor + indexSize > iloc.end)
1175
+ return undefined;
1176
+ cursor += indexSize;
1177
+ }
1178
+ const relative = take(offsetSize);
1179
+ const length = take(lengthSize);
1180
+ if (relative === undefined || length === undefined)
1181
+ return undefined;
1182
+ if (!Number.isSafeInteger(base + relative))
1183
+ return undefined;
1184
+ if (itemId === primary)
1185
+ found.push({ method, offset: base + relative, length });
1186
+ }
1187
+ }
1188
+ if (cursor !== iloc.end)
1189
+ return undefined;
1190
+ return found;
1191
+ }
1192
+ /**
1193
+ * AVIF: top-level boxes consume the file exactly, and each primary-item extent
1194
+ * lies inside a container that its construction method names: a top-level
1195
+ * `mdat` for method 0, or `meta/idat`'s payload for method 1. A file with
1196
+ * neither container is incomplete; an extent may not span two containers.
1197
+ */
1198
+ function avifComplete(bytes) {
1199
+ const top = isoBoxes(bytes, 0, bytes.length);
1200
+ if (top === undefined)
1201
+ failIncomplete('AVIF box stream');
1202
+ const metas = top.filter((box) => box.type === 'meta');
1203
+ if (metas.length !== 1)
1204
+ failIncomplete('AVIF meta box');
1205
+ const meta = metas[0];
1206
+ if (meta.payload + 4 > meta.end || bytes[meta.payload] !== 0)
1207
+ failIncomplete('AVIF meta header');
1208
+ if (bytes[meta.payload + 1] !== 0 || bytes[meta.payload + 2] !== 0 || bytes[meta.payload + 3] !== 0) {
1209
+ failIncomplete('AVIF meta header');
1210
+ }
1211
+ const primary = avifPrimaryItem(bytes, meta);
1212
+ if (primary === undefined || primary === 0)
1213
+ failIncomplete('AVIF primary item');
1214
+ const mdats = top.filter((box) => box.type === 'mdat');
1215
+ const metaChildren = isoBoxes(bytes, meta.payload + 4, meta.end);
1216
+ if (metaChildren === undefined)
1217
+ failIncomplete('AVIF meta children');
1218
+ const idats = metaChildren.filter((box) => box.type === 'idat');
1219
+ const extents = avifPrimaryExtents(bytes, meta, primary);
1220
+ if (extents === undefined || extents.length === 0)
1221
+ failIncomplete('AVIF item location');
1222
+ for (const extent of extents) {
1223
+ if (extent.method !== 0 && extent.method !== 1)
1224
+ failIncomplete('AVIF item location method');
1225
+ // Method 0 offsets are file-relative; method 1 offsets are relative to the
1226
+ // idat payload the spec names. Both must end inside that same container;
1227
+ // an extent never spans two.
1228
+ const inside = extent.method === 0
1229
+ ? mdats.some((mdat) => extent.offset >= mdat.payload &&
1230
+ extent.offset + extent.length <= mdat.end &&
1231
+ Number.isSafeInteger(extent.offset + extent.length))
1232
+ : idats.some((idat) => {
1233
+ const start = idat.payload + extent.offset;
1234
+ return (start >= idat.payload &&
1235
+ start + extent.length <= idat.end &&
1236
+ Number.isSafeInteger(start + extent.length));
1237
+ });
1238
+ if (!inside)
1239
+ failIncomplete(extent.method === 0 ? 'AVIF mdat' : 'AVIF idat');
1240
+ }
1241
+ }
1242
+ /**
1243
+ * Refuse a structurally incomplete image container before a pixel decoder or
1244
+ * encoder sees it.
1245
+ *
1246
+ * This is the gate that separates "the header parsed" from "the file is
1247
+ * complete". Each format's own self-description is walked: PNG chunks and their
1248
+ * CRCs, the GIF block stream and its descriptor count, JPEG markers through EOI,
1249
+ * WebP's RIFF size and chunks, and AVIF's box tree with the primary item's
1250
+ * `mdat` or `meta/idat` extent. A failure is the existing `corrupt` reason; no
1251
+ * caller may publish the bytes anyway.
1252
+ *
1253
+ * An unreadable *dimension* header is still a different case: a complete
1254
+ * container whose header this module cannot interpret publishes without
1255
+ * `width`/`height` and does not fail the build. The distinction is between a
1256
+ * missing hint and missing bytes.
1257
+ */
1258
+ export function assertCompleteContainer(format, bytes, expectation = {}) {
1259
+ switch (format) {
1260
+ case 'png':
1261
+ pngComplete(bytes);
1262
+ return;
1263
+ case 'jpeg':
1264
+ jpegComplete(bytes);
1265
+ return;
1266
+ case 'gif':
1267
+ gifComplete(bytes, expectation.pages);
1268
+ return;
1269
+ case 'webp':
1270
+ webpComplete(bytes);
1271
+ return;
1272
+ case 'avif':
1273
+ avifComplete(bytes);
1274
+ return;
1275
+ }
1276
+ }
1277
+ /** POSIX dirname on a corpus-relative path; `''` at the root. */
1278
+ function dirname(path) {
1279
+ const cut = path.lastIndexOf('/');
1280
+ return cut === -1 ? '' : path.slice(0, cut);
1281
+ }
1282
+ /**
1283
+ * Join a relative reference onto a directory, or `undefined` if it escapes.
1284
+ *
1285
+ * Written out rather than delegated to `path.posix.join`, for the reason
1286
+ * `link-resolution.ts` gives: every path here is already a `/`-joined relative
1287
+ * path, and `join`'s platform behaviour is a second variable this does not want.
1288
+ * A `..` that climbs above the root is not silently dropped — that is the whole
1289
+ * difference between a path that is missing and one that points outside the tree
1290
+ * the author authorized, and the two have different fixes.
1291
+ */
1292
+ function joinRelative(directory, reference) {
1293
+ const parts = directory === '' ? [] : directory.split('/');
1294
+ for (const segment of reference.split('/')) {
1295
+ if (segment === '' || segment === '.')
1296
+ continue;
1297
+ if (segment === '..') {
1298
+ if (parts.length === 0)
1299
+ return undefined;
1300
+ parts.pop();
1301
+ continue;
1302
+ }
1303
+ parts.push(segment);
1304
+ }
1305
+ return parts.length === 0 ? undefined : parts.join('/');
1306
+ }
1307
+ /**
1308
+ * Percent-decode the path half of one Markdown image destination, exactly once.
1309
+ *
1310
+ * `decodeURIComponent` rather than `decodeURI`, because this decodes a *filename*
1311
+ * after the fragment has already been split off: `%23`, `%26`, `%3F` and `%25`
1312
+ * are the only spellings a file whose name carries `#`, `&`, `?` or `%` can be
1313
+ * referenced by, and `decodeURI` deliberately leaves all four encoded — which
1314
+ * asked the filesystem for the escape text and made such an image unresolvable.
1315
+ * `link-resolution.ts` still uses `decodeURI` because a link decodes before it
1316
+ * splits at `#`; here the split has already happened, so a decoded `#` is a
1317
+ * filename byte rather than a fragment.
1318
+ *
1319
+ * **One pass, never two.** `%2523` decodes to the literal three characters
1320
+ * `%23`, which is how a file *named* `chart%23one.png` is addressed. So
1321
+ * `chart%23one.png` wins for the file named `chart#one.png`, and
1322
+ * `chart%2523one.png` wins for the file named `chart%23one.png`; both may exist
1323
+ * in one corpus, and each spelling resolves to exactly one of them.
1324
+ *
1325
+ * A decoded `/` is refused rather than treated as a separator: `%2F` is a byte
1326
+ * inside one segment, `/` is the separator {@link joinRelative} owns, and a
1327
+ * reference must not be able to name a path its author did not write. A
1328
+ * malformed escape is left as written, matching the link resolver: a literal `%`
1329
+ * in a filename is legal, and a typo then resolves to nothing instead of
1330
+ * crashing the build.
1331
+ *
1332
+ * @returns the decoded path, or `undefined` when a segment decodes to contain a
1333
+ * separator and therefore names no single file.
1334
+ */
1335
+ function decodeReferencePath(path) {
1336
+ const segments = [];
1337
+ for (const segment of path.split('/')) {
1338
+ let decoded;
1339
+ try {
1340
+ decoded = decodeURIComponent(segment);
1341
+ }
1342
+ catch {
1343
+ decoded = segment;
1344
+ }
1345
+ if (decoded.includes('/'))
1346
+ return undefined;
1347
+ segments.push(decoded);
1348
+ }
1349
+ return segments.join('/');
1350
+ }
1351
+ /**
1352
+ * Resolve one image reference against the writing note and the content root.
1353
+ *
1354
+ * Two tiers, in this order and no third:
1355
+ *
1356
+ * 1. **Note-relative.** `../img/a.png` from `notes/today.md` is
1357
+ * `img/a.png`. An explicitly relative reference (`./` or `../`) stops here
1358
+ * whether or not it resolved: falling through would let a reference the author
1359
+ * anchored to their own directory silently name a different file at the
1360
+ * content root.
1361
+ * 2. **Content-root-relative.** A bare or multi-segment reference (`a.png`,
1362
+ * `img/a.png`) that missed the note's directory is looked up at the root, which
1363
+ * is what makes `![chart](charts/q3.png)` work from a note in a subdirectory.
1364
+ *
1365
+ * A reference beginning `/` is an explicit content-root anchor and takes tier 2
1366
+ * only, mirroring the note resolver's treatment of a leading slash as a strict
1367
+ * root test rather than a fuzzy one.
1368
+ *
1369
+ * `locate` answers "which file on disk does this name?" and returns its actual
1370
+ * spelling, or `undefined` when nothing inside the authorized tree has that name.
1371
+ * The traversal passes the set of paths the walk found, so "inside the content
1372
+ * root" is not a filesystem question asked here but a fact the caller already
1373
+ * established — which is what keeps a symlink outside the tree, a directory
1374
+ * symlink, and a structurally ignored name from ever being *found*. The candidate
1375
+ * it receives is NFC-normalised, and it is expected to compare normalised too:
1376
+ * a macOS filesystem stores names decomposed while an author types them composed,
1377
+ * so a byte-exact comparison answers differently on two machines for the same two
1378
+ * strings. The answer is the on-disk spelling, because that is what a later read
1379
+ * needs.
1380
+ */
1381
+ export function resolveImageTarget(reference, notePath, locate, isWikilink) {
1382
+ const written = reference.trim();
1383
+ if (written === '')
1384
+ return { kind: 'missing' };
1385
+ // A fragment on an image URL selects nothing this build serves; the path before
1386
+ // it is the reference. The cut happens on the **authored** spelling, before any
1387
+ // decoding, because the two characters must stay distinguishable: a literal `#`
1388
+ // ends the path, while `%23` is a filename byte that only becomes `#` below.
1389
+ // A bare fragment points inside the page, which is likewise not an image file.
1390
+ const cut = written.indexOf('#');
1391
+ const authored = cut === -1 ? written : written.slice(0, cut);
1392
+ if (authored === '')
1393
+ return { kind: 'external' };
1394
+ // A scheme or a protocol-relative host is not a file this build owns.
1395
+ if (HAS_SCHEME.test(authored) || authored.startsWith('//'))
1396
+ return { kind: 'external' };
1397
+ // A wikilink target is an Obsidian path, not a URL, so it is used exactly as
1398
+ // written — the same asymmetry the resolver always had. A Markdown destination
1399
+ // is percent-decoded once; see {@link decodeReferencePath}.
1400
+ const path = isWikilink ? authored : decodeReferencePath(authored);
1401
+ if (path === undefined)
1402
+ return { kind: 'missing' };
1403
+ const located = (candidate) => {
1404
+ const found = locate(candidate.normalize('NFC'));
1405
+ return found === undefined ? { kind: 'missing' } : { kind: 'local', path: found };
1406
+ };
1407
+ if (path.startsWith('/')) {
1408
+ const root = joinRelative('', path.replace(/^\/+/, ''));
1409
+ return root === undefined ? { kind: 'outside' } : located(root);
1410
+ }
1411
+ const explicit = path.startsWith('./') || path.startsWith('../');
1412
+ const joined = joinRelative(dirname(notePath), path);
1413
+ if (joined !== undefined) {
1414
+ const near = located(joined);
1415
+ if (near.kind === 'local')
1416
+ return near;
1417
+ }
1418
+ if (explicit) {
1419
+ // Climbing above the root is a different fault from naming nothing: the
1420
+ // first points at a file the author did not authorize, the second at a file
1421
+ // that is not there.
1422
+ return joined === undefined ? { kind: 'outside' } : { kind: 'missing' };
1423
+ }
1424
+ const root = joinRelative('', path);
1425
+ return root === undefined ? { kind: 'outside' } : located(root);
1426
+ }