@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.
@@ -0,0 +1,117 @@
1
+ // Single responsibility: THE image pipeline. Decode -> resize -> encode, one entry point,
2
+ // one capability list. `storage`, `seo` and `pwa` all call this file and none of them owns a
3
+ // second copy: responsive variants, blur placeholders and PWA icons are the same three steps
4
+ // with different numbers, and a framework that generated them twice would drift twice.
5
+
6
+ import { imageUnsupported } from './errors';
7
+ import { decodeJpeg } from './jpeg-decode';
8
+ import { encodeJpeg } from './jpeg-encode';
9
+ import { DEFAULT_JPEG_QUALITY } from './jpeg-tables';
10
+ import { decodePng, encodePng } from './png';
11
+ import { IMAGE_MIME_TYPES, type ImageFormat, probeImage, sniffImageFormat } from './probe';
12
+ import { hasAlpha, type Raster } from './raster';
13
+ import { type ResizeSpec, resizeRaster } from './resize';
14
+
15
+ /**
16
+ * What the built-in, zero-dependency pipeline can actually produce. Bun ships no image API and
17
+ * the contract forbids `sharp`, so WebP and AVIF are *probed and served*, never synthesised
18
+ * here — a caller that needs them routes transforms through a driver (see `@ultimat3/seo`'s
19
+ * `ImageTransformDriver`). Publishing the real list is what stops `<source type="image/avif">`
20
+ * from promising a variant nothing can encode.
21
+ */
22
+ export const DECODABLE_FORMATS = ['png', 'jpeg'] as const;
23
+ export const ENCODABLE_FORMATS = ['png', 'jpeg'] as const;
24
+
25
+ export type DecodableFormat = (typeof DECODABLE_FORMATS)[number];
26
+ export type EncodableFormat = (typeof ENCODABLE_FORMATS)[number];
27
+
28
+ export const canDecode = (format: ImageFormat): format is DecodableFormat =>
29
+ (DECODABLE_FORMATS as readonly string[]).includes(format);
30
+
31
+ export const canEncode = (format: ImageFormat): format is EncodableFormat =>
32
+ (ENCODABLE_FORMATS as readonly string[]).includes(format);
33
+
34
+ /** Small enough to inline in HTML, big enough to blur convincingly. */
35
+ export const BLUR_PLACEHOLDER_WIDTH = 16;
36
+
37
+ const decodeFix =
38
+ 'convert the source to PNG or JPEG before it reaches the pipeline, or pass a custom ' +
39
+ 'ImageTransformDriver that can read it';
40
+
41
+ const encodeFix =
42
+ "request 'png' or 'jpeg', or route the transform through an ImageTransformDriver (a CDN " +
43
+ 'or an external encoder) that can produce it';
44
+
45
+ /** Bytes in, RGBA out. The only place a format is turned into pixels. */
46
+ export function decodeImage(bytes: Uint8Array): Raster {
47
+ const format = sniffImageFormat(bytes);
48
+ if (format === null) {
49
+ throw imageUnsupported('the bytes match no image format this pipeline knows', decodeFix);
50
+ }
51
+ if (format === 'png') return decodePng(bytes);
52
+ if (format === 'jpeg') return decodeJpeg(bytes);
53
+ throw imageUnsupported(`decoding ${format} is not built in`, decodeFix, { format });
54
+ }
55
+
56
+ /** RGBA in, bytes out. `quality` is ignored by lossless formats. */
57
+ export function encodeImage(
58
+ raster: Raster,
59
+ format: ImageFormat,
60
+ quality: number = DEFAULT_JPEG_QUALITY,
61
+ ): Uint8Array {
62
+ if (format === 'png') return encodePng(raster);
63
+ if (format === 'jpeg') return encodeJpeg(raster, quality);
64
+ throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format });
65
+ }
66
+
67
+ export interface ImageTransformSpec extends ResizeSpec {
68
+ /** Defaults to whichever encodable format preserves the source: PNG if it has alpha. */
69
+ readonly format?: ImageFormat | undefined;
70
+ /** 1-100, JPEG only. */
71
+ readonly quality?: number | undefined;
72
+ }
73
+
74
+ /**
75
+ * PNG keeps transparency, JPEG does not; picking by the pixels means a logo never silently
76
+ * grows a black background because nobody passed `format`.
77
+ */
78
+ export const defaultFormatFor = (raster: Raster): EncodableFormat =>
79
+ hasAlpha(raster) ? 'png' : 'jpeg';
80
+
81
+ /** The whole pipeline in one call: decode, resize, encode. */
82
+ export function transformImageBytes(bytes: Uint8Array, spec: ImageTransformSpec = {}): Uint8Array {
83
+ const { format } = spec;
84
+ // The spec alone answers "can this be written?", so answer it here — decoding and resampling
85
+ // 64 megapixels first, only to refuse at the encoder, is work nobody can use.
86
+ if (format !== undefined && !canEncode(format)) {
87
+ throw imageUnsupported(`encoding ${format} is not built in`, encodeFix, { format });
88
+ }
89
+ const source = decodeImage(bytes);
90
+ const resized = resizeRaster(source, spec);
91
+ return encodeImage(resized, format ?? defaultFormatFor(resized), spec.quality);
92
+ }
93
+
94
+ /** Chunked because spreading a whole image into `String.fromCharCode` overflows the stack. */
95
+ function base64Of(bytes: Uint8Array): string {
96
+ let binary = '';
97
+ for (let i = 0; i < bytes.length; i += 0x8000) {
98
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
99
+ }
100
+ return btoa(binary);
101
+ }
102
+
103
+ export const dataUrl = (bytes: Uint8Array, format: ImageFormat): string =>
104
+ `data:${IMAGE_MIME_TYPES[format]};base64,${base64Of(bytes)}`;
105
+
106
+ /**
107
+ * The LQIP: the source at 16px wide, as a `data:` URI. Always PNG — at this size a JPEG's
108
+ * own headers cost more than the pixels, and alpha survives.
109
+ */
110
+ export function blurDataUrl(bytes: Uint8Array, width: number = BLUR_PLACEHOLDER_WIDTH): string {
111
+ const tiny = resizeRaster(decodeImage(bytes), { width });
112
+ return dataUrl(encodePng(tiny), 'png');
113
+ }
114
+
115
+ export type { ImageFormat };
116
+ /** Intrinsic dimensions without decoding — this is what keeps CLS at 0 for every format. */
117
+ export { IMAGE_MIME_TYPES, probeImage, sniffImageFormat };
@@ -0,0 +1,91 @@
1
+ // Single responsibility: PNG's byte-level primitives — signature, chunk framing, the two
2
+ // checksums and the Paeth predictor. Both directions of the codec need every one of them, so
3
+ // they live here once: a CRC that agreed with itself but not with libpng would be invisible
4
+ // if the reader and the writer each carried their own copy.
5
+
6
+ const NO_BYTES = new Uint8Array(0);
7
+
8
+ export const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
9
+
10
+ export const EMPTY_CHUNK_DATA: Uint8Array = NO_BYTES;
11
+
12
+ const CRC_TABLE = new Uint32Array(256).map((_, n) => {
13
+ let c = n;
14
+ for (let k = 0; k < 8; k += 1) c = (c & 1) === 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
15
+ return c;
16
+ });
17
+
18
+ export function crc32(bytes: Uint8Array, start: number, end: number): number {
19
+ let c = 0xffffffff;
20
+ for (let i = start; i < end; i += 1) {
21
+ c = (CRC_TABLE[(c ^ (bytes[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8);
22
+ }
23
+ return (c ^ 0xffffffff) >>> 0;
24
+ }
25
+
26
+ /** RFC 1950 checksum. 5552 is the most sums that fit before the accumulators overflow. */
27
+ export function adler32(bytes: Uint8Array): number {
28
+ let a = 1;
29
+ let b = 0;
30
+ let i = 0;
31
+ while (i < bytes.length) {
32
+ const end = Math.min(i + 5552, bytes.length);
33
+ for (; i < end; i += 1) {
34
+ a += bytes[i] ?? 0;
35
+ b += a;
36
+ }
37
+ a %= 65521;
38
+ b %= 65521;
39
+ }
40
+ return ((b << 16) | a) >>> 0;
41
+ }
42
+
43
+ export const readU32 = (bytes: Uint8Array, at: number): number =>
44
+ (bytes[at] ?? 0) * 0x1000000 +
45
+ (((bytes[at + 1] ?? 0) << 16) | ((bytes[at + 2] ?? 0) << 8) | (bytes[at + 3] ?? 0));
46
+
47
+ export function writeU32(out: Uint8Array, at: number, value: number): void {
48
+ out[at] = (value >>> 24) & 0xff;
49
+ out[at + 1] = (value >>> 16) & 0xff;
50
+ out[at + 2] = (value >>> 8) & 0xff;
51
+ out[at + 3] = value & 0xff;
52
+ }
53
+
54
+ export function joinBytes(parts: readonly Uint8Array[]): Uint8Array {
55
+ if (parts.length === 1) return parts[0] ?? NO_BYTES;
56
+ let total = 0;
57
+ for (const part of parts) total += part.length;
58
+ const out = new Uint8Array(total);
59
+ let at = 0;
60
+ for (const part of parts) {
61
+ out.set(part, at);
62
+ at += part.length;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** One framed chunk: length, type, data, CRC-32 over type and data. */
68
+ export function chunk(type: string, data: Uint8Array): Uint8Array {
69
+ const out = new Uint8Array(data.length + 12);
70
+ writeU32(out, 0, data.length);
71
+ for (let i = 0; i < 4; i += 1) out[4 + i] = type.charCodeAt(i);
72
+ out.set(data, 8);
73
+ writeU32(out, data.length + 8, crc32(out, 4, data.length + 8));
74
+ return out;
75
+ }
76
+
77
+ /** Bun's zlib refuses a view onto a SharedArrayBuffer, and decoder input can be backed by one. */
78
+ export const unshared = (view: Uint8Array): Uint8Array<ArrayBuffer> =>
79
+ view.buffer instanceof ArrayBuffer
80
+ ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
81
+ : Uint8Array.from(view);
82
+
83
+ /** The filter both directions predict with: the decoder adds it back, the encoder subtracts it. */
84
+ export function paeth(a: number, b: number, c: number): number {
85
+ const p = a + b - c;
86
+ const pa = Math.abs(p - a);
87
+ const pb = Math.abs(p - b);
88
+ const pc = Math.abs(p - c);
89
+ if (pa <= pb && pa <= pc) return a;
90
+ return pb <= pc ? b : c;
91
+ }
@@ -0,0 +1,433 @@
1
+ // Single responsibility: the PNG codec. Every colour type, bit depth and row filter the format
2
+ // allows decodes into the one 8-bit RGBA raster; encoding has exactly one output shape (colour
3
+ // type 6, adaptive filters). Chunk CRCs are verified on the way in because a decoder that
4
+ // tolerates a corrupt chunk hands the app wrong pixels instead of a coded error.
5
+
6
+ import { imageDecodeFailed, imageUnsupported } from './errors';
7
+ import {
8
+ adler32,
9
+ chunk,
10
+ crc32,
11
+ EMPTY_CHUNK_DATA,
12
+ joinBytes,
13
+ PNG_SIGNATURE,
14
+ paeth,
15
+ readU32,
16
+ unshared,
17
+ writeU32,
18
+ } from './png-bytes';
19
+ import { assertPixelBudget, type Raster, rasterFrom } from './raster';
20
+
21
+ /** Bytes per pixel of the encoder's one output shape, and its filter offset. */
22
+ const RGBA_BPP = 4;
23
+
24
+ /** Samples per pixel, by colour type. A palette row carries one index, not one colour. */
25
+ const CHANNELS = Uint8Array.of(1, 0, 3, 1, 2, 0, 4);
26
+
27
+ /**
28
+ * PNG spec table 11.1, keyed by colour type. Every legal depth is a power of two, so the set of
29
+ * them masks directly — which is also why a depth like 3 has to be rejected as not one at all.
30
+ */
31
+ const LEGAL_DEPTHS: Readonly<Record<number, number>> = {
32
+ 0: 1 | 2 | 4 | 8 | 16,
33
+ 2: 8 | 16,
34
+ 3: 1 | 2 | 4 | 8,
35
+ 4: 8 | 16,
36
+ 6: 8 | 16,
37
+ };
38
+
39
+ const channelsOf = (colourType: number): number => CHANNELS[colourType] ?? 1;
40
+
41
+ const isLegalShape = (colourType: number, bitDepth: number): boolean =>
42
+ bitDepth !== 0 &&
43
+ (bitDepth & (bitDepth - 1)) === 0 &&
44
+ ((LEGAL_DEPTHS[colourType] ?? 0) & bitDepth) === bitDepth;
45
+
46
+ /** Stretches a sub-byte sample across the full range, so depth 1 reads 0/255 rather than 0/1. */
47
+ const UPSCALE: Readonly<Record<number, number>> = { 1: 255, 2: 85, 4: 17 };
48
+
49
+ interface PngHeader {
50
+ readonly width: number;
51
+ readonly height: number;
52
+ readonly bitDepth: number;
53
+ readonly colourType: number;
54
+ }
55
+
56
+ function assertSignature(bytes: Uint8Array): void {
57
+ for (let i = 0; i < PNG_SIGNATURE.length; i += 1) {
58
+ if (bytes[i] === PNG_SIGNATURE[i]) continue;
59
+ const found = Array.from(bytes.subarray(0, 8));
60
+ throw imageDecodeFailed(`the bytes are not a PNG: signature reads ${found}`, {
61
+ signature: found,
62
+ });
63
+ }
64
+ }
65
+
66
+ function readHeader(bytes: Uint8Array, at: number, length: number): PngHeader {
67
+ if (length !== 13) {
68
+ throw imageDecodeFailed(`the PNG IHDR chunk carries ${length} bytes, not the required 13`, {
69
+ length,
70
+ });
71
+ }
72
+ const width = readU32(bytes, at);
73
+ const height = readU32(bytes, at + 4);
74
+ const bitDepth = bytes[at + 8] ?? 0;
75
+ const colourType = bytes[at + 9] ?? 0;
76
+ const compression = bytes[at + 10] ?? 0;
77
+ const filter = bytes[at + 11] ?? 0;
78
+ const interlace = bytes[at + 12] ?? 0;
79
+ if (!isLegalShape(colourType, bitDepth)) {
80
+ throw imageDecodeFailed(
81
+ `the PNG declares colour type ${colourType} at ${bitDepth} bits, a pair the format omits`,
82
+ { colourType, bitDepth },
83
+ );
84
+ }
85
+ if (compression !== 0 || filter !== 0 || interlace > 1) {
86
+ throw imageDecodeFailed(
87
+ `the PNG declares compression ${compression}, filter method ${filter}, interlace ` +
88
+ `${interlace}; only 0, 0 and 0-or-1 have ever been defined`,
89
+ { compression, filter, interlace },
90
+ );
91
+ }
92
+ // Before a single byte is allocated: the declared size is the only bomb guard that is cheap.
93
+ assertPixelBudget(width, height, 'png');
94
+ if (interlace === 1) {
95
+ throw imageUnsupported(
96
+ 'the file is an Adam7 interlaced PNG, which the built-in decoder does not implement',
97
+ 'convert the file to a non-interlaced PNG: `convert in.png -interlace none out.png`',
98
+ { width, height },
99
+ );
100
+ }
101
+ return { width, height, bitDepth, colourType };
102
+ }
103
+
104
+ /**
105
+ * PNG wraps its deflate stream in zlib, so the 2-byte header and 4-byte Adler-32 trailer are
106
+ * validated and stripped here and the payload is inflated as RAW deflate. `windowBits: -15` states
107
+ * that: Bun's documented default is 15, meaning zlib-wrapped, and a version that starts honouring
108
+ * it would otherwise reject every PNG we read.
109
+ */
110
+ function inflateIdat(stream: Uint8Array): Uint8Array {
111
+ const cmf = stream[0] ?? 0;
112
+ const flg = stream[1] ?? 0;
113
+ const check = ((cmf << 8) | flg) >>> 0;
114
+ if (stream.length < 6 || (cmf & 0x0f) !== 8 || check % 31 !== 0) {
115
+ throw imageDecodeFailed(
116
+ `the PNG IDAT stream is ${stream.length} bytes opening 0x${check.toString(16)}, not zlib`,
117
+ { header: check, compressed: stream.length },
118
+ );
119
+ }
120
+ if ((flg & 0x20) !== 0) {
121
+ throw imageUnsupported(
122
+ 'the PNG IDAT stream sets a zlib preset dictionary, which the PNG format forbids',
123
+ 're-export the file with a conformant encoder: `convert in.png out.png`',
124
+ );
125
+ }
126
+ try {
127
+ return Bun.inflateSync(unshared(stream.subarray(2, stream.length - 4)), { windowBits: -15 });
128
+ } catch (error) {
129
+ const why = error instanceof Error ? error.message : String(error);
130
+ throw imageDecodeFailed(`the PNG IDAT stream could not be inflated: ${why}`, {
131
+ compressed: stream.length,
132
+ });
133
+ }
134
+ }
135
+
136
+ /** Reverses the per-scanline filter in place, leaving each row's raw bytes where they lay. */
137
+ function unfilter(raw: Uint8Array, height: number, rowBytes: number, bpp: number): void {
138
+ const stride = rowBytes + 1;
139
+ for (let y = 0; y < height; y += 1) {
140
+ const at = y * stride;
141
+ const filter = raw[at] ?? 0;
142
+ const row = at + 1;
143
+ const prev = row - stride;
144
+ const hasPrev = y > 0;
145
+ switch (filter) {
146
+ case 0:
147
+ break;
148
+ case 1: {
149
+ for (let i = bpp; i < rowBytes; i += 1) {
150
+ raw[row + i] = ((raw[row + i] ?? 0) + (raw[row + i - bpp] ?? 0)) & 0xff;
151
+ }
152
+ break;
153
+ }
154
+ case 2: {
155
+ if (!hasPrev) break;
156
+ for (let i = 0; i < rowBytes; i += 1) {
157
+ raw[row + i] = ((raw[row + i] ?? 0) + (raw[prev + i] ?? 0)) & 0xff;
158
+ }
159
+ break;
160
+ }
161
+ // Average and Paeth read the same three neighbours; only the predictor differs.
162
+ case 3:
163
+ case 4: {
164
+ for (let i = 0; i < rowBytes; i += 1) {
165
+ const left = i >= bpp ? (raw[row + i - bpp] ?? 0) : 0;
166
+ const up = hasPrev ? (raw[prev + i] ?? 0) : 0;
167
+ const upLeft = hasPrev && i >= bpp ? (raw[prev + i - bpp] ?? 0) : 0;
168
+ const guess = filter === 3 ? (left + up) >> 1 : paeth(left, up, upLeft);
169
+ raw[row + i] = ((raw[row + i] ?? 0) + guess) & 0xff;
170
+ }
171
+ break;
172
+ }
173
+ default:
174
+ throw imageDecodeFailed(
175
+ `PNG scanline ${y} declares filter type ${filter}, which is not one of 0-4`,
176
+ { row: y, filter },
177
+ );
178
+ }
179
+ }
180
+ }
181
+
182
+ /** One scanline of unfiltered bytes into whole samples, at whatever precision the file uses. */
183
+ function readSamples(raw: Uint8Array, at: number, out: Uint16Array, bitDepth: number): void {
184
+ const count = out.length;
185
+ if (bitDepth === 8) {
186
+ for (let i = 0; i < count; i += 1) out[i] = raw[at + i] ?? 0;
187
+ return;
188
+ }
189
+ if (bitDepth === 16) {
190
+ for (let i = 0; i < count; i += 1) {
191
+ out[i] = ((raw[at + i * 2] ?? 0) << 8) | (raw[at + i * 2 + 1] ?? 0);
192
+ }
193
+ return;
194
+ }
195
+ const perByte = 8 / bitDepth;
196
+ const mask = (1 << bitDepth) - 1;
197
+ for (let i = 0; i < count; i += 1) {
198
+ const byte = raw[at + ((i / perByte) | 0)] ?? 0;
199
+ out[i] = (byte >> (8 - bitDepth * ((i % perByte) + 1))) & mask;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * `tRNS` on a greyscale or truecolour image is not a table: it names ONE sample value that is
205
+ * fully transparent, at the image's own bit depth. Ignoring it drops a logo's cut-out.
206
+ */
207
+ function transparentKey(colourType: number, trns: Uint8Array | undefined): Uint16Array | undefined {
208
+ if (trns === undefined || (colourType !== 0 && colourType !== 2)) return undefined;
209
+ const samples = colourType === 2 ? 3 : 1;
210
+ if (trns.length < samples * 2) return undefined;
211
+ const key = new Uint16Array(samples);
212
+ for (let i = 0; i < samples; i += 1) {
213
+ key[i] = ((trns[i * 2] ?? 0) << 8) | (trns[i * 2 + 1] ?? 0);
214
+ }
215
+ return key;
216
+ }
217
+
218
+ /**
219
+ * Which sample of a pixel feeds R, G, B and A, by colour type; `-1` is "no alpha sample". Stating
220
+ * the mapping once stops four colour types from becoming four loops that can each be wrong.
221
+ */
222
+ const RGBA_SOURCE: Readonly<Record<number, readonly [number, number, number, number]>> = {
223
+ 0: [0, 0, 0, -1],
224
+ 2: [0, 1, 2, -1],
225
+ 4: [0, 0, 0, 1],
226
+ 6: [0, 1, 2, 3],
227
+ };
228
+
229
+ /** Opaque unless a `tRNS` key colour matches this pixel's samples exactly. */
230
+ function opacityFor(samples: Uint16Array, at: number, key: Uint16Array | undefined): number {
231
+ if (key === undefined) return 255;
232
+ for (let i = 0; i < key.length; i += 1) {
233
+ if (samples[at + i] !== key[i]) return 255;
234
+ }
235
+ return 0;
236
+ }
237
+
238
+ function expand(
239
+ raw: Uint8Array,
240
+ header: PngHeader,
241
+ palette: Uint8Array | undefined,
242
+ trns: Uint8Array | undefined,
243
+ ): Uint8ClampedArray {
244
+ const { width, height, bitDepth, colourType } = header;
245
+ if (colourType === 3 && palette === undefined) {
246
+ throw imageDecodeFailed('the PNG is indexed colour but carries no PLTE chunk');
247
+ }
248
+ const plte = palette ?? EMPTY_CHUNK_DATA;
249
+ const entries = (plte.length / 3) | 0;
250
+ const channels = channelsOf(colourType);
251
+ const stride = Math.ceil((width * channels * bitDepth) / 8) + 1;
252
+ const upscale = UPSCALE[bitDepth] ?? 1;
253
+ const key = transparentKey(colourType, trns);
254
+ const samples = new Uint16Array(width * channels);
255
+ const pixels = new Uint8ClampedArray(width * height * 4);
256
+ const [sr, sg, sb, sa] = RGBA_SOURCE[colourType] ?? [0, 0, 0, -1];
257
+ const byteOf = (sample: number): number => (bitDepth === 16 ? sample >>> 8 : sample * upscale);
258
+
259
+ for (let y = 0; y < height; y += 1) {
260
+ readSamples(raw, y * stride + 1, samples, bitDepth);
261
+ let p = y * width * 4;
262
+ for (let x = 0; x < width; x += 1) {
263
+ const s = x * channels;
264
+ if (colourType === 3) {
265
+ const index = samples[s] ?? 0;
266
+ if (index >= entries) {
267
+ throw imageDecodeFailed(
268
+ `PNG pixel ${x},${y} uses palette index ${index} but PLTE holds ${entries} entries`,
269
+ { x, y, index, entries },
270
+ );
271
+ }
272
+ pixels[p] = plte[index * 3] ?? 0;
273
+ pixels[p + 1] = plte[index * 3 + 1] ?? 0;
274
+ pixels[p + 2] = plte[index * 3 + 2] ?? 0;
275
+ pixels[p + 3] = trns !== undefined && index < trns.length ? (trns[index] ?? 255) : 255;
276
+ } else {
277
+ pixels[p] = byteOf(samples[s + sr] ?? 0);
278
+ pixels[p + 1] = byteOf(samples[s + sg] ?? 0);
279
+ pixels[p + 2] = byteOf(samples[s + sb] ?? 0);
280
+ pixels[p + 3] = sa >= 0 ? byteOf(samples[s + sa] ?? 0) : opacityFor(samples, s, key);
281
+ }
282
+ p += 4;
283
+ }
284
+ }
285
+ return pixels;
286
+ }
287
+
288
+ /** PNG bytes in, RGBA out. Every chunk is CRC-checked before a single pixel is believed. */
289
+ export function decodePng(bytes: Uint8Array): Raster {
290
+ assertSignature(bytes);
291
+ let header: PngHeader | undefined;
292
+ let palette: Uint8Array | undefined;
293
+ let trns: Uint8Array | undefined;
294
+ const idat: Uint8Array[] = [];
295
+ let ended = false;
296
+ let offset = 8;
297
+
298
+ while (offset + 8 <= bytes.length) {
299
+ const length = readU32(bytes, offset);
300
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
301
+ const dataAt = offset + 8;
302
+ const crcAt = dataAt + length;
303
+ if (crcAt + 4 > bytes.length) {
304
+ throw imageDecodeFailed(
305
+ `the PNG ends after ${bytes.length} bytes, inside chunk ${type} at offset ${offset}`,
306
+ { chunk: type, offset, declared: length },
307
+ );
308
+ }
309
+ const declared = readU32(bytes, crcAt);
310
+ const actual = crc32(bytes, offset + 4, crcAt);
311
+ if (declared !== actual) {
312
+ throw imageDecodeFailed(
313
+ `PNG chunk ${type} fails its CRC-32: the file says ${declared}, the bytes hash to ${actual}`,
314
+ { chunk: type, declared, actual },
315
+ );
316
+ }
317
+ if (header === undefined && type !== 'IHDR') {
318
+ throw imageDecodeFailed(`the first PNG chunk is ${type}, not IHDR`, { chunk: type });
319
+ }
320
+ if (type === 'IHDR') {
321
+ if (header !== undefined) throw imageDecodeFailed('the PNG carries more than one IHDR chunk');
322
+ header = readHeader(bytes, dataAt, length);
323
+ } else if (type === 'PLTE') {
324
+ palette = bytes.subarray(dataAt, crcAt);
325
+ } else if (type === 'tRNS') {
326
+ trns = bytes.subarray(dataAt, crcAt);
327
+ } else if (type === 'IDAT') {
328
+ idat.push(bytes.subarray(dataAt, crcAt));
329
+ } else if (type === 'IEND') {
330
+ ended = true;
331
+ break;
332
+ }
333
+ offset = crcAt + 4;
334
+ }
335
+
336
+ if (!ended) throw imageDecodeFailed('the PNG never reaches IEND, so the file is truncated');
337
+ if (header === undefined) throw imageDecodeFailed('the PNG carries no IHDR chunk');
338
+ if (idat.length === 0)
339
+ throw imageDecodeFailed('the PNG carries no IDAT chunk, so it has no rows');
340
+
341
+ const { width, height, bitDepth, colourType } = header;
342
+ const channels = channelsOf(colourType);
343
+ const rowBytes = Math.ceil((width * channels * bitDepth) / 8);
344
+ const expected = height * (rowBytes + 1);
345
+ const raw = inflateIdat(joinBytes(idat));
346
+ if (raw.length !== expected) {
347
+ throw imageDecodeFailed(
348
+ `the PNG inflates to ${raw.length} bytes but ${width}x${height} at ${bitDepth} bits over ` +
349
+ `${channels} channels needs exactly ${expected}`,
350
+ { inflated: raw.length, expected },
351
+ );
352
+ }
353
+ unfilter(raw, height, rowBytes, Math.max(1, Math.ceil((bitDepth * channels) / 8)));
354
+ return rasterFrom(width, height, expand(raw, header, palette, trns));
355
+ }
356
+
357
+ /**
358
+ * Filters one scanline into `out`, scored by the libpng heuristic: the sum of its bytes read as
359
+ * signed. The lowest sum is the row deflate compresses best, which is why an encoder that always
360
+ * wrote filter 0 would ship files roughly twice this size. A negative `prev` is "no row above".
361
+ */
362
+ function filterScanline(
363
+ pixels: Uint8ClampedArray,
364
+ row: number,
365
+ prev: number,
366
+ stride: number,
367
+ filter: number,
368
+ out: Uint8Array,
369
+ ): number {
370
+ let score = 0;
371
+ const hasPrev = prev >= 0;
372
+ for (let i = 0; i < stride; i += 1) {
373
+ const raw = pixels[row + i] ?? 0;
374
+ const left = i >= RGBA_BPP ? (pixels[row + i - RGBA_BPP] ?? 0) : 0;
375
+ const up = hasPrev ? (pixels[prev + i] ?? 0) : 0;
376
+ let value = raw;
377
+ if (filter === 1) value = raw - left;
378
+ else if (filter === 2) value = raw - up;
379
+ else if (filter === 3) value = raw - ((left + up) >> 1);
380
+ else if (filter === 4) {
381
+ const upLeft = hasPrev && i >= RGBA_BPP ? (pixels[prev + i - RGBA_BPP] ?? 0) : 0;
382
+ value = raw - paeth(left, up, upLeft);
383
+ }
384
+ value &= 0xff;
385
+ out[i] = value;
386
+ score += value < 128 ? value : 256 - value;
387
+ }
388
+ return score;
389
+ }
390
+
391
+ /**
392
+ * RGBA in, PNG bytes out — always 8-bit colour type 6, non-interlaced. Branching on opacity would
393
+ * give the framework two encoders to keep correct, and alpha on an opaque image is nearly free
394
+ * after deflate, so there is one path.
395
+ */
396
+ export function encodePng(raster: Raster): Uint8Array {
397
+ const { width, height, pixels } = raster;
398
+ const stride = width * 4;
399
+ const filtered = new Uint8Array(height * (stride + 1));
400
+ const scratch = new Uint8Array(stride);
401
+ for (let y = 0; y < height; y += 1) {
402
+ const row = y * stride;
403
+ const at = y * (stride + 1);
404
+ let best = Number.POSITIVE_INFINITY;
405
+ for (let filter = 0; filter <= 4; filter += 1) {
406
+ const score = filterScanline(pixels, row, y > 0 ? row - stride : -1, stride, filter, scratch);
407
+ if (score >= best) continue;
408
+ best = score;
409
+ filtered[at] = filter;
410
+ filtered.set(scratch, at + 1);
411
+ }
412
+ }
413
+
414
+ // `windowBits: -15` asks for RAW deflate explicitly, because the zlib envelope PNG requires is
415
+ // written by hand below — leaving it to the default would double-wrap the stream the day Bun's
416
+ // documented default (15, zlib-wrapped) is the one that actually applies.
417
+ const deflated = Bun.deflateSync(filtered, { windowBits: -15 });
418
+ const idat = new Uint8Array(deflated.length + 6);
419
+ idat.set([0x78, 0x01]);
420
+ idat.set(deflated, 2);
421
+ writeU32(idat, deflated.length + 2, adler32(filtered));
422
+
423
+ const ihdr = new Uint8Array(13);
424
+ writeU32(ihdr, 0, width);
425
+ writeU32(ihdr, 4, height);
426
+ ihdr.set([8, 6], 8);
427
+ return joinBytes([
428
+ PNG_SIGNATURE,
429
+ chunk('IHDR', ihdr),
430
+ chunk('IDAT', idat),
431
+ chunk('IEND', EMPTY_CHUNK_DATA),
432
+ ]);
433
+ }