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/exif.js ADDED
@@ -0,0 +1,507 @@
1
+ import { clean, latin1, toHex, utf8 } from './bytes.js';
2
+ import { classifyTag, POINTER_TAGS, tagDef } from './exif-tags.js';
3
+ /** Byte size of one value of each TIFF field type. */
4
+ const TYPE_SIZE = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 6: 1, 7: 1, 8: 2, 9: 4, 10: 8, 11: 4, 12: 8, 13: 4, 129: 1 };
5
+ export function parseTiff(b) {
6
+ if (b.length < 8)
7
+ return null;
8
+ const le = b[0] === 0x49 && b[1] === 0x49;
9
+ const be = b[0] === 0x4d && b[1] === 0x4d;
10
+ if (!le && !be)
11
+ return null;
12
+ const r16 = (o) => (le ? b[o] | (b[o + 1] << 8) : (b[o] << 8) | b[o + 1]);
13
+ const r32 = (o) => (le ? b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24) : (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0;
14
+ if (r16(2) !== 42)
15
+ return null;
16
+ const tiff = { le, ifds: {}, stripThumbnail: false, warnings: [] };
17
+ const seen = new Set();
18
+ const readIfd = (off) => {
19
+ if (off < 8 || off + 2 > b.length || seen.has(off))
20
+ return null;
21
+ seen.add(off);
22
+ let n = r16(off);
23
+ const maxN = Math.floor((b.length - off - 2) / 12);
24
+ if (n > maxN) {
25
+ tiff.warnings.push('Truncated IFD');
26
+ n = maxN;
27
+ }
28
+ const entries = [];
29
+ for (let i = 0; i < n; i++) {
30
+ const e = off + 2 + i * 12;
31
+ const tag = r16(e);
32
+ const type = r16(e + 2);
33
+ const count = r32(e + 4);
34
+ const size = TYPE_SIZE[type];
35
+ if (!size) {
36
+ tiff.warnings.push(`Unknown field type ${type} for tag ${toHex(tag)}`);
37
+ continue;
38
+ }
39
+ const total = size * count;
40
+ let value;
41
+ if (total <= 4)
42
+ value = b.subarray(e + 8, e + 8 + total);
43
+ else {
44
+ const vo = r32(e + 8);
45
+ if (vo + total > b.length) {
46
+ tiff.warnings.push(`Value of tag ${toHex(tag)} points outside the EXIF block`);
47
+ continue;
48
+ }
49
+ value = b.subarray(vo, vo + total);
50
+ }
51
+ entries.push({ tag, type, count, value });
52
+ }
53
+ const nextPos = off + 2 + n * 12;
54
+ const next = nextPos + 4 <= b.length ? r32(nextPos) : 0;
55
+ return { entries, next };
56
+ };
57
+ const ifd0 = readIfd(r32(4));
58
+ if (!ifd0)
59
+ return null;
60
+ tiff.ifds.ifd0 = ifd0.entries;
61
+ const ptr = (entries, tag) => {
62
+ const e = entries.find((x) => x.tag === tag);
63
+ if (!e || (e.type !== 4 && e.type !== 13) || e.count < 1)
64
+ return 0;
65
+ return readUint(e, le, 0);
66
+ };
67
+ const exifOff = ptr(ifd0.entries, POINTER_TAGS.exif);
68
+ const exif = exifOff ? readIfd(exifOff) : null;
69
+ if (exif)
70
+ tiff.ifds.exif = exif.entries;
71
+ const gpsOff = ptr(ifd0.entries, POINTER_TAGS.gps);
72
+ const gps = gpsOff ? readIfd(gpsOff) : null;
73
+ if (gps)
74
+ tiff.ifds.gps = gps.entries;
75
+ const interopOff = exif ? ptr(exif.entries, POINTER_TAGS.interop) : 0;
76
+ const interop = interopOff ? readIfd(interopOff) : null;
77
+ if (interop)
78
+ tiff.ifds.interop = interop.entries;
79
+ const ifd1 = ifd0.next ? readIfd(ifd0.next) : null;
80
+ if (ifd1 && ifd1.entries.length) {
81
+ tiff.ifds.ifd1 = ifd1.entries;
82
+ const tOff = ptr(ifd1.entries, 0x0201);
83
+ const tLen = ptr(ifd1.entries, 0x0202);
84
+ if (tOff && tLen && tOff + tLen <= b.length)
85
+ tiff.thumbnail = b.subarray(tOff, tOff + tLen);
86
+ if (ifd1.entries.some((e) => e.tag === 0x0111))
87
+ tiff.stripThumbnail = true;
88
+ }
89
+ return tiff;
90
+ }
91
+ // ---------------------------------------------------------------- value decoding
92
+ function readUint(e, le, i) {
93
+ const v = e.value;
94
+ if (e.type === 3 || e.type === 8) {
95
+ const o = i * 2;
96
+ const n = le ? v[o] | (v[o + 1] << 8) : (v[o] << 8) | v[o + 1];
97
+ return e.type === 8 && n & 0x8000 ? n - 0x10000 : n;
98
+ }
99
+ if (e.type === 4 || e.type === 9 || e.type === 13) {
100
+ const o = i * 4;
101
+ const n = (le ? v[o] | (v[o + 1] << 8) | (v[o + 2] << 16) | (v[o + 3] << 24) : (v[o] << 24) | (v[o + 1] << 16) | (v[o + 2] << 8) | v[o + 3]) >>> 0;
102
+ return e.type === 9 ? n | 0 : n;
103
+ }
104
+ if (e.type === 1 || e.type === 7)
105
+ return v[i];
106
+ if (e.type === 6)
107
+ return (v[i] << 24) >> 24;
108
+ return NaN;
109
+ }
110
+ function readRational(e, le, i) {
111
+ const o = i * 8;
112
+ const v = e.value;
113
+ const r = (p) => (le ? v[p] | (v[p + 1] << 8) | (v[p + 2] << 16) | (v[p + 3] << 24) : (v[p] << 24) | (v[p + 1] << 16) | (v[p + 2] << 8) | v[p + 3]);
114
+ let num = r(o) >>> 0;
115
+ let den = r(o + 4) >>> 0;
116
+ if (e.type === 10) {
117
+ num |= 0;
118
+ den |= 0;
119
+ }
120
+ return den === 0 ? NaN : num / den;
121
+ }
122
+ export function entryNumbers(e, le) {
123
+ const out = [];
124
+ const n = Math.min(e.count, 64);
125
+ for (let i = 0; i < n; i++) {
126
+ if (e.type === 5 || e.type === 10)
127
+ out.push(readRational(e, le, i));
128
+ else if (e.type === 11)
129
+ out.push(new DataView(e.value.buffer, e.value.byteOffset + i * 4, 4).getFloat32(0, le));
130
+ else if (e.type === 12)
131
+ out.push(new DataView(e.value.buffer, e.value.byteOffset + i * 8, 8).getFloat64(0, le));
132
+ else
133
+ out.push(readUint(e, le, i));
134
+ }
135
+ return out;
136
+ }
137
+ export function entryText(e) {
138
+ if (e.type === 129)
139
+ return clean(utf8(e.value));
140
+ return clean(latin1(e.value));
141
+ }
142
+ function ucs2(v) {
143
+ let s = '';
144
+ for (let i = 0; i + 1 < v.length; i += 2)
145
+ s += String.fromCharCode(v[i] | (v[i + 1] << 8));
146
+ return clean(s);
147
+ }
148
+ function userComment(v, le) {
149
+ if (v.length < 8)
150
+ return '';
151
+ const code = latin1(v, 0, 8);
152
+ const body = v.subarray(8);
153
+ if (code.startsWith('UNICODE')) {
154
+ let s = '';
155
+ for (let i = 0; i + 1 < body.length; i += 2)
156
+ s += String.fromCharCode(le ? body[i] | (body[i + 1] << 8) : (body[i] << 8) | body[i + 1]);
157
+ return clean(s);
158
+ }
159
+ return clean(code.startsWith('ASCII') ? latin1(body) : utf8(body));
160
+ }
161
+ const fmt = (n, d = 2) => (Number.isFinite(n) ? String(Math.round(n * 10 ** d) / 10 ** d) : '?');
162
+ function dms(e, le) {
163
+ const [d = NaN, m = 0, s = 0] = entryNumbers(e, le);
164
+ return d + m / 60 + s / 3600;
165
+ }
166
+ /** Human-readable value of an entry. */
167
+ export function formatEntry(ifd, e, le) {
168
+ const name = tagDef(ifd, e.tag)?.name;
169
+ if (name === 'MakerNote')
170
+ return `${e.value.length.toLocaleString('en')} bytes of vendor data`;
171
+ if (name === 'UserComment')
172
+ return userComment(e.value, le);
173
+ if (name?.startsWith('XP'))
174
+ return ucs2(e.value);
175
+ if (name === 'ExposureTime') {
176
+ const v = entryNumbers(e, le)[0];
177
+ return v > 0 && v < 1 ? `1/${Math.round(1 / v)} s` : `${fmt(v)} s`;
178
+ }
179
+ if (name === 'FNumber')
180
+ return `f/${fmt(entryNumbers(e, le)[0], 1)}`;
181
+ if (name === 'FocalLength')
182
+ return `${fmt(entryNumbers(e, le)[0], 1)} mm`;
183
+ if (ifd === 'gps' && (name === 'GPSLatitude' || name === 'GPSLongitude' || name === 'GPSDestLatitude' || name === 'GPSDestLongitude'))
184
+ return fmt(dms(e, le), 6) + '°';
185
+ if (ifd === 'gps' && name === 'GPSTimeStamp') {
186
+ const [h = 0, m = 0, s = 0] = entryNumbers(e, le);
187
+ return `${String(Math.floor(h)).padStart(2, '0')}:${String(Math.floor(m)).padStart(2, '0')}:${String(Math.floor(s)).padStart(2, '0')}`;
188
+ }
189
+ if (ifd === 'gps' && name === 'GPSAltitude')
190
+ return `${fmt(entryNumbers(e, le)[0], 1)} m`;
191
+ if (ifd === 'gps' && (name === 'GPSImgDirection' || name === 'GPSTrack' || name === 'GPSDestBearing'))
192
+ return `${fmt(entryNumbers(e, le)[0], 0)}°`;
193
+ if (ifd === 'gps' && name === 'GPSVersionID')
194
+ return Array.from(e.value).join('.');
195
+ if (e.type === 2 || e.type === 129)
196
+ return entryText(e);
197
+ if (e.type === 7) {
198
+ const printable = e.value.every((c) => c === 0 || (c >= 0x20 && c < 0x7f));
199
+ return printable && e.value.length < 128 ? entryText(e) : `${e.value.length.toLocaleString('en')} bytes`;
200
+ }
201
+ const nums = entryNumbers(e, le);
202
+ const s = nums.map((n) => fmt(n, 4)).join(', ');
203
+ return e.count > 64 ? s + ', …' : s;
204
+ }
205
+ // ---------------------------------------------------------------- analysis
206
+ /**
207
+ * GPS tags that say nothing on their own. Cameras without a GPS receiver (most DSLRs) still write
208
+ * a GPS block holding only these, so they must not be reported as a location.
209
+ */
210
+ const GPS_HOUSEKEEPING = new Set([0x0000, 0x0008, 0x0009, 0x000a, 0x000b, 0x0012, 0x001b, 0x001e]);
211
+ /** True when the GPS block carries an actual position, direction, destination or area. */
212
+ export function gpsInformative(tiff) {
213
+ return (tiff.ifds.gps ?? []).some((e) => !GPS_HOUSEKEEPING.has(e.tag));
214
+ }
215
+ /** Skip housekeeping-only GPS tags when there is no real GPS information. */
216
+ function silentGps(tiff, ifd, tag, informative) {
217
+ return ifd === 'gps' && !informative && GPS_HOUSEKEEPING.has(tag);
218
+ }
219
+ const NAMES = new Set(['Artist', 'XPAuthor', 'CameraOwnerName', 'Photographer', 'ImageEditor']);
220
+ const SERIALS = new Set(['BodySerialNumber', 'LensSerialNumber', 'CameraSerialNumber', 'ImageUniqueID']);
221
+ export function analyzeTiff(tiff, source, removable = true) {
222
+ const { le } = tiff;
223
+ const out = { findings: [], serialNumbers: [], names: [], software: [] };
224
+ const get = (ifd, tag) => tiff.ifds[ifd]?.find((e) => e.tag === tag);
225
+ const informative = gpsInformative(tiff);
226
+ for (const ifd of ['ifd0', 'exif', 'gps', 'interop', 'ifd1']) {
227
+ for (const e of tiff.ifds[ifd] ?? []) {
228
+ if (silentGps(tiff, ifd, e.tag, informative))
229
+ continue;
230
+ if (ifd !== 'ifd1' && (e.tag === POINTER_TAGS.exif || e.tag === POINTER_TAGS.gps || e.tag === POINTER_TAGS.interop))
231
+ continue;
232
+ const cls = classifyTag(ifd, e.tag);
233
+ if (cls === 'technical')
234
+ continue;
235
+ if (ifd === 'ifd1')
236
+ continue; // summarised below as one thumbnail finding
237
+ const def = tagDef(ifd, e.tag);
238
+ const name = def?.name ?? `Tag${toHex(e.tag)}`;
239
+ const value = formatEntry(ifd, e, le);
240
+ out.findings.push({
241
+ category: cls,
242
+ source,
243
+ key: `exif.${name}`,
244
+ label: def?.label ?? `Unknown ${ifd.toUpperCase()} tag ${toHex(e.tag)}`,
245
+ value: truncate(value),
246
+ removable,
247
+ });
248
+ if (NAMES.has(name) && value)
249
+ out.names.push(value);
250
+ if (SERIALS.has(name) && value)
251
+ out.serialNumbers.push(value);
252
+ if ((name === 'Software' || name.endsWith('Software')) && value)
253
+ out.software.push(value);
254
+ }
255
+ }
256
+ if (tiff.ifds.ifd1) {
257
+ out.findings.push({
258
+ category: 'thumbnail',
259
+ source,
260
+ key: 'exif.Thumbnail',
261
+ label: 'Embedded thumbnail',
262
+ value: tiff.thumbnail ? `${tiff.thumbnail.length.toLocaleString('en')} byte JPEG preview` : 'Preview image directory',
263
+ removable,
264
+ });
265
+ if (tiff.thumbnail)
266
+ out.thumbnail = tiff.thumbnail;
267
+ }
268
+ // Summaries
269
+ const lat = get('gps', 0x0002);
270
+ const lon = get('gps', 0x0004);
271
+ if (lat && lon) {
272
+ const latRef = get('gps', 0x0001);
273
+ const lonRef = get('gps', 0x0003);
274
+ let latitude = dms(lat, le);
275
+ let longitude = dms(lon, le);
276
+ if (latRef && entryText(latRef).toUpperCase().startsWith('S'))
277
+ latitude = -latitude;
278
+ if (lonRef && entryText(lonRef).toUpperCase().startsWith('W'))
279
+ longitude = -longitude;
280
+ if (Number.isFinite(latitude) && Number.isFinite(longitude) && !(latitude === 0 && longitude === 0)) {
281
+ const gps = { latitude, longitude };
282
+ const alt = get('gps', 0x0006);
283
+ if (alt) {
284
+ const ref = get('gps', 0x0005);
285
+ const a = entryNumbers(alt, le)[0];
286
+ if (Number.isFinite(a))
287
+ gps.altitude = ref && ref.value[0] === 1 ? -a : a;
288
+ }
289
+ const dir = get('gps', 0x0011);
290
+ if (dir) {
291
+ const d = entryNumbers(dir, le)[0];
292
+ if (Number.isFinite(d))
293
+ gps.direction = d;
294
+ }
295
+ out.gps = gps;
296
+ }
297
+ }
298
+ const taken = get('exif', 0x9003) ?? get('exif', 0x9004) ?? get('ifd0', 0x0132);
299
+ if (taken)
300
+ out.capturedAt = entryText(taken);
301
+ const tz = get('exif', 0x9011) ?? get('exif', 0x9010);
302
+ if (tz)
303
+ out.timeZone = entryText(tz);
304
+ const make = get('ifd0', 0x010f);
305
+ const model = get('ifd0', 0x0110);
306
+ const makeS = make ? entryText(make) : '';
307
+ const modelS = model ? entryText(model) : '';
308
+ if (makeS || modelS)
309
+ out.device = modelS.toLowerCase().startsWith(makeS.toLowerCase()) ? modelS : `${makeS} ${modelS}`.trim();
310
+ const orient = get('ifd0', 0x0112);
311
+ if (orient)
312
+ out.orientation = entryNumbers(orient, le)[0];
313
+ const w = get('exif', 0xa002);
314
+ const h = get('exif', 0xa003);
315
+ if (w && h) {
316
+ out.width = entryNumbers(w, le)[0];
317
+ out.height = entryNumbers(h, le)[0];
318
+ }
319
+ return out;
320
+ }
321
+ export function tiffCategories(tiff) {
322
+ const s = new Set();
323
+ const informative = gpsInformative(tiff);
324
+ for (const ifd of ['ifd0', 'exif', 'gps', 'interop', 'ifd1']) {
325
+ for (const e of tiff.ifds[ifd] ?? []) {
326
+ if (silentGps(tiff, ifd, e.tag, informative))
327
+ continue;
328
+ if (ifd !== 'ifd1' && (e.tag === POINTER_TAGS.exif || e.tag === POINTER_TAGS.gps || e.tag === POINTER_TAGS.interop))
329
+ continue;
330
+ const c = classifyTag(ifd, e.tag);
331
+ if (c !== 'technical')
332
+ s.add(c);
333
+ }
334
+ }
335
+ return s;
336
+ }
337
+ function truncate(s, max = 500) {
338
+ return s.length > max ? s.slice(0, max) + '…' : s;
339
+ }
340
+ /**
341
+ * Rebuild a TIFF/EXIF block without the removed categories. Values are copied byte-for-byte in the
342
+ * original byte order. The vendor MakerNote is always dropped on rewrite because its internal
343
+ * offsets break when relocated. Returns bytes=null when nothing is left worth keeping.
344
+ */
345
+ export function buildTiff(tiff, opts) {
346
+ const { le } = tiff;
347
+ const removed = new Set();
348
+ const notes = [];
349
+ const informative = gpsInformative(tiff);
350
+ const keep = (ifd, e) => {
351
+ if (ifd !== 'ifd1' && (e.tag === POINTER_TAGS.exif || e.tag === POINTER_TAGS.gps || e.tag === POINTER_TAGS.interop))
352
+ return false;
353
+ if (silentGps(tiff, ifd, e.tag, informative))
354
+ return !opts.remove.has('location');
355
+ const c = classifyTag(ifd, e.tag);
356
+ if (c === 'technical')
357
+ return true;
358
+ if (opts.remove.has(c)) {
359
+ removed.add(c);
360
+ return false;
361
+ }
362
+ if (ifd === 'exif' && e.tag === 0x927c) {
363
+ notes.push('The vendor maker note was removed because it cannot be safely moved inside the file.');
364
+ removed.add('device');
365
+ return false;
366
+ }
367
+ return true;
368
+ };
369
+ const enc16 = (v) => (le ? [v & 0xff, (v >> 8) & 0xff] : [(v >> 8) & 0xff, v & 0xff]);
370
+ const enc32 = (v) => le ? [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff] : [(v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff];
371
+ const short = (tag, v) => ({ tag, type: 3, count: 1, value: new Uint8Array(enc16(v)) });
372
+ const long = (tag, v) => ({ tag, type: 4, count: 1, value: new Uint8Array(enc32(v)) });
373
+ const filtered = {};
374
+ for (const ifd of ['ifd0', 'exif', 'gps', 'interop']) {
375
+ const src = tiff.ifds[ifd];
376
+ if (!src)
377
+ continue;
378
+ let list = src.filter((e) => keep(ifd, e));
379
+ if (ifd === 'ifd0' && opts.orientation !== undefined) {
380
+ list = list.filter((e) => e.tag !== 0x0112);
381
+ list.push(short(0x0112, opts.orientation));
382
+ }
383
+ if (ifd === 'exif' && opts.dimensions) {
384
+ list = list.filter((e) => e.tag !== 0xa002 && e.tag !== 0xa003);
385
+ list.push(long(0xa002, opts.dimensions.width), long(0xa003, opts.dimensions.height));
386
+ }
387
+ filtered[ifd] = list;
388
+ }
389
+ // Thumbnail (IFD1): keep only if not removed, it is a JPEG thumbnail, and pixels were not re-encoded.
390
+ let thumb;
391
+ if (tiff.ifds.ifd1) {
392
+ if (opts.remove.has('thumbnail'))
393
+ removed.add('thumbnail');
394
+ else if (opts.dimensions) {
395
+ removed.add('thumbnail');
396
+ notes.push('The embedded thumbnail was removed because it no longer matches the edited picture.');
397
+ }
398
+ else if (tiff.stripThumbnail || !tiff.thumbnail) {
399
+ removed.add('thumbnail');
400
+ notes.push('An uncompressed embedded thumbnail was removed because it cannot be moved safely.');
401
+ }
402
+ else {
403
+ thumb = tiff.thumbnail;
404
+ filtered.ifd1 = tiff.ifds.ifd1.filter((e) => e.tag !== 0x0201 && e.tag !== 0x0202);
405
+ }
406
+ }
407
+ const hasInterop = !!filtered.interop?.length;
408
+ if (filtered.exif && hasInterop)
409
+ filtered.exif.push(long(POINTER_TAGS.interop, 0));
410
+ const hasExif = !!filtered.exif?.length;
411
+ const hasGps = !!filtered.gps?.length;
412
+ if (!filtered.ifd0)
413
+ filtered.ifd0 = [];
414
+ if (hasExif)
415
+ filtered.ifd0.push(long(POINTER_TAGS.exif, 0));
416
+ if (hasGps)
417
+ filtered.ifd0.push(long(POINTER_TAGS.gps, 0));
418
+ if (thumb && filtered.ifd1)
419
+ filtered.ifd1.push(long(0x0201, 0), long(0x0202, thumb.length));
420
+ const order = ['ifd0'];
421
+ if (hasExif)
422
+ order.push('exif');
423
+ if (hasExif && hasInterop)
424
+ order.push('interop');
425
+ if (hasGps)
426
+ order.push('gps');
427
+ if (thumb && filtered.ifd1)
428
+ order.push('ifd1');
429
+ for (const n of order)
430
+ filtered[n].sort((a, b) => a.tag - b.tag);
431
+ const meaningful = order.some((n) => filtered[n].some((e) => !isPointer(n, e.tag)));
432
+ if (!meaningful)
433
+ return { bytes: null, removed, notes };
434
+ // Layout pass
435
+ let pos = 8;
436
+ const ifdPos = {};
437
+ const valuePos = new Map();
438
+ for (const n of order) {
439
+ const list = filtered[n];
440
+ ifdPos[n] = pos;
441
+ pos += 2 + list.length * 12 + 4;
442
+ for (const e of list) {
443
+ if (e.value.length > 4) {
444
+ if (pos & 1)
445
+ pos++;
446
+ valuePos.set(e, pos);
447
+ pos += e.value.length;
448
+ }
449
+ }
450
+ if (pos & 1)
451
+ pos++;
452
+ }
453
+ let thumbPos = 0;
454
+ if (thumb && ifdPos.ifd1 !== undefined) {
455
+ thumbPos = pos;
456
+ pos += thumb.length;
457
+ }
458
+ const out = new Uint8Array(pos);
459
+ out.set(le ? [0x49, 0x49] : [0x4d, 0x4d], 0);
460
+ out.set(enc16(42), 2);
461
+ out.set(enc32(8), 4);
462
+ const pointerValue = (n, tag) => {
463
+ if (n === 'ifd0' && tag === POINTER_TAGS.exif)
464
+ return ifdPos.exif;
465
+ if (n === 'ifd0' && tag === POINTER_TAGS.gps)
466
+ return ifdPos.gps;
467
+ if (n === 'exif' && tag === POINTER_TAGS.interop)
468
+ return ifdPos.interop;
469
+ if (n === 'ifd1' && tag === 0x0201)
470
+ return thumbPos;
471
+ return undefined;
472
+ };
473
+ for (const n of order) {
474
+ const list = filtered[n];
475
+ let p = ifdPos[n];
476
+ out.set(enc16(list.length), p);
477
+ p += 2;
478
+ for (const e of list) {
479
+ out.set(enc16(e.tag), p);
480
+ out.set(enc16(e.type), p + 2);
481
+ out.set(enc32(e.count), p + 4);
482
+ const pv = pointerValue(n, e.tag);
483
+ if (pv !== undefined)
484
+ out.set(enc32(pv), p + 8);
485
+ else if (e.value.length > 4)
486
+ out.set(enc32(valuePos.get(e)), p + 8);
487
+ else
488
+ out.set(e.value, p + 8);
489
+ p += 12;
490
+ }
491
+ const next = n === 'ifd0' && order.includes('ifd1') ? ifdPos.ifd1 : 0;
492
+ out.set(enc32(next), p);
493
+ for (const e of list) {
494
+ const vp = valuePos.get(e);
495
+ if (vp !== undefined)
496
+ out.set(e.value, vp);
497
+ }
498
+ }
499
+ if (thumb)
500
+ out.set(thumb, thumbPos);
501
+ return { bytes: out, removed, notes };
502
+ }
503
+ function isPointer(n, tag) {
504
+ return (n === 'ifd0' && (tag === POINTER_TAGS.exif || tag === POINTER_TAGS.gps)) || (n === 'exif' && tag === POINTER_TAGS.interop);
505
+ }
506
+ /** "Exif\0\0" prefix used by JPEG APP1, WebP and HEIF Exif payloads. */
507
+ export const EXIF_HEADER = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
@@ -0,0 +1,19 @@
1
+ import type { Category } from './types.js';
2
+ export interface FileNameFinding {
3
+ category: Category;
4
+ label: string;
5
+ value: string;
6
+ }
7
+ export interface FileNameReport {
8
+ name: string;
9
+ findings: FileNameFinding[];
10
+ /** True when the name itself gives something away. */
11
+ revealing: boolean;
12
+ }
13
+ /**
14
+ * Browsers send the file name with every upload. Camera apps and messengers write the date,
15
+ * device and app into it, and people often name files after places or people.
16
+ */
17
+ export declare function inspectFileName(name: string): FileNameReport;
18
+ /** A name that says nothing, e.g. "photo.jpg". Use it when uploading a cleaned file. */
19
+ export declare function neutralFileName(mime: string, base?: string): string;
@@ -0,0 +1,64 @@
1
+ const d8 = (s) => `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
2
+ const t6 = (s) => `${s.slice(0, 2)}:${s.slice(2, 4)}:${s.slice(4, 6)}`;
3
+ const RULES = [
4
+ {
5
+ re: /^PXL_(\d{8})_(\d{6})\d*(?:\.(MP|PORTRAIT|NIGHT|LONG_EXPOSURE[A-Z_]*|RAW-\d+))*/i,
6
+ apply: (m) => [
7
+ { category: 'device', label: 'Camera app', value: 'Google Pixel camera' },
8
+ { category: 'time', label: 'Taken (from file name, UTC)', value: `${d8(m[1])} ${t6(m[2])}` },
9
+ ...(/\.MP\b/i.test(m[0]) ? [{ category: 'hidden', label: 'Motion photo marker', value: '".MP" means a short video is stored inside this photo' }] : []),
10
+ ],
11
+ },
12
+ { re: /^MVIMG_(\d{8})_(\d{6})/i, apply: (m) => [{ category: 'device', label: 'Camera app', value: 'Google camera (motion photo)' }, { category: 'time', label: 'Taken (from file name)', value: `${d8(m[1])} ${t6(m[2])}` }, { category: 'hidden', label: 'Motion photo marker', value: 'A short video is stored inside this photo' }] },
13
+ { re: /^IMG-(\d{8})-WA\d+/i, apply: (m) => [{ category: 'software', label: 'Shared via', value: 'WhatsApp (Android)' }, { category: 'time', label: 'Date (from file name)', value: d8(m[1]) }] },
14
+ { re: /^WhatsApp Image (\d{4}-\d{2}-\d{2}) at (\d{1,2})[.:](\d{2})[.:](\d{2})/i, apply: (m) => [{ category: 'software', label: 'Shared via', value: 'WhatsApp' }, { category: 'time', label: 'Received (from file name)', value: `${m[1]} ${m[2].padStart(2, '0')}:${m[3]}:${m[4]}` }] },
15
+ { re: /^signal-(\d{4}-\d{2}-\d{2})-(\d{6})/i, apply: (m) => [{ category: 'software', label: 'Shared via', value: 'Signal' }, { category: 'time', label: 'Saved (from file name)', value: `${m[1]} ${t6(m[2])}` }] },
16
+ { re: /^photo_(\d{4}-\d{2}-\d{2})_(\d{2})-(\d{2})-(\d{2})/i, apply: (m) => [{ category: 'software', label: 'Shared via', value: 'Telegram' }, { category: 'time', label: 'Saved (from file name)', value: `${m[1]} ${m[2]}:${m[3]}:${m[4]}` }] },
17
+ {
18
+ re: /^Screenshot_(\d{8})[-_](\d{6})(?:_(.+?))?(?:\.\w+)?$/i,
19
+ apply: (m) => [
20
+ { category: 'software', label: 'Screenshot', value: 'Android screenshot' },
21
+ { category: 'time', label: 'Taken (from file name)', value: `${d8(m[1])} ${t6(m[2])}` },
22
+ ...(m[3] ? [{ category: 'software', label: 'App on screen', value: m[3].replace(/_/g, ' ') }] : []),
23
+ ],
24
+ },
25
+ { re: /^Screen ?shot (\d{4}-\d{2}-\d{2}) at (\d{1,2})[.:](\d{2})[.:](\d{2})/i, apply: (m) => [{ category: 'software', label: 'Screenshot', value: 'macOS screenshot' }, { category: 'time', label: 'Taken (from file name)', value: `${m[1]} ${m[2].padStart(2, '0')}:${m[3]}:${m[4]}` }] },
26
+ { re: /^Screenshot (\d{4}-\d{2}-\d{2}) (\d{6})/i, apply: (m) => [{ category: 'software', label: 'Screenshot', value: 'Windows screenshot' }, { category: 'time', label: 'Taken (from file name)', value: `${m[1]} ${t6(m[2])}` }] },
27
+ { re: /^(?:IMG|VID)_(\d{8})_(\d{6})/i, apply: (m) => [{ category: 'device', label: 'Camera app', value: 'Android phone camera' }, { category: 'time', label: 'Taken (from file name)', value: `${d8(m[1])} ${t6(m[2])}` }] },
28
+ { re: /^(\d{8})_(\d{6})(?:\(\d+\))?(?:\.\w+)?$/, apply: (m) => [{ category: 'device', label: 'Camera app', value: 'Samsung phone camera' }, { category: 'time', label: 'Taken (from file name)', value: `${d8(m[1])} ${t6(m[2])}` }] },
29
+ { re: /^IMG_E\d{4}/i, apply: () => [{ category: 'device', label: 'Camera app', value: 'iPhone or iPad camera' }, { category: 'history', label: 'Edited', value: '"IMG_E" means it was edited on the device' }] },
30
+ { re: /^IMG_\d{4}(?:\.\w+)?$/i, apply: () => [{ category: 'device', label: 'Camera app', value: 'iPhone, iPad or camera numbering' }] },
31
+ { re: /^(DSC_|DSCN)\d+/i, apply: () => [{ category: 'device', label: 'Camera', value: 'Nikon camera numbering' }] },
32
+ { re: /^DSCF\d+/i, apply: () => [{ category: 'device', label: 'Camera', value: 'Fujifilm camera numbering' }] },
33
+ { re: /^(_DSC|DSC0)\d+/i, apply: () => [{ category: 'device', label: 'Camera', value: 'Sony camera numbering' }] },
34
+ { re: /^(GOPR|GX|GH)\d{4,}/i, apply: () => [{ category: 'device', label: 'Camera', value: 'GoPro numbering' }] },
35
+ { re: /^DJI_\d+/i, apply: () => [{ category: 'device', label: 'Camera', value: 'DJI drone' }] },
36
+ ];
37
+ const GENERIC = /^(img|image|photo|picture|pic|download|untitled|unnamed|file|scan|capture|edited|copy|final|clean|crop(ped)?|new|\d+|[a-f0-9-]{16,}|[\s_\-().0-9]+)$/i;
38
+ /**
39
+ * Browsers send the file name with every upload. Camera apps and messengers write the date,
40
+ * device and app into it, and people often name files after places or people.
41
+ */
42
+ export function inspectFileName(name) {
43
+ const base = name.replace(/\.[A-Za-z0-9]{2,5}$/, '');
44
+ const findings = [];
45
+ for (const r of RULES) {
46
+ const m = r.re.exec(name) ?? r.re.exec(base);
47
+ if (m) {
48
+ findings.push(...r.apply(m));
49
+ break;
50
+ }
51
+ }
52
+ const hashLike = /^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$|^[a-f0-9]{16,}$/i.test(base);
53
+ if (!findings.length && !hashLike) {
54
+ const words = base.split(/[\s_\-.]+/).filter((w) => w && !GENERIC.test(w));
55
+ if (words.length)
56
+ findings.push({ category: 'descriptions', label: 'Custom file name', value: `"${base}" may describe the place, people or event` });
57
+ }
58
+ return { name, findings, revealing: findings.length > 0 };
59
+ }
60
+ const EXT = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/heic': 'heic', 'image/avif': 'avif' };
61
+ /** A name that says nothing, e.g. "photo.jpg". Use it when uploading a cleaned file. */
62
+ export function neutralFileName(mime, base = 'photo') {
63
+ return `${base}.${EXT[mime] ?? 'jpg'}`;
64
+ }
package/dist/heif.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { Collector } from './collect.js';
2
+ import type { FormatStrip } from './jpeg.js';
3
+ import type { Category, Format } from './types.js';
4
+ export declare function heifFormat(b: Uint8Array): Format | null;
5
+ export declare function inspectHeif(b: Uint8Array, c: Collector): void;
6
+ export declare function stripHeif(b: Uint8Array, remove: Set<Category>): FormatStrip;