@ultimat3/core 1.0.0
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/LICENSE +21 -0
- package/README.md +171 -0
- package/package.json +33 -0
- package/src/actor.ts +81 -0
- package/src/assert.ts +44 -0
- package/src/clock.ts +51 -0
- package/src/config.ts +265 -0
- package/src/context.ts +210 -0
- package/src/cursor.ts +116 -0
- package/src/env.ts +259 -0
- package/src/error-codes.ts +131 -0
- package/src/errors.ts +159 -0
- package/src/ids.ts +132 -0
- package/src/image/color.ts +34 -0
- package/src/image/errors.ts +58 -0
- package/src/image/fixtures.ts +263 -0
- package/src/image/jpeg-decode.ts +283 -0
- package/src/image/jpeg-encode.ts +463 -0
- package/src/image/jpeg-headers.ts +267 -0
- package/src/image/jpeg-huffman.ts +202 -0
- package/src/image/jpeg-tables.ts +117 -0
- package/src/image/pipeline.ts +117 -0
- package/src/image/png-bytes.ts +91 -0
- package/src/image/png.ts +433 -0
- package/src/image/probe-svg.ts +85 -0
- package/src/image/probe.ts +302 -0
- package/src/image/raster.ts +71 -0
- package/src/image/resize.ts +320 -0
- package/src/index.ts +256 -0
- package/src/lifecycle.ts +242 -0
- package/src/listeners.ts +80 -0
- package/src/logger.ts +146 -0
- package/src/registrar.ts +94 -0
- package/src/result.ts +78 -0
- package/src/roles.ts +66 -0
- package/src/service.ts +61 -0
- package/src/telemetry.ts +290 -0
- package/src/version.ts +37 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Single responsibility: reading an SVG's intrinsic box out of its TEXT. Every other format
|
|
2
|
+
// declares its size in fixed header bytes; SVG declares it in markup, which is a different job
|
|
3
|
+
// with a different failure mode (untrusted text, catastrophic backtracking, percentages that are
|
|
4
|
+
// not pixels) — and mixing the two into one file is what made `probe.ts` two files' worth.
|
|
5
|
+
|
|
6
|
+
import { imageDecodeFailed } from './errors';
|
|
7
|
+
import type { ImageSize } from './raster';
|
|
8
|
+
|
|
9
|
+
/** Only the prologue and the root tag carry the box; an SVG body can be megabytes. */
|
|
10
|
+
const SVG_HEAD_BYTES = 65_536;
|
|
11
|
+
/** `TextDecoder` already strips a leading BOM, so only real whitespace is left to skip. */
|
|
12
|
+
const SVG_WHITESPACE = ' \t\n\r\f\v';
|
|
13
|
+
const SVG_PIXELS = /^\s*(\d*\.?\d+)(?:px)?\s*$/i;
|
|
14
|
+
|
|
15
|
+
const svgHead = (bytes: Uint8Array): string =>
|
|
16
|
+
new TextDecoder().decode(bytes.subarray(0, SVG_HEAD_BYTES));
|
|
17
|
+
|
|
18
|
+
/** The only things allowed before the root element: a comment, an XML declaration, a DOCTYPE. */
|
|
19
|
+
const SVG_PROLOGUE: readonly (readonly [string, string])[] = [
|
|
20
|
+
['<!--', '-->'],
|
|
21
|
+
['<?', '?>'],
|
|
22
|
+
['<!', '>'],
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Index of the root `<svg`, or -1. Hand-walked rather than matched with a regex: the input is
|
|
27
|
+
* untrusted, and an alternation of lazy groups backtracks catastrophically on a near-miss.
|
|
28
|
+
*/
|
|
29
|
+
function svgRootIndex(text: string): number {
|
|
30
|
+
let at = 0;
|
|
31
|
+
while (at < text.length) {
|
|
32
|
+
if (SVG_WHITESPACE.includes(text[at] ?? '')) {
|
|
33
|
+
at += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (text.startsWith('<svg', at)) return at;
|
|
37
|
+
const prologue = SVG_PROLOGUE.find(([open]) => text.startsWith(open, at));
|
|
38
|
+
if (prologue === undefined) return -1;
|
|
39
|
+
const close = text.indexOf(prologue[1], at);
|
|
40
|
+
at = close === -1 ? text.length : close + prologue[1].length;
|
|
41
|
+
}
|
|
42
|
+
return -1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The sniff's other half: a `<` opened the file, but only a root `<svg` makes it an image. */
|
|
46
|
+
export const hasSvgRoot = (bytes: Uint8Array): boolean => svgRootIndex(svgHead(bytes)) >= 0;
|
|
47
|
+
|
|
48
|
+
const svgAttribute = (tag: string, name: string): string | undefined =>
|
|
49
|
+
new RegExp(`\\s${name}\\s*=\\s*['"]([^'"]*)['"]`, 'i').exec(tag)?.[1];
|
|
50
|
+
|
|
51
|
+
/** A percentage is a share of a viewport, not a pixel size — it cannot reserve a box. */
|
|
52
|
+
function svgPixels(value: string | undefined): number | null {
|
|
53
|
+
if (value === undefined) return null;
|
|
54
|
+
const matched = SVG_PIXELS.exec(value);
|
|
55
|
+
const pixels = Number(matched?.[1] ?? Number.NaN);
|
|
56
|
+
return Number.isFinite(pixels) && pixels > 0 ? Math.round(pixels) : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function svgViewBox(tag: string): ImageSize | null {
|
|
60
|
+
const raw = svgAttribute(tag, 'viewBox');
|
|
61
|
+
if (raw === undefined) return null;
|
|
62
|
+
const parts = raw.trim().split(/[\s,]+/);
|
|
63
|
+
const width = Math.round(Number(parts[2] ?? ''));
|
|
64
|
+
const height = Math.round(Number(parts[3] ?? ''));
|
|
65
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 1 || height < 1) return null;
|
|
66
|
+
return { width, height };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Pixel `width`/`height` first, then the `viewBox` — the order a browser resolves them in. */
|
|
70
|
+
export function probeSvg(bytes: Uint8Array): ImageSize {
|
|
71
|
+
const text = svgHead(bytes);
|
|
72
|
+
const start = svgRootIndex(text);
|
|
73
|
+
const closing = text.indexOf('>', start);
|
|
74
|
+
const tag = text.slice(start, closing === -1 ? text.length : closing + 1);
|
|
75
|
+
const width = svgPixels(svgAttribute(tag, 'width'));
|
|
76
|
+
const height = svgPixels(svgAttribute(tag, 'height'));
|
|
77
|
+
if (width !== null && height !== null) return { width, height };
|
|
78
|
+
const viewBox = svgViewBox(tag);
|
|
79
|
+
if (viewBox !== null) return viewBox;
|
|
80
|
+
throw imageDecodeFailed(
|
|
81
|
+
'SVG declares no intrinsic size: its root tag carries no pixel `width` and `height`, and ' +
|
|
82
|
+
'no usable `viewBox`',
|
|
83
|
+
{ format: 'svg' },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Single responsibility: identify an image and read its intrinsic pixel size from the header
|
|
2
|
+
// bytes alone — never a decode, never a guess from a file extension. WebP, AVIF, GIF and SVG
|
|
3
|
+
// have no built-in codec here, yet every `<img>` still needs `width`/`height` inlined, and
|
|
4
|
+
// that is the only thing that keeps CLS at 0 for them. SVG declares its box in markup rather
|
|
5
|
+
// than in header bytes, so that reading lives in `probe-svg.ts`.
|
|
6
|
+
|
|
7
|
+
import { imageDecodeFailed, imageUnsupported } from './errors';
|
|
8
|
+
import { hasSvgRoot, probeSvg } from './probe-svg';
|
|
9
|
+
import { assertPixelBudget, type ImageSize } from './raster';
|
|
10
|
+
|
|
11
|
+
export const IMAGE_FORMATS = ['png', 'jpeg', 'webp', 'avif', 'gif', 'svg'] as const;
|
|
12
|
+
export type ImageFormat = (typeof IMAGE_FORMATS)[number];
|
|
13
|
+
|
|
14
|
+
export const IMAGE_MIME_TYPES: Readonly<Record<ImageFormat, string>> = {
|
|
15
|
+
png: 'image/png',
|
|
16
|
+
jpeg: 'image/jpeg',
|
|
17
|
+
webp: 'image/webp',
|
|
18
|
+
avif: 'image/avif',
|
|
19
|
+
gif: 'image/gif',
|
|
20
|
+
svg: 'image/svg+xml',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface ImageInfo {
|
|
24
|
+
readonly format: ImageFormat;
|
|
25
|
+
readonly width: number;
|
|
26
|
+
readonly height: number;
|
|
27
|
+
readonly mimeType: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `noUncheckedIndexedAccess` makes every index a `number | undefined`; -1 matches no byte. */
|
|
31
|
+
const byteAt = (bytes: Uint8Array, at: number): number => bytes[at] ?? -1;
|
|
32
|
+
|
|
33
|
+
const viewOf = (bytes: Uint8Array): DataView =>
|
|
34
|
+
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
35
|
+
|
|
36
|
+
const ascii = (bytes: Uint8Array, at: number, text: string): boolean => {
|
|
37
|
+
if (at < 0 || at + text.length > bytes.length) return false;
|
|
38
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
39
|
+
if (byteAt(bytes, at + i) !== text.charCodeAt(i)) return false;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const fourcc = (bytes: Uint8Array, at: number): string =>
|
|
45
|
+
at + 4 > bytes.length
|
|
46
|
+
? ''
|
|
47
|
+
: String.fromCharCode(
|
|
48
|
+
byteAt(bytes, at),
|
|
49
|
+
byteAt(bytes, at + 1),
|
|
50
|
+
byteAt(bytes, at + 2),
|
|
51
|
+
byteAt(bytes, at + 3),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** A truncated header is a decode failure with a name, never a silently returned 0x0. */
|
|
55
|
+
function requireBytes(bytes: Uint8Array, needed: number, format: string, missing: string): void {
|
|
56
|
+
if (bytes.length < needed) {
|
|
57
|
+
throw imageDecodeFailed(
|
|
58
|
+
`${format} header is truncated: ${bytes.length} bytes, but ${missing} needs ${needed}`,
|
|
59
|
+
{ format, length: bytes.length, needed },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------- sniffing
|
|
65
|
+
|
|
66
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] as const;
|
|
67
|
+
|
|
68
|
+
/** `mif1` is the generic HEIF brand AVIF files carry; `avis` is an image sequence. */
|
|
69
|
+
const AVIF_BRANDS: ReadonlySet<string> = new Set(['avif', 'avis', 'mif1']);
|
|
70
|
+
|
|
71
|
+
function isAvif(bytes: Uint8Array): boolean {
|
|
72
|
+
if (!ascii(bytes, 4, 'ftyp')) return false;
|
|
73
|
+
if (AVIF_BRANDS.has(fourcc(bytes, 8))) return true;
|
|
74
|
+
const declared = bytes.length >= 4 ? viewOf(bytes).getUint32(0) : 0;
|
|
75
|
+
const end = Math.min(bytes.length, declared >= 16 ? declared : bytes.length);
|
|
76
|
+
for (let at = 16; at + 4 <= end; at += 4) {
|
|
77
|
+
if (AVIF_BRANDS.has(fourcc(bytes, at))) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whitespace and a UTF-8 BOM may precede an SVG document; nothing else may. */
|
|
83
|
+
const SVG_LEADING_BYTES: ReadonlySet<number> = new Set([0x20, 0x09, 0x0a, 0x0d, 0xef, 0xbb, 0xbf]);
|
|
84
|
+
|
|
85
|
+
/** Cheap gate: refusing binary here is what stops a 64KB text decode per non-image sniff. */
|
|
86
|
+
function opensWithTag(bytes: Uint8Array): boolean {
|
|
87
|
+
for (let at = 0; at < bytes.length && at < 64; at += 1) {
|
|
88
|
+
const byte = byteAt(bytes, at);
|
|
89
|
+
if (byte === 0x3c) return true;
|
|
90
|
+
if (!SVG_LEADING_BYTES.has(byte)) return false;
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Magic-byte sniff only. `null` when nothing matches — a file extension is a claim, not
|
|
97
|
+
* evidence, and an agent that trusts one ships a `.png` that is really a WebP.
|
|
98
|
+
*/
|
|
99
|
+
export function sniffImageFormat(bytes: Uint8Array): ImageFormat | null {
|
|
100
|
+
if (PNG_SIGNATURE.every((byte, at) => byteAt(bytes, at) === byte)) return 'png';
|
|
101
|
+
if (byteAt(bytes, 0) === 0xff && byteAt(bytes, 1) === 0xd8 && byteAt(bytes, 2) === 0xff) {
|
|
102
|
+
return 'jpeg';
|
|
103
|
+
}
|
|
104
|
+
if (ascii(bytes, 0, 'GIF87a') || ascii(bytes, 0, 'GIF89a')) return 'gif';
|
|
105
|
+
if (ascii(bytes, 0, 'RIFF') && ascii(bytes, 8, 'WEBP')) return 'webp';
|
|
106
|
+
if (isAvif(bytes)) return 'avif';
|
|
107
|
+
if (opensWithTag(bytes) && hasSvgRoot(bytes)) return 'svg';
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------- per format
|
|
112
|
+
|
|
113
|
+
/** Signature (8) + chunk length (4) + `IHDR` (4) + width (4) + height (4). */
|
|
114
|
+
function probePng(bytes: Uint8Array): ImageSize {
|
|
115
|
+
requireBytes(bytes, 24, 'PNG', 'the signature plus the IHDR width and height');
|
|
116
|
+
if (!ascii(bytes, 12, 'IHDR')) {
|
|
117
|
+
throw imageDecodeFailed(
|
|
118
|
+
`PNG's first chunk is "${fourcc(bytes, 12)}", but IHDR must come first`,
|
|
119
|
+
{
|
|
120
|
+
format: 'png',
|
|
121
|
+
chunk: fourcc(bytes, 12),
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const view = viewOf(bytes);
|
|
126
|
+
return { width: view.getUint32(16), height: view.getUint32(20) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** SOF0..SOF15 minus DHT (C4), JPG (C8) and DAC (CC), which share the range but not the shape. */
|
|
130
|
+
const isSofMarker = (marker: number): boolean =>
|
|
131
|
+
marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
132
|
+
|
|
133
|
+
/** Stand-alone markers carry no length word: TEM and the eight restart markers. */
|
|
134
|
+
const isStandaloneMarker = (marker: number): boolean =>
|
|
135
|
+
marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Walks segment lengths to the first SOF. Baseline, progressive and lossless all declare their
|
|
139
|
+
* size the same way — probing is not decoding, so a format the decoder refuses still measures.
|
|
140
|
+
*/
|
|
141
|
+
function probeJpeg(bytes: Uint8Array): ImageSize {
|
|
142
|
+
const view = viewOf(bytes);
|
|
143
|
+
let at = 2;
|
|
144
|
+
while (at + 3 < bytes.length) {
|
|
145
|
+
if (byteAt(bytes, at) !== 0xff) {
|
|
146
|
+
throw imageDecodeFailed(`JPEG marker walk desynchronised at byte ${at}, expected 0xFF`, {
|
|
147
|
+
format: 'jpeg',
|
|
148
|
+
offset: at,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
let marker = byteAt(bytes, at + 1);
|
|
152
|
+
// Any number of 0xFF fill bytes may pad the gap before a marker code.
|
|
153
|
+
while (marker === 0xff) {
|
|
154
|
+
at += 1;
|
|
155
|
+
marker = byteAt(bytes, at + 1);
|
|
156
|
+
}
|
|
157
|
+
if (isStandaloneMarker(marker) || marker === 0xd8) {
|
|
158
|
+
at += 2;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Past SOS the bytes are entropy-coded, not segments: walking on would only desynchronise.
|
|
162
|
+
if (marker === 0xda || marker === 0xd9) break;
|
|
163
|
+
if (at + 3 >= bytes.length) break;
|
|
164
|
+
const length = view.getUint16(at + 2);
|
|
165
|
+
if (isSofMarker(marker)) {
|
|
166
|
+
requireBytes(bytes, at + 9, 'JPEG', 'the SOF segment width and height');
|
|
167
|
+
return { width: view.getUint16(at + 7), height: view.getUint16(at + 5) };
|
|
168
|
+
}
|
|
169
|
+
if (length < 2) {
|
|
170
|
+
throw imageDecodeFailed(`JPEG segment 0xFF${marker.toString(16)} declares length ${length}`, {
|
|
171
|
+
format: 'jpeg',
|
|
172
|
+
offset: at,
|
|
173
|
+
length,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
at += 2 + length;
|
|
177
|
+
}
|
|
178
|
+
throw imageDecodeFailed(
|
|
179
|
+
`JPEG ends after ${bytes.length} bytes with no SOF marker, so it declares no size`,
|
|
180
|
+
{ format: 'jpeg', offset: at },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Header (6) + logical screen width and height, little-endian. */
|
|
185
|
+
function probeGif(bytes: Uint8Array): ImageSize {
|
|
186
|
+
requireBytes(bytes, 10, 'GIF', 'the logical screen width and height');
|
|
187
|
+
const view = viewOf(bytes);
|
|
188
|
+
return { width: view.getUint16(6, true), height: view.getUint16(8, true) };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The lossy bitstream: a 3-byte frame tag, the start code, then 14-bit dimensions. */
|
|
192
|
+
function probeVp8(bytes: Uint8Array, data: number): ImageSize {
|
|
193
|
+
const end = Math.min(bytes.length - 3, data + 32);
|
|
194
|
+
for (let at = data; at <= end; at += 1) {
|
|
195
|
+
if (byteAt(bytes, at) !== 0x9d) continue;
|
|
196
|
+
if (byteAt(bytes, at + 1) !== 0x01 || byteAt(bytes, at + 2) !== 0x2a) continue;
|
|
197
|
+
// The start code can sit in the last bytes of a truncated file; a DataView read past the
|
|
198
|
+
// end raises a bare RangeError, which is exactly the un-coded failure the contract forbids.
|
|
199
|
+
requireBytes(bytes, at + 7, 'WebP', 'the VP8 frame width and height');
|
|
200
|
+
const view = viewOf(bytes);
|
|
201
|
+
return {
|
|
202
|
+
width: view.getUint16(at + 3, true) & 0x3fff,
|
|
203
|
+
height: view.getUint16(at + 5, true) & 0x3fff,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
throw imageDecodeFailed('WebP VP8 chunk has no 9D 01 2A start code within its first 32 bytes', {
|
|
207
|
+
format: 'webp',
|
|
208
|
+
chunk: 'VP8 ',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Lossless: a 0x2F signature byte, then 14-bit width-1 and height-1 packed little-endian. */
|
|
213
|
+
function probeVp8l(bytes: Uint8Array, data: number): ImageSize {
|
|
214
|
+
requireBytes(bytes, data + 5, 'WebP', 'the VP8L signature and packed dimensions');
|
|
215
|
+
if (byteAt(bytes, data) !== 0x2f) {
|
|
216
|
+
throw imageDecodeFailed('WebP VP8L chunk does not start with its 0x2F signature byte', {
|
|
217
|
+
format: 'webp',
|
|
218
|
+
chunk: 'VP8L',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const bits = viewOf(bytes).getUint32(data + 1, true);
|
|
222
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Extended: flags (4), then 24-bit little-endian canvas width-1 and height-1. */
|
|
226
|
+
function probeVp8x(bytes: Uint8Array, data: number): ImageSize {
|
|
227
|
+
requireBytes(bytes, data + 10, 'WebP', 'the VP8X canvas width and height');
|
|
228
|
+
const read24 = (at: number): number =>
|
|
229
|
+
byteAt(bytes, at) | (byteAt(bytes, at + 1) << 8) | (byteAt(bytes, at + 2) << 16);
|
|
230
|
+
return { width: read24(data + 4) + 1, height: read24(data + 7) + 1 };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function probeWebp(bytes: Uint8Array): ImageSize {
|
|
234
|
+
requireBytes(bytes, 20, 'WebP', 'the RIFF header and the first chunk header');
|
|
235
|
+
const chunk = fourcc(bytes, 12);
|
|
236
|
+
const data = 20;
|
|
237
|
+
if (chunk === 'VP8 ') return probeVp8(bytes, data);
|
|
238
|
+
if (chunk === 'VP8L') return probeVp8l(bytes, data);
|
|
239
|
+
if (chunk === 'VP8X') return probeVp8x(bytes, data);
|
|
240
|
+
throw imageDecodeFailed(`WebP's first chunk is "${chunk}", not VP8 , VP8L or VP8X`, {
|
|
241
|
+
format: 'webp',
|
|
242
|
+
chunk,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* `ispe` lives in the `meta` box, which always precedes the pixel data — scanning further
|
|
248
|
+
* would only ever match a false positive inside `mdat`. The first one is the primary item's.
|
|
249
|
+
*/
|
|
250
|
+
const AVIF_SCAN_BYTES = 65_536;
|
|
251
|
+
|
|
252
|
+
function probeAvif(bytes: Uint8Array): ImageSize {
|
|
253
|
+
const end = Math.min(bytes.length, AVIF_SCAN_BYTES);
|
|
254
|
+
for (let at = 0; at + 16 <= end; at += 1) {
|
|
255
|
+
if (!ascii(bytes, at, 'ispe')) continue;
|
|
256
|
+
const view = viewOf(bytes);
|
|
257
|
+
return { width: view.getUint32(at + 8), height: view.getUint32(at + 12) };
|
|
258
|
+
}
|
|
259
|
+
throw imageDecodeFailed('AVIF carries no `ispe` property box, so it declares no intrinsic size', {
|
|
260
|
+
format: 'avif',
|
|
261
|
+
scanned: end,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------------------------------------------------------------------------- entry point
|
|
266
|
+
|
|
267
|
+
function probeSize(bytes: Uint8Array, format: ImageFormat): ImageSize {
|
|
268
|
+
switch (format) {
|
|
269
|
+
case 'png':
|
|
270
|
+
return probePng(bytes);
|
|
271
|
+
case 'jpeg':
|
|
272
|
+
return probeJpeg(bytes);
|
|
273
|
+
case 'gif':
|
|
274
|
+
return probeGif(bytes);
|
|
275
|
+
case 'webp':
|
|
276
|
+
return probeWebp(bytes);
|
|
277
|
+
case 'avif':
|
|
278
|
+
return probeAvif(bytes);
|
|
279
|
+
case 'svg':
|
|
280
|
+
return probeSvg(bytes);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Format + intrinsic pixel dimensions, read from the header. Never decodes — this is what
|
|
286
|
+
* lets `<img>` carry `width`/`height` for formats no built-in codec can read.
|
|
287
|
+
*/
|
|
288
|
+
export function probeImage(bytes: Uint8Array): ImageInfo {
|
|
289
|
+
const format = sniffImageFormat(bytes);
|
|
290
|
+
if (format === null) {
|
|
291
|
+
throw imageUnsupported(
|
|
292
|
+
`the first bytes match no image format this pipeline knows (${bytes.length} bytes read)`,
|
|
293
|
+
`re-encode the source as one of ${IMAGE_FORMATS.join(', ')} — \`file <path>\` names what ` +
|
|
294
|
+
'it actually is',
|
|
295
|
+
{ length: bytes.length },
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
const { width, height } = probeSize(bytes, format);
|
|
299
|
+
// Before any allocation downstream: a hostile header is refused here, not at malloc.
|
|
300
|
+
assertPixelBudget(width, height, format);
|
|
301
|
+
return { format, width, height, mimeType: IMAGE_MIME_TYPES[format] };
|
|
302
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Single responsibility: the ONE in-memory image every codec agrees on — 8-bit RGBA,
|
|
2
|
+
// row-major, non-premultiplied. A single representation is why a decoder and an encoder
|
|
3
|
+
// never negotiate, and why resize/composite has exactly one code path to be correct in.
|
|
4
|
+
|
|
5
|
+
import { imageDecodeFailed, imageTooLarge } from './errors';
|
|
6
|
+
|
|
7
|
+
export interface Raster {
|
|
8
|
+
readonly width: number;
|
|
9
|
+
readonly height: number;
|
|
10
|
+
/** RGBA, 4 bytes per pixel, row-major. Length is always `width * height * 4`. */
|
|
11
|
+
readonly pixels: Uint8ClampedArray;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ImageSize {
|
|
15
|
+
readonly width: number;
|
|
16
|
+
readonly height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The decompression-bomb ceiling. 64 megapixels is four times a 24MP camera frame and
|
|
21
|
+
* 256MB of RGBA — past it a header is far more likely hostile than a real photograph.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_IMAGE_PIXELS = 64_000_000;
|
|
24
|
+
|
|
25
|
+
/** Checked from the header before a single byte is allocated. */
|
|
26
|
+
export function assertPixelBudget(width: number, height: number, source: string): void {
|
|
27
|
+
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) {
|
|
28
|
+
throw imageTooLarge(`${source} declares a ${width}x${height} image, which is not a size`, {
|
|
29
|
+
width,
|
|
30
|
+
height,
|
|
31
|
+
source,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
if (width * height > MAX_IMAGE_PIXELS) {
|
|
35
|
+
throw imageTooLarge(
|
|
36
|
+
`${source} declares ${width}x${height} = ${width * height} pixels, over the ` +
|
|
37
|
+
`${MAX_IMAGE_PIXELS} ceiling`,
|
|
38
|
+
{ width, height, pixels: width * height, ceiling: MAX_IMAGE_PIXELS, source },
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A transparent canvas of the given size, budget already checked. */
|
|
44
|
+
export function createRaster(width: number, height: number, source = 'raster'): Raster {
|
|
45
|
+
assertPixelBudget(width, height, source);
|
|
46
|
+
return { width, height, pixels: new Uint8ClampedArray(width * height * 4) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wraps an existing buffer, refusing a length that disagrees with the declared size. A mismatch is
|
|
51
|
+
* a decode or a scaler bug — inconsistent bytes, not too many of them — so it is classified as one.
|
|
52
|
+
*/
|
|
53
|
+
export function rasterFrom(width: number, height: number, pixels: Uint8ClampedArray): Raster {
|
|
54
|
+
assertPixelBudget(width, height, 'raster');
|
|
55
|
+
if (pixels.length !== width * height * 4) {
|
|
56
|
+
throw imageDecodeFailed(
|
|
57
|
+
`raster buffer is ${pixels.length} bytes but ${width}x${height} needs ${width * height * 4}`,
|
|
58
|
+
{ width, height, length: pixels.length },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
return { width, height, pixels };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether any pixel is not fully opaque — decides PNG vs JPEG when nobody asked. */
|
|
65
|
+
export function hasAlpha(raster: Raster): boolean {
|
|
66
|
+
const { pixels } = raster;
|
|
67
|
+
for (let i = 3; i < pixels.length; i += 4) {
|
|
68
|
+
if (pixels[i] !== 255) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|