safe-upload 0.0.0-stage → 0.1.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/dist/jpeg.js ADDED
@@ -0,0 +1,374 @@
1
+ import { concat, latin1, putU16be, startsWith, u16be, u32be } from './bytes.js';
2
+ import { Collector } from './collect.js';
3
+ import { buildTiff, EXIF_HEADER, parseTiff, tiffCategories } from './exif.js';
4
+ import { analyzeIrb } from './iptc.js';
5
+ import { analyzeXmp } from './xmp.js';
6
+ const XMP_ID = 'http://ns.adobe.com/xap/1.0/\0';
7
+ const XMP_EXT_ID = 'http://ns.adobe.com/xmp/extension/\0';
8
+ function kindOf(marker, p) {
9
+ if (marker === 0xfe)
10
+ return 'com';
11
+ if (marker === 0xe0)
12
+ return startsWith(p, 0, 'JFIF\0') ? 'jfif' : startsWith(p, 0, 'JFXX\0') ? 'jfxx' : 'app';
13
+ if (marker === 0xe1) {
14
+ if (startsWith(p, 0, 'Exif\0'))
15
+ return 'exif';
16
+ if (startsWith(p, 0, XMP_ID))
17
+ return 'xmp';
18
+ if (startsWith(p, 0, XMP_EXT_ID))
19
+ return 'xmpext';
20
+ return 'app';
21
+ }
22
+ if (marker === 0xe2) {
23
+ if (startsWith(p, 0, 'ICC_PROFILE\0'))
24
+ return 'icc';
25
+ if (startsWith(p, 0, 'MPF\0'))
26
+ return 'mpf';
27
+ return 'app';
28
+ }
29
+ if (marker === 0xeb)
30
+ return 'jumbf';
31
+ if (marker === 0xed)
32
+ return 'iptc';
33
+ if (marker === 0xee && startsWith(p, 0, 'Adobe'))
34
+ return 'adobe';
35
+ if (marker >= 0xe0 && marker <= 0xef)
36
+ return 'app';
37
+ return 'image';
38
+ }
39
+ export function isJpeg(b) {
40
+ return b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff;
41
+ }
42
+ /** Find the end (offset after EOI) of a JPEG starting at `from`, scanning through entropy-coded data. */
43
+ export function findJpegEnd(b, from) {
44
+ let p = from + 2;
45
+ while (p + 4 <= b.length) {
46
+ if (b[p] !== 0xff) {
47
+ p++;
48
+ continue;
49
+ }
50
+ const m = b[p + 1];
51
+ if (m === 0xff) {
52
+ p++;
53
+ continue;
54
+ }
55
+ if (m === 0xd9)
56
+ return p + 2;
57
+ if (m === 0x00 || (m >= 0xd0 && m <= 0xd7) || m === 0x01) {
58
+ p += 2;
59
+ continue;
60
+ }
61
+ // A marker segment with length; skip it (inside scans this only happens between scans).
62
+ const len = u16be(b, p + 2);
63
+ if (m === 0xda) {
64
+ // Start of scan: skip header, then scan entropy data for the next real marker.
65
+ p += 2 + len;
66
+ while (p + 1 < b.length) {
67
+ if (b[p] === 0xff) {
68
+ const n = b[p + 1];
69
+ if (n !== 0x00 && !(n >= 0xd0 && n <= 0xd7) && n !== 0xff)
70
+ break;
71
+ }
72
+ p++;
73
+ }
74
+ continue;
75
+ }
76
+ p += 2 + len;
77
+ }
78
+ return b.length;
79
+ }
80
+ export function parseJpeg(b) {
81
+ const head = [];
82
+ let p = 2;
83
+ let width;
84
+ let height;
85
+ while (p + 4 <= b.length) {
86
+ if (b[p] !== 0xff)
87
+ throw new Error(`Corrupt JPEG: expected a marker at byte ${p}`);
88
+ const marker = b[p + 1];
89
+ if (marker === 0xff) {
90
+ p++;
91
+ continue;
92
+ }
93
+ if (marker === 0xda || marker === 0xd9)
94
+ break;
95
+ if ((marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
96
+ p += 2;
97
+ continue;
98
+ }
99
+ const len = u16be(b, p + 2);
100
+ if (len < 2 || p + 2 + len > b.length)
101
+ throw new Error('Corrupt JPEG: segment runs past the end of the file');
102
+ const payload = b.subarray(p + 4, p + 2 + len);
103
+ const kind = kindOf(marker, payload);
104
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc && payload.length >= 5) {
105
+ height = u16be(payload, 1);
106
+ width = u16be(payload, 3);
107
+ }
108
+ head.push({ marker, start: p, end: p + 2 + len, payload, kind });
109
+ p += 2 + len;
110
+ }
111
+ const scanStart = p;
112
+ const imageEnd = findJpegEnd(b, 0);
113
+ return { head, scanStart, imageEnd, trailer: b.subarray(imageEnd), width, height };
114
+ }
115
+ /** Image types declared in a CIPA DC-007 multi-picture (MPF) index. */
116
+ const MP_TYPES = {
117
+ 0x010001: 'Large preview copy of the picture (VGA)',
118
+ 0x010002: 'Large preview copy of the picture (Full HD)',
119
+ 0x010003: 'Large preview copy of the picture (4K)',
120
+ 0x010004: 'Large preview copy of the picture (8K)',
121
+ 0x010005: 'Large preview copy of the picture (16K)',
122
+ 0x020001: 'Panorama frame',
123
+ 0x020002: 'Stereo (disparity) image',
124
+ 0x020003: 'Multi-angle image',
125
+ 0x040000: 'Original, unedited image',
126
+ 0x050000: 'HDR gain map',
127
+ };
128
+ /**
129
+ * Read the MPF index in an APP2 segment and return a label for each extra image, keyed by its
130
+ * absolute offset in the file. Offsets in the index count from the MPF TIFF header.
131
+ */
132
+ export function mpfLabels(seg) {
133
+ const out = new Map();
134
+ const tiff = parseTiff(seg.payload.subarray(4));
135
+ const entry = tiff?.ifds.ifd0?.find((e) => e.tag === 0xb002);
136
+ if (!tiff || !entry)
137
+ return out;
138
+ const v = entry.value;
139
+ const r32 = (o) => (tiff.le ? (v[o] | (v[o + 1] << 8) | (v[o + 2] << 16) | (v[o + 3] << 24)) >>> 0 : u32be(v, o));
140
+ const tiffAbs = seg.start + 4 + 4;
141
+ for (let o = 0; o + 16 <= v.length; o += 16) {
142
+ const type = r32(o) & 0xffffff;
143
+ const offset = r32(o + 8);
144
+ if (offset === 0)
145
+ continue; // the main image
146
+ out.set(tiffAbs + offset, MP_TYPES[type] ?? 'Extra image (for example an HDR gain map, depth map or preview)');
147
+ }
148
+ return out;
149
+ }
150
+ /** Split bytes after the main image into recognisable items (extra JPEGs, MP4/MOV video, vendor data). */
151
+ export function splitTrailer(trailer, motionVideoLength, labelAt) {
152
+ const items = [];
153
+ const n = trailer.length;
154
+ // Google motion photos declare the video length counted from the end of the file.
155
+ const videoStart = motionVideoLength && motionVideoLength <= n ? n - motionVideoLength : -1;
156
+ const isBox = (q) => q + 12 <= n && startsWith(trailer, q + 4, 'ftyp') && u32be(trailer, q) >= 8 && u32be(trailer, q) < 4096;
157
+ let p = 0;
158
+ while (p < n) {
159
+ if (videoStart >= 0 && p >= videoStart) {
160
+ items.push({ kind: 'video', mime: 'video/mp4', label: 'Motion photo video', bytes: trailer.subarray(videoStart) });
161
+ return items;
162
+ }
163
+ if (isBox(p)) {
164
+ const brand = latin1(trailer, p + 8, p + 12);
165
+ const mime = brand === 'qt ' ? 'video/quicktime' : 'video/mp4';
166
+ items.push({ kind: 'video', mime, label: 'Embedded video (motion or live photo)', bytes: trailer.subarray(p) });
167
+ return items;
168
+ }
169
+ if (isJpeg(trailer.subarray(p, p + 4))) {
170
+ const end = findJpegEnd(trailer, p);
171
+ const limit = videoStart >= 0 ? Math.min(end, videoStart) : end;
172
+ items.push({ kind: 'image', mime: 'image/jpeg', label: labelAt?.(p) ?? 'Extra image (for example an HDR gain map, depth map or preview)', bytes: trailer.subarray(p, limit) });
173
+ p = limit;
174
+ continue;
175
+ }
176
+ if (trailer[p] === 0) {
177
+ p++; // padding
178
+ continue;
179
+ }
180
+ // Unknown data: run until a later video box or JPEG start.
181
+ let q = p + 1;
182
+ while (q < n && !isBox(q) && !(trailer[q] === 0xff && trailer[q + 1] === 0xd8 && trailer[q + 2] === 0xff) && !(videoStart >= 0 && q >= videoStart))
183
+ q++;
184
+ const samsung = q >= 4 && startsWith(trailer, q - 4, 'SEFT');
185
+ items.push({ kind: 'data', mime: 'application/octet-stream', label: samsung ? 'Samsung extra data' : 'Unrecognised data after the image', bytes: trailer.subarray(p, q) });
186
+ p = q;
187
+ }
188
+ return items;
189
+ }
190
+ export function inspectJpeg(b, c) {
191
+ const j = parseJpeg(b);
192
+ c.report.width = j.width;
193
+ c.report.height = j.height;
194
+ let motionVideoLength;
195
+ let comIndex = 0;
196
+ let mpf = new Map();
197
+ for (const s of j.head) {
198
+ switch (s.kind) {
199
+ case 'exif':
200
+ c.tiff(s.payload.subarray(6), 'exif');
201
+ break;
202
+ case 'xmp': {
203
+ const a = analyzeXmp(new TextDecoder().decode(s.payload.subarray(XMP_ID.length)));
204
+ if (a.motionVideoLength)
205
+ motionVideoLength = a.motionVideoLength;
206
+ c.xmp(s.payload.subarray(XMP_ID.length), 'xmp');
207
+ break;
208
+ }
209
+ case 'xmpext':
210
+ c.add({ category: 'other', source: 'xmp', key: 'xmp.extended', label: 'Extended XMP block', value: `${s.payload.length.toLocaleString('en')} bytes`, removable: true });
211
+ break;
212
+ case 'iptc':
213
+ c.irb(s.payload, 'iptc');
214
+ break;
215
+ case 'mpf':
216
+ mpf = mpfLabels(s);
217
+ c.add({ category: 'hidden', source: 'jpeg', key: 'jpeg.MPF', label: 'Multi-picture index', value: `The file declares ${mpf.size || 'some'} extra picture${mpf.size === 1 ? '' : 's'} stored after the main image`, removable: true });
218
+ break;
219
+ case 'jumbf':
220
+ c.add({ category: 'provenance', source: 'jpeg', key: 'jpeg.JUMBF', label: 'Content credentials (C2PA/JUMBF)', value: `${s.payload.length.toLocaleString('en')} bytes`, removable: true });
221
+ break;
222
+ case 'com': {
223
+ const text = new TextDecoder().decode(s.payload).replace(/\0+$/, '');
224
+ c.add({ category: 'descriptions', source: 'jpeg', key: `jpeg.Comment${comIndex++ || ''}`, label: 'JPEG comment', value: text.length > 300 ? text.slice(0, 300) + '…' : text, removable: true });
225
+ break;
226
+ }
227
+ case 'jfxx':
228
+ c.add({ category: 'thumbnail', source: 'jpeg', key: 'jpeg.JFXX', label: 'JFIF thumbnail', value: `${s.payload.length.toLocaleString('en')} bytes`, removable: true });
229
+ break;
230
+ case 'app': {
231
+ const id = latin1(s.payload, 0, Math.min(16, s.payload.length)).split('\0')[0].replace(/[^\x20-\x7e]/g, '');
232
+ c.add({ category: 'other', source: 'jpeg', key: `jpeg.APP${s.marker - 0xe0}`, label: `APP${s.marker - 0xe0} block${id ? ` "${id}"` : ''}`, value: `${s.payload.length.toLocaleString('en')} bytes`, removable: true });
233
+ break;
234
+ }
235
+ }
236
+ }
237
+ if (j.trailer.length) {
238
+ const items = splitTrailer(j.trailer, motionVideoLength, (rel) => mpf.get(j.imageEnd + rel));
239
+ const meaningful = items.filter((i) => i.bytes.length > 0);
240
+ for (const h of meaningful) {
241
+ c.hidden(h);
242
+ c.add({ category: 'hidden', source: 'jpeg', key: `jpeg.trailer.${h.kind}`, label: h.label, value: `${h.bytes.length.toLocaleString('en')} bytes after the end of the picture`, removable: true });
243
+ }
244
+ }
245
+ }
246
+ function segment(marker, payload) {
247
+ const out = new Uint8Array(4 + payload.length);
248
+ out[0] = 0xff;
249
+ out[1] = marker;
250
+ putU16be(out, 2, payload.length + 2);
251
+ out.set(payload, 4);
252
+ return out;
253
+ }
254
+ /** Remove metadata categories from a JPEG without touching the compressed picture. */
255
+ export function stripJpeg(b, remove, exifOverride) {
256
+ const j = parseJpeg(b);
257
+ const removed = new Set();
258
+ const notes = [];
259
+ const parts = [b.subarray(0, 2)];
260
+ // Decide on XMP as a whole: remove it if any category it carries is being removed.
261
+ const xmpSegs = j.head.filter((s) => s.kind === 'xmp');
262
+ let dropXmp = false;
263
+ let motionVideoLength;
264
+ for (const s of xmpSegs) {
265
+ const a = analyzeXmp(new TextDecoder().decode(s.payload.subarray(XMP_ID.length)));
266
+ if (a.motionVideoLength)
267
+ motionVideoLength = a.motionVideoLength;
268
+ const hits = [...a.categories].filter((c) => remove.has(c));
269
+ if (hits.length) {
270
+ dropXmp = true;
271
+ hits.forEach((c) => removed.add(c));
272
+ }
273
+ }
274
+ const trailerItems = j.trailer.length ? splitTrailer(j.trailer, motionVideoLength).filter((i) => i.bytes.length) : [];
275
+ const dropTrailer = trailerItems.length > 0 && remove.has('hidden');
276
+ if (dropTrailer) {
277
+ removed.add('hidden');
278
+ if (xmpSegs.length && !dropXmp) {
279
+ // XMP would point at media that no longer exists; drop it too.
280
+ for (const s of xmpSegs) {
281
+ const a = analyzeXmp(new TextDecoder().decode(s.payload.subarray(XMP_ID.length)));
282
+ if (a.categories.has('hidden'))
283
+ dropXmp = true;
284
+ }
285
+ }
286
+ }
287
+ if (dropXmp && xmpSegs.length)
288
+ notes.push('The XMP metadata block was removed as a whole.');
289
+ for (const s of j.head) {
290
+ const raw = b.subarray(s.start, s.end);
291
+ switch (s.kind) {
292
+ case 'exif': {
293
+ const tiff = parseTiff(s.payload.subarray(6));
294
+ if (!tiff) {
295
+ if (remove.has('other'))
296
+ removed.add('other');
297
+ else
298
+ parts.push(raw);
299
+ break;
300
+ }
301
+ const present = tiffCategories(tiff);
302
+ const needs = [...present].some((c) => remove.has(c)) || exifOverride !== undefined || tiff.ifds.exif?.some((e) => e.tag === 0x927c && remove.has('device'));
303
+ if (!needs) {
304
+ parts.push(raw);
305
+ break;
306
+ }
307
+ const r = buildTiff(tiff, { remove, orientation: exifOverride?.orientation, dimensions: exifOverride?.dimensions });
308
+ r.removed.forEach((c) => removed.add(c));
309
+ notes.push(...r.notes);
310
+ if (r.bytes) {
311
+ const payload = concat([EXIF_HEADER, r.bytes]);
312
+ if (payload.length + 2 > 0xffff)
313
+ throw new Error('Rewritten EXIF block is too large for a JPEG segment');
314
+ parts.push(segment(0xe1, payload));
315
+ }
316
+ break;
317
+ }
318
+ case 'xmp':
319
+ case 'xmpext':
320
+ if (dropXmp)
321
+ break;
322
+ parts.push(raw);
323
+ break;
324
+ case 'iptc': {
325
+ const a = analyzeIrb(s.payload, 'iptc');
326
+ const xmpCats = a.xmp ? analyzeXmp(new TextDecoder().decode(a.xmp)).categories : new Set();
327
+ const cats = new Set([...a.categories, ...xmpCats]);
328
+ const hits = [...cats].filter((c) => remove.has(c));
329
+ if (hits.length) {
330
+ hits.forEach((c) => removed.add(c));
331
+ notes.push('The IPTC/Photoshop block was removed as a whole.');
332
+ }
333
+ else
334
+ parts.push(raw);
335
+ break;
336
+ }
337
+ case 'mpf':
338
+ if (dropTrailer)
339
+ break;
340
+ parts.push(raw);
341
+ break;
342
+ case 'jumbf':
343
+ if (remove.has('provenance'))
344
+ removed.add('provenance');
345
+ else
346
+ parts.push(raw);
347
+ break;
348
+ case 'com':
349
+ if (remove.has('descriptions'))
350
+ removed.add('descriptions');
351
+ else
352
+ parts.push(raw);
353
+ break;
354
+ case 'jfxx':
355
+ if (remove.has('thumbnail'))
356
+ removed.add('thumbnail');
357
+ else
358
+ parts.push(raw);
359
+ break;
360
+ case 'app':
361
+ if (remove.has('other'))
362
+ removed.add('other');
363
+ else
364
+ parts.push(raw);
365
+ break;
366
+ default:
367
+ parts.push(raw); // JFIF, ICC, Adobe, DQT, DHT, SOF, DRI ...
368
+ }
369
+ }
370
+ parts.push(b.subarray(j.scanStart, j.imageEnd));
371
+ if (j.trailer.length && !dropTrailer)
372
+ parts.push(j.trailer);
373
+ return { bytes: concat(parts), removed, retained: [], notes };
374
+ }
package/dist/png.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { Collector } from './collect.js';
2
+ import type { FormatStrip } from './jpeg.js';
3
+ import type { Category } from './types.js';
4
+ export declare const PNG_SIG: Uint8Array<ArrayBuffer>;
5
+ export declare function isPng(b: Uint8Array): boolean;
6
+ export declare function inspectPng(b: Uint8Array, c: Collector): Promise<void>;
7
+ export declare function stripPng(b: Uint8Array, remove: Set<Category>): Promise<FormatStrip>;
8
+ /** Insert an eXIf chunk right after IHDR (used after re-encoding). */
9
+ export declare function insertPngExif(png: Uint8Array, tiff: Uint8Array): Uint8Array;
package/dist/png.js ADDED
@@ -0,0 +1,245 @@
1
+ import { concat, crc32, encodeAscii, inflate, latin1, putU32be, startsWith, u32be, utf8 } from './bytes.js';
2
+ import { Collector } from './collect.js';
3
+ import { buildTiff, EXIF_HEADER, parseTiff, tiffCategories } from './exif.js';
4
+ import { analyzeIrb } from './iptc.js';
5
+ import { analyzeXmp } from './xmp.js';
6
+ export const PNG_SIG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
7
+ export function isPng(b) {
8
+ return b.length >= 8 && PNG_SIG.every((v, i) => b[i] === v);
9
+ }
10
+ function chunks(b) {
11
+ const out = [];
12
+ let p = 8;
13
+ while (p + 12 <= b.length) {
14
+ const len = u32be(b, p);
15
+ const type = latin1(b, p + 4, p + 8);
16
+ const end = p + 12 + len;
17
+ if (end > b.length)
18
+ throw new Error('Corrupt PNG: chunk runs past the end of the file');
19
+ out.push({ type, start: p, end, data: b.subarray(p + 8, p + 8 + len) });
20
+ p = end;
21
+ if (type === 'IEND')
22
+ break;
23
+ }
24
+ return out;
25
+ }
26
+ /** Text keywords written by image generators (Automatic1111, ComfyUI, InvokeAI, NovelAI, Midjourney exports). */
27
+ const AI_KEYS = /^(parameters|prompt|workflow|negative_prompt|sd-metadata|invokeai_metadata|invokeai_graph|dream|generation_data|comfy.*|novelai.*|ai_metadata|aigc)$/i;
28
+ function textCategory(key) {
29
+ if (AI_KEYS.test(key))
30
+ return 'ai';
31
+ const k = key.toLowerCase();
32
+ if (k === 'author' || k === 'copyright' || k === 'artist')
33
+ return 'author';
34
+ if (k === 'title' || k === 'description' || k === 'comment' || k === 'disclaimer' || k === 'warning' || k === 'keywords')
35
+ return 'descriptions';
36
+ if (k === 'software')
37
+ return 'software';
38
+ if (k === 'creation time' || k === 'date:create' || k === 'date:modify' || k === 'date:timestamp')
39
+ return 'time';
40
+ if (k === 'source')
41
+ return 'device';
42
+ return 'other';
43
+ }
44
+ async function readText(c) {
45
+ const d = c.data;
46
+ const nul = d.indexOf(0);
47
+ if (nul < 0)
48
+ return null;
49
+ const key = latin1(d, 0, nul);
50
+ try {
51
+ if (c.type === 'tEXt')
52
+ return { key, text: latin1(d, nul + 1) };
53
+ if (c.type === 'zTXt')
54
+ return { key, text: latin1(await inflate(d.subarray(nul + 2))) };
55
+ if (c.type === 'iTXt') {
56
+ const compressed = d[nul + 1] === 1;
57
+ let p = nul + 3;
58
+ const langEnd = d.indexOf(0, p);
59
+ p = langEnd + 1;
60
+ const transEnd = d.indexOf(0, p);
61
+ p = transEnd + 1;
62
+ const body = d.subarray(p);
63
+ return { key, text: utf8(compressed ? await inflate(body) : body) };
64
+ }
65
+ }
66
+ catch {
67
+ return { key, text: '' };
68
+ }
69
+ return null;
70
+ }
71
+ /** ImageMagick stores EXIF/XMP/IPTC as hex in "Raw profile type …" text chunks. */
72
+ function rawProfile(text) {
73
+ const m = /^\s*\S+\s+(\d+)\s+([\s\S]*)$/.exec(text);
74
+ if (!m)
75
+ return null;
76
+ const hex = m[2].replace(/[^0-9a-f]/gi, '');
77
+ const n = Math.min(Number(m[1]), hex.length >> 1);
78
+ const out = new Uint8Array(n);
79
+ for (let i = 0; i < n; i++)
80
+ out[i] = parseInt(hex.substr(i * 2, 2), 16);
81
+ return out;
82
+ }
83
+ function tiffFromExifProfile(bytes) {
84
+ return startsWith(bytes, 0, 'Exif\0\0') ? bytes.subarray(6) : bytes;
85
+ }
86
+ const TEXT_TYPES = new Set(['tEXt', 'zTXt', 'iTXt']);
87
+ export async function inspectPng(b, c) {
88
+ for (const ch of chunks(b)) {
89
+ if (ch.type === 'IHDR' && ch.data.length >= 8) {
90
+ c.report.width = u32be(ch.data, 0);
91
+ c.report.height = u32be(ch.data, 4);
92
+ }
93
+ else if (ch.type === 'eXIf') {
94
+ c.tiff(tiffFromExifProfile(ch.data), 'exif');
95
+ }
96
+ else if (ch.type === 'tIME') {
97
+ const d = ch.data;
98
+ const v = `${(d[0] << 8) | d[1]}-${String(d[2]).padStart(2, '0')}-${String(d[3]).padStart(2, '0')} ${String(d[4]).padStart(2, '0')}:${String(d[5]).padStart(2, '0')}:${String(d[6]).padStart(2, '0')} UTC`;
99
+ c.add({ category: 'time', source: 'png', key: 'png.tIME', label: 'Last modified', value: v, removable: true });
100
+ }
101
+ else if (ch.type === 'caBX') {
102
+ c.add({ category: 'provenance', source: 'png', key: 'png.caBX', label: 'Content credentials (C2PA)', value: `${ch.data.length.toLocaleString('en')} bytes`, removable: true });
103
+ }
104
+ else if (TEXT_TYPES.has(ch.type)) {
105
+ const t = await readText(ch);
106
+ if (!t)
107
+ continue;
108
+ if (t.key === 'XML:com.adobe.xmp')
109
+ c.xmp(t.text, 'xmp');
110
+ else if (/^Raw profile type (exif|APP1)$/i.test(t.key)) {
111
+ const raw = rawProfile(t.text);
112
+ if (raw) {
113
+ if (startsWith(raw, 0, 'Exif\0\0') || raw[0] === 0x49 || raw[0] === 0x4d)
114
+ c.tiff(tiffFromExifProfile(raw), 'exif');
115
+ else
116
+ c.xmp(raw, 'xmp');
117
+ }
118
+ }
119
+ else if (/^Raw profile type xmp$/i.test(t.key)) {
120
+ const raw = rawProfile(t.text);
121
+ if (raw)
122
+ c.xmp(raw, 'xmp');
123
+ }
124
+ else if (/^Raw profile type (iptc|8bim)$/i.test(t.key)) {
125
+ const raw = rawProfile(t.text);
126
+ if (raw)
127
+ c.irb(raw, 'iptc');
128
+ }
129
+ else if (/^Raw profile type icc$/i.test(t.key)) {
130
+ // colour profile: technical, keep
131
+ }
132
+ else {
133
+ const cat = textCategory(t.key);
134
+ const value = t.text.length > 2000 ? t.text.slice(0, 2000) + '…' : t.text;
135
+ c.add({ category: cat, source: 'png', key: `png.text.${t.key}`, label: cat === 'ai' ? `AI generator data "${t.key}"` : `Text "${t.key}"`, value, removable: true });
136
+ if (cat === 'ai' && !c.report.summary.aiPrompt)
137
+ c.report.summary.aiPrompt = value;
138
+ if (cat === 'author')
139
+ c.report.summary.names.push(t.text);
140
+ if (cat === 'software')
141
+ c.report.summary.software.push(t.text);
142
+ }
143
+ }
144
+ else if (/^[a-z]/.test(ch.type) && !KNOWN_ANCILLARY.has(ch.type)) {
145
+ c.add({ category: 'other', source: 'png', key: `png.${ch.type}`, label: `Private chunk "${ch.type}"`, value: `${ch.data.length.toLocaleString('en')} bytes`, removable: true });
146
+ }
147
+ }
148
+ }
149
+ /** Ancillary chunks that only affect how pixels are displayed. */
150
+ const KNOWN_ANCILLARY = new Set(['tRNS', 'gAMA', 'cHRM', 'sRGB', 'iCCP', 'sBIT', 'bKGD', 'hIST', 'pHYs', 'sPLT', 'acTL', 'fcTL', 'fdAT', 'cICP', 'mDCv', 'cLLi', 'oFFs', 'pCAL', 'sCAL', 'sTER', 'gIFg', 'gIFx', 'vpAg', 'iDOT', 'gdAT', 'gmAP']);
151
+ function makeChunk(type, data) {
152
+ const out = new Uint8Array(12 + data.length);
153
+ putU32be(out, 0, data.length);
154
+ out.set(encodeAscii(type), 4);
155
+ out.set(data, 8);
156
+ putU32be(out, 8 + data.length, crc32(out, 4, 8 + data.length));
157
+ return out;
158
+ }
159
+ export async function stripPng(b, remove) {
160
+ const removed = new Set();
161
+ const notes = [];
162
+ const parts = [b.subarray(0, 8)];
163
+ const dropWhole = (cats) => {
164
+ const hits = [...cats].filter((c) => remove.has(c));
165
+ hits.forEach((c) => removed.add(c));
166
+ return hits.length > 0;
167
+ };
168
+ for (const ch of chunks(b)) {
169
+ const raw = b.subarray(ch.start, ch.end);
170
+ if (ch.type === 'eXIf') {
171
+ const tiff = parseTiff(tiffFromExifProfile(ch.data));
172
+ if (!tiff) {
173
+ if (!dropWhole(['other']))
174
+ parts.push(raw);
175
+ continue;
176
+ }
177
+ if (![...tiffCategories(tiff)].some((c) => remove.has(c))) {
178
+ parts.push(raw);
179
+ continue;
180
+ }
181
+ const r = buildTiff(tiff, { remove });
182
+ r.removed.forEach((c) => removed.add(c));
183
+ notes.push(...r.notes);
184
+ if (r.bytes)
185
+ parts.push(makeChunk('eXIf', r.bytes));
186
+ }
187
+ else if (ch.type === 'tIME') {
188
+ if (!dropWhole(['time']))
189
+ parts.push(raw);
190
+ }
191
+ else if (ch.type === 'caBX') {
192
+ if (!dropWhole(['provenance']))
193
+ parts.push(raw);
194
+ }
195
+ else if (TEXT_TYPES.has(ch.type)) {
196
+ const t = await readText(ch);
197
+ if (!t) {
198
+ if (!dropWhole(['other']))
199
+ parts.push(raw);
200
+ continue;
201
+ }
202
+ let cats;
203
+ if (t.key === 'XML:com.adobe.xmp')
204
+ cats = analyzeXmp(t.text).categories;
205
+ else if (/^Raw profile type (exif|APP1)$/i.test(t.key)) {
206
+ const raw2 = rawProfile(t.text);
207
+ const tiff = raw2 ? parseTiff(tiffFromExifProfile(raw2)) : null;
208
+ cats = tiff ? tiffCategories(tiff) : raw2 ? analyzeXmp(utf8(raw2)).categories : new Set(['other']);
209
+ }
210
+ else if (/^Raw profile type xmp$/i.test(t.key)) {
211
+ const raw2 = rawProfile(t.text);
212
+ cats = raw2 ? analyzeXmp(utf8(raw2)).categories : new Set(['other']);
213
+ }
214
+ else if (/^Raw profile type (iptc|8bim)$/i.test(t.key)) {
215
+ const raw2 = rawProfile(t.text);
216
+ cats = raw2 ? analyzeIrb(raw2, 'iptc').categories : new Set(['other']);
217
+ }
218
+ else if (/^Raw profile type icc$/i.test(t.key))
219
+ cats = new Set();
220
+ else
221
+ cats = new Set([textCategory(t.key)]);
222
+ if (cats.size && dropWhole(cats)) {
223
+ if (t.key.startsWith('XML:') || t.key.startsWith('Raw profile'))
224
+ notes.push(`The "${t.key}" block was removed as a whole.`);
225
+ continue;
226
+ }
227
+ parts.push(raw);
228
+ }
229
+ else if (/^[a-z]/.test(ch.type) && !KNOWN_ANCILLARY.has(ch.type)) {
230
+ if (!dropWhole(['other']))
231
+ parts.push(raw);
232
+ }
233
+ else
234
+ parts.push(raw);
235
+ }
236
+ return { bytes: concat(parts), removed, retained: [], notes: [...new Set(notes)] };
237
+ }
238
+ /** Insert an eXIf chunk right after IHDR (used after re-encoding). */
239
+ export function insertPngExif(png, tiff) {
240
+ const all = chunks(png);
241
+ const ihdr = all.find((c) => c.type === 'IHDR');
242
+ if (!ihdr)
243
+ return png;
244
+ return concat([png.subarray(0, ihdr.end), makeChunk('eXIf', tiff), png.subarray(ihdr.end)]);
245
+ }