@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.
- package/LICENSE +201 -0
- package/README.md +515 -0
- package/contracts/render-policy.jcs +1 -0
- package/contracts/theme-styling-contract.jcs +1 -0
- package/package.json +96 -0
- package/src/core/errors.js +136 -0
- package/src/core/index.js +758 -0
- package/src/core/internal/appearance/bootstrap-script.js +159 -0
- package/src/core/internal/appearance/contract.js +193 -0
- package/src/core/internal/appearance/controller-markup.js +80 -0
- package/src/core/internal/appearance/styling-contract.js +509 -0
- package/src/core/internal/canonical-jcs.js +0 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +220 -0
- package/src/core/internal/feeds.js +161 -0
- package/src/core/internal/fs-walk.js +38 -0
- package/src/core/internal/media/avif-probe.js +169 -0
- package/src/core/internal/media/font-policy.js +56 -0
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +129 -0
- package/src/core/internal/media/pipeline.js +353 -0
- package/src/core/internal/media/png-codec.js +406 -0
- package/src/core/internal/media/resize.js +147 -0
- package/src/core/internal/media/sniff.js +109 -0
- package/src/core/internal/media/webp-probe.js +139 -0
- package/src/core/internal/messages.js +78 -0
- package/src/core/internal/page-kinds.js +709 -0
- package/src/core/internal/provenance.js +49 -0
- package/src/core/internal/render-policy-content.js +295 -0
- package/src/core/internal/route-labels.js +68 -0
- package/src/core/internal/route.js +167 -0
- package/src/core/internal/search-index.js +119 -0
- package/src/core/internal/seo.js +114 -0
- package/src/core/internal/sitemap.js +77 -0
- package/src/core/internal/skeleton.js +413 -0
- package/src/core/internal/source-inventory.js +145 -0
- package/src/core/internal/text-direction.js +59 -0
- package/src/core/internal/theme-assets.js +387 -0
- package/src/core/internal/uuid.js +51 -0
- package/src/core/manifest.js +0 -0
- package/types/eleventy-plugin-syntaxhighlight.d.ts +26 -0
- package/types/eleventy.d.ts +38 -0
- 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
|
+
});
|