@rathnasgala2/template 2.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.
Files changed (43) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +515 -0
  3. package/contracts/render-policy.jcs +1 -0
  4. package/contracts/theme-styling-contract.jcs +1 -0
  5. package/package.json +96 -0
  6. package/src/core/errors.js +136 -0
  7. package/src/core/index.js +758 -0
  8. package/src/core/internal/appearance/bootstrap-script.js +159 -0
  9. package/src/core/internal/appearance/contract.js +193 -0
  10. package/src/core/internal/appearance/controller-markup.js +80 -0
  11. package/src/core/internal/appearance/styling-contract.js +509 -0
  12. package/src/core/internal/canonical-jcs.js +0 -0
  13. package/src/core/internal/content-security.js +0 -0
  14. package/src/core/internal/eleventy-render.js +220 -0
  15. package/src/core/internal/feeds.js +161 -0
  16. package/src/core/internal/fs-walk.js +38 -0
  17. package/src/core/internal/media/avif-probe.js +169 -0
  18. package/src/core/internal/media/font-policy.js +56 -0
  19. package/src/core/internal/media/jpeg-codec.js +0 -0
  20. package/src/core/internal/media/limits.js +129 -0
  21. package/src/core/internal/media/pipeline.js +353 -0
  22. package/src/core/internal/media/png-codec.js +406 -0
  23. package/src/core/internal/media/resize.js +147 -0
  24. package/src/core/internal/media/sniff.js +109 -0
  25. package/src/core/internal/media/webp-probe.js +139 -0
  26. package/src/core/internal/messages.js +78 -0
  27. package/src/core/internal/page-kinds.js +709 -0
  28. package/src/core/internal/provenance.js +49 -0
  29. package/src/core/internal/render-policy-content.js +295 -0
  30. package/src/core/internal/route-labels.js +68 -0
  31. package/src/core/internal/route.js +167 -0
  32. package/src/core/internal/search-index.js +119 -0
  33. package/src/core/internal/seo.js +114 -0
  34. package/src/core/internal/sitemap.js +77 -0
  35. package/src/core/internal/skeleton.js +413 -0
  36. package/src/core/internal/source-inventory.js +145 -0
  37. package/src/core/internal/text-direction.js +59 -0
  38. package/src/core/internal/theme-assets.js +387 -0
  39. package/src/core/internal/uuid.js +51 -0
  40. package/src/core/manifest.js +0 -0
  41. package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
  42. package/types/eleventy.d.ts +38 -0
  43. package/types/index.d.ts +347 -0
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Bounded PNG decode and deterministic PNG encode.
3
+ *
4
+ * Decoding is scoped, on purpose, to the common 8-bit-depth author-photo
5
+ * case: color types 0 (grayscale), 2 (truecolor), 3 (palette), 4
6
+ * (grayscale+alpha) and 6 (truecolor+alpha), bit depth 8 only, no
7
+ * interlacing. Any other bit depth, interlace method, or PNG structure this
8
+ * module does not recognize is a `MEDIA_FORMAT_INVALID` rejection — never a
9
+ * best-effort guess — matching the fail-closed posture the brief and
10
+ * DEC-097 both require of this pipeline. This is a deliberately smaller
11
+ * surface than DEC-097's own closed `gala-theme-binary-assets-v2` PNG
12
+ * grammar (which the theme packages use for passive presentation assets);
13
+ * author media only needs a decode path sufficient to bound, re-derive
14
+ * orientation-free pixels from, and re-encode ordinary photographic/graphic
15
+ * uploads.
16
+ *
17
+ * The decompression-bomb defense runs *before* any large allocation: `IHDR`
18
+ * is parsed first and its declared width/height/pixel-count are checked
19
+ * against the shared media ceilings; only then is a byte ceiling computed
20
+ * from those *declared* dimensions and enforced, incrementally, while
21
+ * inflating `IDAT` — so a file that lies about its own decompressed size
22
+ * (small `IHDR`, huge compressed payload) is aborted mid-stream rather than
23
+ * fully materialized first.
24
+ */
25
+
26
+ import { deflateSync, inflateSync } from 'node:zlib';
27
+
28
+ import { MediaPipelineError } from '../../errors.js';
29
+ import { assertDimensionsWithinCeiling } from './limits.js';
30
+
31
+ const PNG_SIGNATURE = Buffer.from([
32
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
33
+ ]);
34
+
35
+ /** @type {Readonly<Record<number, number>>} color type -> channel count */
36
+ const CHANNELS_BY_COLOR_TYPE = Object.freeze({
37
+ 0: 1,
38
+ 2: 3,
39
+ 3: 1,
40
+ 4: 2,
41
+ 6: 4,
42
+ });
43
+
44
+ /**
45
+ * Read every PNG chunk in `bytes` after the signature, in file order.
46
+ *
47
+ * @param {Buffer} bytes complete PNG file bytes, signature included
48
+ * @param {string} referencePath the source path, for error messages
49
+ * @returns {{type: string, data: Buffer}[]} chunks in file order
50
+ */
51
+ function readChunks(bytes, referencePath) {
52
+ /** @type {{type: string, data: Buffer}[]} */
53
+ const chunks = [];
54
+ let offset = 8;
55
+ while (offset < bytes.length) {
56
+ if (offset + 8 > bytes.length) {
57
+ throw new MediaPipelineError(
58
+ 'MEDIA_FORMAT_INVALID',
59
+ 'truncated PNG chunk header',
60
+ referencePath,
61
+ );
62
+ }
63
+ const length = bytes.readUInt32BE(offset);
64
+ const type = bytes.subarray(offset + 4, offset + 8).toString('ascii');
65
+ const dataStart = offset + 8;
66
+ const dataEnd = dataStart + length;
67
+ if (dataEnd + 4 > bytes.length) {
68
+ throw new MediaPipelineError(
69
+ 'MEDIA_FORMAT_INVALID',
70
+ 'truncated PNG chunk data',
71
+ referencePath,
72
+ );
73
+ }
74
+ chunks.push({ type, data: bytes.subarray(dataStart, dataEnd) });
75
+ offset = dataEnd + 4;
76
+ if (type === 'IEND') break;
77
+ }
78
+ if (offset !== bytes.length) {
79
+ // Defends against a polyglot: bytes appended after the PNG's own IEND
80
+ // (for example a second file's magic bytes) are never silently ignored.
81
+ throw new MediaPipelineError(
82
+ 'MEDIA_FORMAT_INVALID',
83
+ 'trailing bytes after IEND',
84
+ referencePath,
85
+ );
86
+ }
87
+ return chunks;
88
+ }
89
+
90
+ /**
91
+ * Bounded-decode one PNG file's bytes into a raw RGBA8 raster.
92
+ *
93
+ * @param {Buffer} bytes complete PNG file bytes
94
+ * @param {string} referencePath the source path, for error messages
95
+ * @returns {{width: number, height: number, rgba: Buffer}} the decoded
96
+ * raster
97
+ */
98
+ export function decodePng(bytes, referencePath) {
99
+ if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
100
+ throw new MediaPipelineError(
101
+ 'MEDIA_FORMAT_INVALID',
102
+ 'missing PNG signature',
103
+ referencePath,
104
+ );
105
+ }
106
+ const chunks = readChunks(bytes, referencePath);
107
+ const ihdr = chunks[0];
108
+ if (!ihdr || ihdr.type !== 'IHDR' || ihdr.data.length !== 13) {
109
+ throw new MediaPipelineError(
110
+ 'MEDIA_FORMAT_INVALID',
111
+ 'missing or malformed IHDR',
112
+ referencePath,
113
+ );
114
+ }
115
+ const width = ihdr.data.readUInt32BE(0);
116
+ const height = ihdr.data.readUInt32BE(4);
117
+ const bitDepth = ihdr.data.readUInt8(8);
118
+ const colorType = ihdr.data.readUInt8(9);
119
+ const compressionMethod = ihdr.data.readUInt8(10);
120
+ const filterMethod = ihdr.data.readUInt8(11);
121
+ const interlaceMethod = ihdr.data.readUInt8(12);
122
+
123
+ assertDimensionsWithinCeiling(width, height, referencePath, 'PNG');
124
+ if (
125
+ bitDepth !== 8 ||
126
+ !(colorType in CHANNELS_BY_COLOR_TYPE) ||
127
+ compressionMethod !== 0 ||
128
+ filterMethod !== 0 ||
129
+ interlaceMethod !== 0
130
+ ) {
131
+ throw new MediaPipelineError(
132
+ 'MEDIA_FORMAT_INVALID',
133
+ `unsupported PNG IHDR (bitDepth=${bitDepth}, colorType=${colorType}, interlace=${interlaceMethod})`,
134
+ referencePath,
135
+ );
136
+ }
137
+
138
+ const channels = CHANNELS_BY_COLOR_TYPE[colorType];
139
+ const bytesPerPixel = channels;
140
+ const rowBytes = width * bytesPerPixel;
141
+ const expectedRawBytes = height * (1 + rowBytes);
142
+
143
+ /** @type {Buffer | undefined} */
144
+ let palette;
145
+ /** @type {Buffer | undefined} */
146
+ let paletteAlpha;
147
+ const idatParts = [];
148
+ for (const chunk of chunks) {
149
+ if (chunk.type === 'PLTE') palette = chunk.data;
150
+ else if (chunk.type === 'tRNS') paletteAlpha = chunk.data;
151
+ else if (chunk.type === 'IDAT') idatParts.push(chunk.data);
152
+ }
153
+ if (colorType === 3 && !palette) {
154
+ throw new MediaPipelineError(
155
+ 'MEDIA_FORMAT_INVALID',
156
+ 'palette color type without PLTE chunk',
157
+ referencePath,
158
+ );
159
+ }
160
+ if (idatParts.length === 0) {
161
+ throw new MediaPipelineError(
162
+ 'MEDIA_FORMAT_INVALID',
163
+ 'no IDAT chunk present',
164
+ referencePath,
165
+ );
166
+ }
167
+
168
+ const compressed = Buffer.concat(idatParts);
169
+ let raw;
170
+ try {
171
+ // `maxOutputLength` bounds allocation to the exact size this IHDR can
172
+ // ever legitimately decompress to; a file whose compressed stream would
173
+ // produce more than that is a decompression-bomb attempt and is
174
+ // rejected before the extra bytes are ever materialized.
175
+ raw = inflateSync(compressed, { maxOutputLength: expectedRawBytes });
176
+ } catch {
177
+ throw new MediaPipelineError(
178
+ 'MEDIA_RESOURCE_EXCEEDED',
179
+ 'PNG IDAT stream exceeds its declared decompressed size or is malformed',
180
+ referencePath,
181
+ );
182
+ }
183
+ if (raw.length !== expectedRawBytes) {
184
+ throw new MediaPipelineError(
185
+ 'MEDIA_FORMAT_INVALID',
186
+ 'PNG IDAT decompressed to an unexpected byte count',
187
+ referencePath,
188
+ );
189
+ }
190
+
191
+ const rgba = Buffer.alloc(width * height * 4);
192
+ let previousRow = Buffer.alloc(rowBytes);
193
+ for (let y = 0; y < height; y += 1) {
194
+ const rowStart = y * (1 + rowBytes);
195
+ const filterType = raw[rowStart];
196
+ const rowData = raw.subarray(rowStart + 1, rowStart + 1 + rowBytes);
197
+ const currentRow = Buffer.alloc(rowBytes);
198
+ unfilterRow(
199
+ filterType,
200
+ rowData,
201
+ previousRow,
202
+ currentRow,
203
+ bytesPerPixel,
204
+ referencePath,
205
+ );
206
+ writeRowToRgba(
207
+ rgba,
208
+ y,
209
+ width,
210
+ currentRow,
211
+ colorType,
212
+ palette,
213
+ paletteAlpha,
214
+ );
215
+ previousRow = currentRow;
216
+ }
217
+
218
+ return { width, height, rgba };
219
+ }
220
+
221
+ /**
222
+ * Reverse one PNG scanline filter in place into `out`.
223
+ *
224
+ * @param {number} filterType the row's leading filter-type byte
225
+ * @param {Buffer} row the filtered row bytes (no filter-type byte)
226
+ * @param {Buffer} previousRow the previous row's already-unfiltered bytes
227
+ * @param {Buffer} out destination buffer for the unfiltered row
228
+ * @param {number} bpp bytes per whole pixel
229
+ * @param {string} referencePath the source path, for error messages
230
+ * @returns {void}
231
+ */
232
+ function unfilterRow(filterType, row, previousRow, out, bpp, referencePath) {
233
+ for (let i = 0; i < row.length; i += 1) {
234
+ const a = i >= bpp ? out[i - bpp] : 0;
235
+ const b = previousRow[i];
236
+ const c = i >= bpp ? previousRow[i - bpp] : 0;
237
+ let value = row[i];
238
+ if (filterType === 0) {
239
+ // none
240
+ } else if (filterType === 1) {
241
+ value = (value + a) & 0xff;
242
+ } else if (filterType === 2) {
243
+ value = (value + b) & 0xff;
244
+ } else if (filterType === 3) {
245
+ value = (value + Math.floor((a + b) / 2)) & 0xff;
246
+ } else if (filterType === 4) {
247
+ value = (value + paeth(a, b, c)) & 0xff;
248
+ } else {
249
+ throw new MediaPipelineError(
250
+ 'MEDIA_FORMAT_INVALID',
251
+ `unsupported PNG scanline filter type ${filterType}`,
252
+ referencePath,
253
+ );
254
+ }
255
+ out[i] = value;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * The PNG Paeth predictor.
261
+ *
262
+ * @param {number} a left byte
263
+ * @param {number} b above byte
264
+ * @param {number} c upper-left byte
265
+ * @returns {number} the predicted byte value
266
+ */
267
+ function paeth(a, b, c) {
268
+ const p = a + b - c;
269
+ const pa = Math.abs(p - a);
270
+ const pb = Math.abs(p - b);
271
+ const pc = Math.abs(p - c);
272
+ if (pa <= pb && pa <= pc) return a;
273
+ if (pb <= pc) return b;
274
+ return c;
275
+ }
276
+
277
+ /**
278
+ * Expand one unfiltered PNG row into the destination RGBA8 raster.
279
+ *
280
+ * @param {Buffer} rgba the full-image RGBA8 destination buffer
281
+ * @param {number} y the row index
282
+ * @param {number} width the image width in pixels
283
+ * @param {Buffer} row the row's unfiltered channel bytes
284
+ * @param {number} colorType the PNG color type
285
+ * @param {Buffer | undefined} palette `PLTE` chunk bytes, for color type 3
286
+ * @param {Buffer | undefined} paletteAlpha `tRNS` chunk bytes, for color
287
+ * type 3
288
+ * @returns {void}
289
+ */
290
+ function writeRowToRgba(rgba, y, width, row, colorType, palette, paletteAlpha) {
291
+ const rowOffset = y * width * 4;
292
+ for (let x = 0; x < width; x += 1) {
293
+ const out = rowOffset + x * 4;
294
+ if (colorType === 0) {
295
+ const gray = row[x];
296
+ rgba[out] = gray;
297
+ rgba[out + 1] = gray;
298
+ rgba[out + 2] = gray;
299
+ rgba[out + 3] = 255;
300
+ } else if (colorType === 2) {
301
+ const i = x * 3;
302
+ rgba[out] = row[i];
303
+ rgba[out + 1] = row[i + 1];
304
+ rgba[out + 2] = row[i + 2];
305
+ rgba[out + 3] = 255;
306
+ } else if (colorType === 3) {
307
+ const index = row[x];
308
+ const p = index * 3;
309
+ rgba[out] = palette?.[p] ?? 0;
310
+ rgba[out + 1] = palette?.[p + 1] ?? 0;
311
+ rgba[out + 2] = palette?.[p + 2] ?? 0;
312
+ rgba[out + 3] = paletteAlpha?.[index] ?? 255;
313
+ } else if (colorType === 4) {
314
+ const i = x * 2;
315
+ const gray = row[i];
316
+ rgba[out] = gray;
317
+ rgba[out + 1] = gray;
318
+ rgba[out + 2] = gray;
319
+ rgba[out + 3] = row[i + 1];
320
+ } else {
321
+ const i = x * 4;
322
+ rgba[out] = row[i];
323
+ rgba[out + 1] = row[i + 1];
324
+ rgba[out + 2] = row[i + 2];
325
+ rgba[out + 3] = row[i + 3];
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * @param {string} type four-character chunk type
332
+ * @param {Buffer} data chunk payload bytes
333
+ * @returns {Buffer} the complete length-prefixed, CRC-suffixed chunk
334
+ */
335
+ function encodeChunk(type, data) {
336
+ const typeBytes = Buffer.from(type, 'ascii');
337
+ const length = Buffer.alloc(4);
338
+ length.writeUInt32BE(data.length, 0);
339
+ const crcInput = Buffer.concat([typeBytes, data]);
340
+ const crc = Buffer.alloc(4);
341
+ crc.writeUInt32BE(crc32(crcInput) >>> 0, 0);
342
+ return Buffer.concat([length, typeBytes, data, crc]);
343
+ }
344
+
345
+ /** @type {number[] | undefined} */
346
+ let crcTable;
347
+
348
+ /**
349
+ * @param {Buffer} buffer bytes to checksum
350
+ * @returns {number} the CRC-32 (ISO 3309 / PNG) checksum
351
+ */
352
+ function crc32(buffer) {
353
+ if (!crcTable) {
354
+ crcTable = [];
355
+ for (let n = 0; n < 256; n += 1) {
356
+ let c = n;
357
+ for (let k = 0; k < 8; k += 1) {
358
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
359
+ }
360
+ crcTable[n] = c >>> 0;
361
+ }
362
+ }
363
+ let crc = 0xffffffff;
364
+ for (let i = 0; i < buffer.length; i += 1) {
365
+ crc = crcTable[(crc ^ buffer[i]) & 0xff] ^ (crc >>> 8);
366
+ }
367
+ return (crc ^ 0xffffffff) >>> 0;
368
+ }
369
+
370
+ /**
371
+ * Deterministically encode an RGBA8 raster as a minimal truecolor+alpha PNG:
372
+ * one `IHDR`, one "filter type 0 (none)" `IDAT` stream at a fixed zlib
373
+ * level/strategy, one `IEND`. No ancillary chunk (no metadata) is ever
374
+ * emitted, matching the brief's "strip nonessential metadata" requirement
375
+ * for every output this renderer produces.
376
+ *
377
+ * @param {{width: number, height: number, rgba: Buffer}} raster the raster
378
+ * to encode
379
+ * @returns {Buffer} the complete PNG file bytes
380
+ */
381
+ export function encodePng({ width, height, rgba }) {
382
+ const rowBytes = width * 4;
383
+ const raw = Buffer.alloc(height * (1 + rowBytes));
384
+ for (let y = 0; y < height; y += 1) {
385
+ const rowStart = y * (1 + rowBytes);
386
+ raw[rowStart] = 0; // filter type: none
387
+ rgba.copy(raw, rowStart + 1, y * rowBytes, y * rowBytes + rowBytes);
388
+ }
389
+ const idatData = deflateSync(raw, { level: 9, strategy: 0 });
390
+
391
+ const ihdr = Buffer.alloc(13);
392
+ ihdr.writeUInt32BE(width, 0);
393
+ ihdr.writeUInt32BE(height, 4);
394
+ ihdr.writeUInt8(8, 8); // bit depth
395
+ ihdr.writeUInt8(6, 9); // color type: truecolor + alpha
396
+ ihdr.writeUInt8(0, 10);
397
+ ihdr.writeUInt8(0, 11);
398
+ ihdr.writeUInt8(0, 12);
399
+
400
+ return Buffer.concat([
401
+ PNG_SIGNATURE,
402
+ encodeChunk('IHDR', ihdr),
403
+ encodeChunk('IDAT', idatData),
404
+ encodeChunk('IEND', Buffer.alloc(0)),
405
+ ]);
406
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Deterministic RGBA8 box-filter downscale for responsive derivative
3
+ * generation.
4
+ *
5
+ * IEEE 754 double-precision arithmetic is fully deterministic — the same
6
+ * inputs always produce the same bit pattern on any conforming engine — so
7
+ * this box filter (unlike a lossy codec's own pixel decode, which DEC-097
8
+ * notes is implementation-dependent) is safe to use directly for the
9
+ * two-clean-build byte-equality requirement: the same source raster and the
10
+ * same target width always produce the same resized raster, and therefore,
11
+ * fed through this pipeline's own deterministic encoders, the same
12
+ * derivative bytes.
13
+ */
14
+
15
+ import { MediaPipelineError } from '../../errors.js';
16
+
17
+ /**
18
+ * Downscale an RGBA8 raster to exactly `targetWidth` pixels wide, preserving
19
+ * aspect ratio (target height is `round(height * targetWidth / width)`,
20
+ * floored to at least one pixel). Never upscales: the caller is responsible
21
+ * for only requesting widths at or below the source width.
22
+ *
23
+ * @param {{width: number, height: number, rgba: Buffer}} raster the source
24
+ * raster
25
+ * @param {number} targetWidth the desired output width, in pixels
26
+ * @param {string} referencePath the source path, for error messages
27
+ * @returns {{width: number, height: number, rgba: Buffer}} the resized
28
+ * raster
29
+ */
30
+ export function resizeRaster(
31
+ { width, height, rgba },
32
+ targetWidth,
33
+ referencePath,
34
+ ) {
35
+ if (targetWidth < 1 || targetWidth > width) {
36
+ throw new MediaPipelineError(
37
+ 'MEDIA_FORMAT_INVALID',
38
+ `derivative width ${targetWidth} is not between 1 and the source width ${width}`,
39
+ referencePath,
40
+ );
41
+ }
42
+ if (targetWidth === width) {
43
+ return { width, height, rgba: Buffer.from(rgba) };
44
+ }
45
+ const targetHeight = Math.max(1, Math.round((height * targetWidth) / width));
46
+ const out = Buffer.alloc(targetWidth * targetHeight * 4);
47
+
48
+ for (let oy = 0; oy < targetHeight; oy += 1) {
49
+ const y0 = (oy * height) / targetHeight;
50
+ const y1 = ((oy + 1) * height) / targetHeight;
51
+ const sy0 = Math.floor(y0);
52
+ const sy1 = Math.max(sy0 + 1, Math.ceil(y1));
53
+ for (let ox = 0; ox < targetWidth; ox += 1) {
54
+ const x0 = (ox * width) / targetWidth;
55
+ const x1 = ((ox + 1) * width) / targetWidth;
56
+ const sx0 = Math.floor(x0);
57
+ const sx1 = Math.max(sx0 + 1, Math.ceil(x1));
58
+
59
+ let r = 0;
60
+ let g = 0;
61
+ let b = 0;
62
+ let a = 0;
63
+ let count = 0;
64
+ for (let sy = sy0; sy < sy1 && sy < height; sy += 1) {
65
+ for (let sx = sx0; sx < sx1 && sx < width; sx += 1) {
66
+ const i = (sy * width + sx) * 4;
67
+ r += rgba[i];
68
+ g += rgba[i + 1];
69
+ b += rgba[i + 2];
70
+ a += rgba[i + 3];
71
+ count += 1;
72
+ }
73
+ }
74
+ const outIndex = (oy * targetWidth + ox) * 4;
75
+ out[outIndex] = Math.round(r / count);
76
+ out[outIndex + 1] = Math.round(g / count);
77
+ out[outIndex + 2] = Math.round(b / count);
78
+ out[outIndex + 3] = Math.round(a / count);
79
+ }
80
+ }
81
+
82
+ return { width: targetWidth, height: targetHeight, rgba: out };
83
+ }
84
+
85
+ /**
86
+ * Rotate/flip an RGBA8 raster to normalize an EXIF orientation value to `1`
87
+ * (identity).
88
+ *
89
+ * @param {{width: number, height: number, rgba: Buffer}} raster the source
90
+ * raster
91
+ * @param {number} orientation an EXIF orientation value, `1..8`
92
+ * @returns {{width: number, height: number, rgba: Buffer}} the
93
+ * orientation-normalized raster
94
+ */
95
+ export function applyExifOrientation({ width, height, rgba }, orientation) {
96
+ if (orientation === 1) return { width, height, rgba };
97
+
98
+ const swapDimensions = orientation >= 5;
99
+ const outWidth = swapDimensions ? height : width;
100
+ const outHeight = swapDimensions ? width : height;
101
+ const out = Buffer.alloc(outWidth * outHeight * 4);
102
+
103
+ for (let y = 0; y < height; y += 1) {
104
+ for (let x = 0; x < width; x += 1) {
105
+ const [dx, dy] = mapOrientedCoordinate(x, y, width, height, orientation);
106
+ const srcIndex = (y * width + x) * 4;
107
+ const dstIndex = (dy * outWidth + dx) * 4;
108
+ out[dstIndex] = rgba[srcIndex];
109
+ out[dstIndex + 1] = rgba[srcIndex + 1];
110
+ out[dstIndex + 2] = rgba[srcIndex + 2];
111
+ out[dstIndex + 3] = rgba[srcIndex + 3];
112
+ }
113
+ }
114
+
115
+ return { width: outWidth, height: outHeight, rgba: out };
116
+ }
117
+
118
+ /**
119
+ * Map one source pixel coordinate through an EXIF orientation transform.
120
+ *
121
+ * @param {number} x source x
122
+ * @param {number} y source y
123
+ * @param {number} width source width
124
+ * @param {number} height source height
125
+ * @param {number} orientation an EXIF orientation value, `2..8`
126
+ * @returns {[number, number]} the destination `[x, y]` coordinate
127
+ */
128
+ function mapOrientedCoordinate(x, y, width, height, orientation) {
129
+ switch (orientation) {
130
+ case 2:
131
+ return [width - 1 - x, y]; // flip horizontal
132
+ case 3:
133
+ return [width - 1 - x, height - 1 - y]; // rotate 180
134
+ case 4:
135
+ return [x, height - 1 - y]; // flip vertical
136
+ case 5:
137
+ return [y, x]; // transpose
138
+ case 6:
139
+ return [height - 1 - y, x]; // rotate 90 CW
140
+ case 7:
141
+ return [height - 1 - y, width - 1 - x]; // transverse
142
+ case 8:
143
+ return [y, width - 1 - x]; // rotate 270 CW
144
+ default:
145
+ return [x, y];
146
+ }
147
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Media-type sniffing from decoded bytes, never from a file extension or a
3
+ * caller-declared media type (brief S2 section 3: "Establish media type from
4
+ * decoded bytes, not extension").
5
+ *
6
+ * SVG is deliberately excluded from this sniffer's admitted output set: "SVG
7
+ * is never processed as author media" (brief S2 section 3). Any byte
8
+ * sequence recognizable as SVG/XML is classified `'svg'` here specifically
9
+ * so the caller can reject it with a distinct, explicit
10
+ * `MEDIA_SVG_REJECTED` reason rather than falling through to a generic
11
+ * "unrecognized format" error.
12
+ */
13
+
14
+ const PNG_SIGNATURE = Buffer.from([
15
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
16
+ ]);
17
+
18
+ /**
19
+ * @param {Buffer} bytes candidate file bytes
20
+ * @returns {boolean} true when `bytes` starts with the exact PNG signature
21
+ */
22
+ function isPng(bytes) {
23
+ return bytes.length >= 8 && bytes.subarray(0, 8).equals(PNG_SIGNATURE);
24
+ }
25
+
26
+ /**
27
+ * @param {Buffer} bytes candidate file bytes
28
+ * @returns {boolean} true when `bytes` starts with the JPEG SOI marker and a
29
+ * second marker byte `0xff`
30
+ */
31
+ function isJpeg(bytes) {
32
+ return (
33
+ bytes.length >= 4 &&
34
+ bytes[0] === 0xff &&
35
+ bytes[1] === 0xd8 &&
36
+ bytes[2] === 0xff
37
+ );
38
+ }
39
+
40
+ /**
41
+ * @param {Buffer} bytes candidate file bytes
42
+ * @returns {boolean} true when `bytes` is a `RIFF....WEBP` container
43
+ */
44
+ function isWebp(bytes) {
45
+ return (
46
+ bytes.length >= 12 &&
47
+ bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
48
+ bytes.subarray(8, 12).toString('ascii') === 'WEBP'
49
+ );
50
+ }
51
+
52
+ /**
53
+ * @param {Buffer} bytes candidate file bytes
54
+ * @returns {boolean} true when `bytes` starts with an ISOBMFF `ftyp` box
55
+ * whose major brand is `avif`
56
+ */
57
+ function isAvif(bytes) {
58
+ if (bytes.length < 12) return false;
59
+ if (bytes.subarray(4, 8).toString('ascii') !== 'ftyp') return false;
60
+ return bytes.subarray(8, 12).toString('ascii') === 'avif';
61
+ }
62
+
63
+ /**
64
+ * Detect whether `bytes` looks like SVG/XML markup, scanning only the first
65
+ * 4096 bytes (bounded: this classification exists purely to reject, never to
66
+ * parse). A byte-order mark, XML declaration, comment or a bare `<svg` root
67
+ * element are all treated as SVG for rejection purposes; this is
68
+ * intentionally over-inclusive.
69
+ *
70
+ * @param {Buffer} bytes candidate file bytes
71
+ * @returns {boolean} true when `bytes` looks like SVG/XML markup
72
+ */
73
+ function looksLikeSvg(bytes) {
74
+ const head = bytes.subarray(0, 4096).toString('utf8').trimStart();
75
+ return (
76
+ head.startsWith('') ||
77
+ head.startsWith('<?xml') ||
78
+ head.startsWith('<!--') ||
79
+ head.startsWith('<svg') ||
80
+ /<svg[\s>]/i.test(head.slice(0, 512))
81
+ );
82
+ }
83
+
84
+ /**
85
+ * @typedef {'png' | 'jpeg' | 'webp' | 'avif' | 'svg' | 'unknown'} SniffedFormat
86
+ */
87
+
88
+ /**
89
+ * Classify raw file bytes into a decoded-byte-derived format label.
90
+ *
91
+ * @param {Buffer} bytes raw candidate file bytes
92
+ * @returns {SniffedFormat} the sniffed format
93
+ */
94
+ export function sniffMediaFormat(bytes) {
95
+ if (looksLikeSvg(bytes)) return 'svg';
96
+ if (isPng(bytes)) return 'png';
97
+ if (isJpeg(bytes)) return 'jpeg';
98
+ if (isWebp(bytes)) return 'webp';
99
+ if (isAvif(bytes)) return 'avif';
100
+ return 'unknown';
101
+ }
102
+
103
+ /** @type {Readonly<Record<'png' | 'jpeg' | 'webp' | 'avif', string>>} */
104
+ export const MEDIA_TYPE_BY_FORMAT = Object.freeze({
105
+ png: 'image/png',
106
+ jpeg: 'image/jpeg',
107
+ webp: 'image/webp',
108
+ avif: 'image/avif',
109
+ });