@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.
- package/README.md +63 -34
- package/bin/anc.mjs +120 -11
- package/package.json +8 -1
- package/scripts/copy-assets.js +141 -0
- package/scripts/init-repository.js +18 -0
- package/scripts/load-config.js +258 -5
- package/scripts/markdown-to-artifact.js +310 -6
- package/scripts/publish-media.js +468 -0
- package/scripts/resolve-links.js +318 -42
- package/scripts/scan-residue.js +17 -6
- package/scripts/verify-output-inventory.js +53 -0
- package/src/lib/content.js +22 -0
- package/src/lib/font-coverage.js +845 -0
- package/src/lib/markdown.js +257 -11
- package/src/lib/media-codec.js +355 -0
- package/src/lib/media-delivery.js +76 -0
- package/src/lib/media.js +1426 -0
- package/src/lib/mermaid-render.js +36 -0
- package/src/lib/schema.js +83 -8
- package/src/lib/svg-css.js +685 -0
- package/src/lib/svg-policy.js +1699 -0
- package/src/lib/svg-refusal.js +1 -0
- package/src/lib/svg-render.js +343 -0
- package/src/pages/notes/[slug].astro +10 -1
- package/src/pages/privacy.astro +9 -5
- package/src/styles/global.css +29 -0
package/src/lib/media.js
ADDED
|
@@ -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 `` 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 `` 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
|
+
}
|