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/LICENSE +21 -0
- package/README.md +134 -2
- package/dist/api.d.ts +25 -0
- package/dist/api.js +172 -0
- package/dist/browser/canvas.d.ts +20 -0
- package/dist/browser/canvas.js +53 -0
- package/dist/browser/faces.d.ts +26 -0
- package/dist/browser/faces.js +68 -0
- package/dist/browser/index.d.ts +9 -0
- package/dist/browser/index.js +5 -0
- package/dist/browser/mediapipe.d.ts +28 -0
- package/dist/browser/mediapipe.js +69 -0
- package/dist/browser/objects.d.ts +29 -0
- package/dist/browser/objects.js +69 -0
- package/dist/browser/redact.d.ts +29 -0
- package/dist/browser/redact.js +147 -0
- package/dist/bytes.d.ts +22 -0
- package/dist/bytes.js +109 -0
- package/dist/categories.d.ts +13 -0
- package/dist/categories.js +113 -0
- package/dist/collect.d.ts +16 -0
- package/dist/collect.js +108 -0
- package/dist/exif-tags.d.ts +23 -0
- package/dist/exif-tags.js +200 -0
- package/dist/exif.d.ts +64 -0
- package/dist/exif.js +507 -0
- package/dist/filename.d.ts +19 -0
- package/dist/filename.js +64 -0
- package/dist/heif.d.ts +6 -0
- package/dist/heif.js +299 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/iptc.d.ts +11 -0
- package/dist/iptc.js +118 -0
- package/dist/jpeg.d.ts +51 -0
- package/dist/jpeg.js +374 -0
- package/dist/png.d.ts +9 -0
- package/dist/png.js +245 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +1 -0
- package/dist/webp.d.ts +6 -0
- package/dist/webp.js +123 -0
- package/dist/xmp.d.ts +25 -0
- package/dist/xmp.js +364 -0
- package/package.json +78 -4
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/** What kind of information a piece of metadata gives away. */
|
|
2
|
+
export type Category = 'location' | 'time' | 'device' | 'people' | 'author' | 'descriptions' | 'software' | 'history' | 'thumbnail' | 'hidden' | 'ai' | 'provenance' | 'settings' | 'other';
|
|
3
|
+
export type Risk = 'high' | 'medium' | 'low';
|
|
4
|
+
export type Format = 'jpeg' | 'png' | 'webp' | 'heic' | 'avif' | 'unknown';
|
|
5
|
+
/** Where in the file a finding lives. */
|
|
6
|
+
export type Source = 'exif' | 'xmp' | 'iptc' | 'jpeg' | 'png' | 'webp' | 'heif' | 'icc';
|
|
7
|
+
export interface Finding {
|
|
8
|
+
category: Category;
|
|
9
|
+
source: Source;
|
|
10
|
+
/** Stable machine key, e.g. "exif.GPSLatitude". */
|
|
11
|
+
key: string;
|
|
12
|
+
/** Human label, e.g. "GPS latitude". */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Human-readable value, truncated for very long data. */
|
|
15
|
+
value: string;
|
|
16
|
+
/** False when this format cannot drop it without re-encoding the picture. */
|
|
17
|
+
removable: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface GpsPoint {
|
|
20
|
+
latitude: number;
|
|
21
|
+
longitude: number;
|
|
22
|
+
altitude?: number;
|
|
23
|
+
/** Compass direction the camera was facing, degrees. */
|
|
24
|
+
direction?: number;
|
|
25
|
+
}
|
|
26
|
+
export interface HiddenMedia {
|
|
27
|
+
kind: 'video' | 'image' | 'data';
|
|
28
|
+
mime: string;
|
|
29
|
+
/** Short description such as "Motion photo video". */
|
|
30
|
+
label: string;
|
|
31
|
+
bytes: Uint8Array;
|
|
32
|
+
}
|
|
33
|
+
export interface Report {
|
|
34
|
+
format: Format;
|
|
35
|
+
mime: string;
|
|
36
|
+
size: number;
|
|
37
|
+
width?: number;
|
|
38
|
+
height?: number;
|
|
39
|
+
/** EXIF orientation 1..8 when present. */
|
|
40
|
+
orientation?: number;
|
|
41
|
+
findings: Finding[];
|
|
42
|
+
/** Categories with at least one finding, in display order. */
|
|
43
|
+
categories: Category[];
|
|
44
|
+
/** Convenience summaries for the most revealing items. */
|
|
45
|
+
summary: {
|
|
46
|
+
gps?: GpsPoint;
|
|
47
|
+
capturedAt?: string;
|
|
48
|
+
timeZone?: string;
|
|
49
|
+
device?: string;
|
|
50
|
+
serialNumbers: string[];
|
|
51
|
+
/** People tagged as appearing in the photo. */
|
|
52
|
+
people: string[];
|
|
53
|
+
/** Owner, artist, photographer and creator names. */
|
|
54
|
+
names: string[];
|
|
55
|
+
software: string[];
|
|
56
|
+
aiPrompt?: string;
|
|
57
|
+
};
|
|
58
|
+
/** Embedded preview pictures (EXIF/IPTC/XMP thumbnails). */
|
|
59
|
+
thumbnails: Uint8Array[];
|
|
60
|
+
/** Extra pictures or video stored in the file next to the main image. */
|
|
61
|
+
hidden: HiddenMedia[];
|
|
62
|
+
notes: string[];
|
|
63
|
+
}
|
|
64
|
+
export interface StripOptions {
|
|
65
|
+
/** Categories to remove. Defaults to every category whose `defaultRemove` is true. */
|
|
66
|
+
remove?: Category[] | 'all';
|
|
67
|
+
}
|
|
68
|
+
export interface StripResult {
|
|
69
|
+
bytes: Uint8Array;
|
|
70
|
+
mime: string;
|
|
71
|
+
format: Format;
|
|
72
|
+
/** True when only metadata changed and the picture data is byte-identical. */
|
|
73
|
+
lossless: true;
|
|
74
|
+
/** Categories that were present and are now gone. */
|
|
75
|
+
removed: Category[];
|
|
76
|
+
/** Findings that were requested for removal but could not be removed in this format. */
|
|
77
|
+
retained: Finding[];
|
|
78
|
+
notes: string[];
|
|
79
|
+
}
|
|
80
|
+
export type Input = Uint8Array | ArrayBuffer | Blob;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/webp.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Collector } from './collect.js';
|
|
2
|
+
import type { FormatStrip } from './jpeg.js';
|
|
3
|
+
import type { Category } from './types.js';
|
|
4
|
+
export declare function isWebp(b: Uint8Array): boolean;
|
|
5
|
+
export declare function inspectWebp(b: Uint8Array, c: Collector): void;
|
|
6
|
+
export declare function stripWebp(b: Uint8Array, remove: Set<Category>): FormatStrip;
|
package/dist/webp.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { concat, encodeAscii, latin1, putU32le, startsWith, u32le } from './bytes.js';
|
|
2
|
+
import { Collector } from './collect.js';
|
|
3
|
+
import { buildTiff, parseTiff, tiffCategories } from './exif.js';
|
|
4
|
+
import { analyzeXmp } from './xmp.js';
|
|
5
|
+
export function isWebp(b) {
|
|
6
|
+
return b.length >= 16 && startsWith(b, 0, 'RIFF') && startsWith(b, 8, 'WEBP');
|
|
7
|
+
}
|
|
8
|
+
function chunks(b) {
|
|
9
|
+
const out = [];
|
|
10
|
+
const riffEnd = Math.min(b.length, 8 + u32le(b, 4));
|
|
11
|
+
let p = 12;
|
|
12
|
+
while (p + 8 <= riffEnd) {
|
|
13
|
+
const fourcc = latin1(b, p, p + 4);
|
|
14
|
+
const size = u32le(b, p + 4);
|
|
15
|
+
const dataEnd = p + 8 + size;
|
|
16
|
+
if (dataEnd > b.length)
|
|
17
|
+
throw new Error('Corrupt WebP: chunk runs past the end of the file');
|
|
18
|
+
const end = dataEnd + (size & 1);
|
|
19
|
+
out.push({ fourcc, start: p, end: Math.min(end, b.length), data: b.subarray(p + 8, dataEnd) });
|
|
20
|
+
p = end;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
const tiffOf = (d) => (startsWith(d, 0, 'Exif\0\0') ? d.subarray(6) : d);
|
|
25
|
+
export function inspectWebp(b, c) {
|
|
26
|
+
for (const ch of chunks(b)) {
|
|
27
|
+
const d = ch.data;
|
|
28
|
+
if (ch.fourcc === 'VP8X' && d.length >= 10) {
|
|
29
|
+
c.report.width = 1 + (d[4] | (d[5] << 8) | (d[6] << 16));
|
|
30
|
+
c.report.height = 1 + (d[7] | (d[8] << 8) | (d[9] << 16));
|
|
31
|
+
}
|
|
32
|
+
else if (ch.fourcc === 'VP8 ' && d.length >= 10 && !c.report.width) {
|
|
33
|
+
c.report.width = (d[6] | (d[7] << 8)) & 0x3fff;
|
|
34
|
+
c.report.height = (d[8] | (d[9] << 8)) & 0x3fff;
|
|
35
|
+
}
|
|
36
|
+
else if (ch.fourcc === 'VP8L' && d.length >= 5 && !c.report.width) {
|
|
37
|
+
const bits = d[1] | (d[2] << 8) | (d[3] << 16) | (d[4] << 24);
|
|
38
|
+
c.report.width = (bits & 0x3fff) + 1;
|
|
39
|
+
c.report.height = ((bits >> 14) & 0x3fff) + 1;
|
|
40
|
+
}
|
|
41
|
+
else if (ch.fourcc === 'EXIF')
|
|
42
|
+
c.tiff(tiffOf(d), 'exif');
|
|
43
|
+
else if (ch.fourcc === 'XMP ')
|
|
44
|
+
c.xmp(d, 'xmp');
|
|
45
|
+
else if (!['VP8 ', 'VP8L', 'VP8X', 'ALPH', 'ANIM', 'ANMF', 'ICCP'].includes(ch.fourcc)) {
|
|
46
|
+
c.add({ category: 'other', source: 'webp', key: `webp.${ch.fourcc.trim()}`, label: `Extra chunk "${ch.fourcc.trim()}"`, value: `${d.length.toLocaleString('en')} bytes`, removable: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
function chunkBytes(fourcc, data) {
|
|
51
|
+
const pad = data.length & 1;
|
|
52
|
+
const out = new Uint8Array(8 + data.length + pad);
|
|
53
|
+
out.set(encodeAscii(fourcc), 0);
|
|
54
|
+
putU32le(out, 4, data.length);
|
|
55
|
+
out.set(data, 8);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
export function stripWebp(b, remove) {
|
|
59
|
+
const removed = new Set();
|
|
60
|
+
const notes = [];
|
|
61
|
+
const kept = [];
|
|
62
|
+
let vp8x;
|
|
63
|
+
let hasExif = false;
|
|
64
|
+
let hasXmp = false;
|
|
65
|
+
for (const ch of chunks(b)) {
|
|
66
|
+
const raw = b.subarray(ch.start, ch.end);
|
|
67
|
+
if (ch.fourcc === 'VP8X') {
|
|
68
|
+
vp8x = { index: kept.length, data: ch.data.slice() };
|
|
69
|
+
kept.push(raw);
|
|
70
|
+
}
|
|
71
|
+
else if (ch.fourcc === 'EXIF') {
|
|
72
|
+
const tiff = parseTiff(tiffOf(ch.data));
|
|
73
|
+
if (!tiff) {
|
|
74
|
+
if (remove.has('other'))
|
|
75
|
+
removed.add('other');
|
|
76
|
+
else {
|
|
77
|
+
kept.push(raw);
|
|
78
|
+
hasExif = true;
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (![...tiffCategories(tiff)].some((c) => remove.has(c))) {
|
|
83
|
+
kept.push(raw);
|
|
84
|
+
hasExif = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const r = buildTiff(tiff, { remove });
|
|
88
|
+
r.removed.forEach((c) => removed.add(c));
|
|
89
|
+
notes.push(...r.notes);
|
|
90
|
+
if (r.bytes) {
|
|
91
|
+
kept.push(chunkBytes('EXIF', r.bytes));
|
|
92
|
+
hasExif = true;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
else if (ch.fourcc === 'XMP ') {
|
|
96
|
+
const hits = [...analyzeXmp(new TextDecoder().decode(ch.data)).categories].filter((c) => remove.has(c));
|
|
97
|
+
if (hits.length) {
|
|
98
|
+
hits.forEach((c) => removed.add(c));
|
|
99
|
+
notes.push('The XMP metadata block was removed as a whole.');
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
kept.push(raw);
|
|
103
|
+
hasXmp = true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
else if (!['VP8 ', 'VP8L', 'ALPH', 'ANIM', 'ANMF', 'ICCP'].includes(ch.fourcc) && remove.has('other')) {
|
|
107
|
+
removed.add('other');
|
|
108
|
+
}
|
|
109
|
+
else
|
|
110
|
+
kept.push(raw);
|
|
111
|
+
}
|
|
112
|
+
if (vp8x) {
|
|
113
|
+
const d = vp8x.data;
|
|
114
|
+
d[0] = (d[0] & ~0x0c) | (hasExif ? 0x08 : 0) | (hasXmp ? 0x04 : 0);
|
|
115
|
+
kept[vp8x.index] = chunkBytes('VP8X', d);
|
|
116
|
+
}
|
|
117
|
+
const body = concat(kept);
|
|
118
|
+
const head = new Uint8Array(12);
|
|
119
|
+
head.set(encodeAscii('RIFF'), 0);
|
|
120
|
+
putU32le(head, 4, 4 + body.length);
|
|
121
|
+
head.set(encodeAscii('WEBP'), 8);
|
|
122
|
+
return { bytes: concat([head, body]), removed, retained: [], notes };
|
|
123
|
+
}
|
package/dist/xmp.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Category, Finding, GpsPoint, Source } from './types.js';
|
|
2
|
+
export interface XmpProperty {
|
|
3
|
+
ns: string;
|
|
4
|
+
name: string;
|
|
5
|
+
value: string;
|
|
6
|
+
category: Category;
|
|
7
|
+
}
|
|
8
|
+
export interface XmpAnalysis {
|
|
9
|
+
properties: XmpProperty[];
|
|
10
|
+
categories: Set<Category>;
|
|
11
|
+
gps?: GpsPoint;
|
|
12
|
+
people: string[];
|
|
13
|
+
names: string[];
|
|
14
|
+
software: string[];
|
|
15
|
+
motionPhoto: boolean;
|
|
16
|
+
/** Byte length of the motion-photo video at the end of the file, when declared. */
|
|
17
|
+
motionVideoLength?: number;
|
|
18
|
+
aiPrompt?: string;
|
|
19
|
+
}
|
|
20
|
+
export declare function classifyXmp(ns: string, name: string): Category | 'technical';
|
|
21
|
+
/** Parse an XMP packet into top-level properties with categories and a few summaries. */
|
|
22
|
+
export declare function analyzeXmp(xml: string): XmpAnalysis;
|
|
23
|
+
export declare function xmpFindings(a: XmpAnalysis, source: Source, removable?: boolean): Finding[];
|
|
24
|
+
/** An empty but valid XMP packet, padded with spaces to an exact byte length when possible. */
|
|
25
|
+
export declare function emptyXmpPacket(length: number): Uint8Array;
|
package/dist/xmp.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/** Namespace URI → per-property classification. '*' is the namespace default. */
|
|
2
|
+
const NS = {
|
|
3
|
+
'http://ns.adobe.com/exif/1.0/': {
|
|
4
|
+
'*': 'settings',
|
|
5
|
+
DateTimeOriginal: 'time',
|
|
6
|
+
DateTimeDigitized: 'time',
|
|
7
|
+
OffsetTime: 'time',
|
|
8
|
+
OffsetTimeOriginal: 'time',
|
|
9
|
+
OffsetTimeDigitized: 'time',
|
|
10
|
+
SubSecTimeOriginal: 'time',
|
|
11
|
+
GPSTimeStamp: 'time',
|
|
12
|
+
UserComment: 'descriptions',
|
|
13
|
+
PixelXDimension: 'technical',
|
|
14
|
+
PixelYDimension: 'technical',
|
|
15
|
+
ColorSpace: 'technical',
|
|
16
|
+
ExifVersion: 'technical',
|
|
17
|
+
ImageUniqueID: 'device',
|
|
18
|
+
CameraOwnerName: 'author',
|
|
19
|
+
BodySerialNumber: 'device',
|
|
20
|
+
LensSerialNumber: 'device',
|
|
21
|
+
LensMake: 'device',
|
|
22
|
+
LensModel: 'device',
|
|
23
|
+
},
|
|
24
|
+
'http://cipa.jp/exif/1.0/': {
|
|
25
|
+
'*': 'settings',
|
|
26
|
+
BodySerialNumber: 'device',
|
|
27
|
+
LensSerialNumber: 'device',
|
|
28
|
+
LensMake: 'device',
|
|
29
|
+
LensModel: 'device',
|
|
30
|
+
LensSpecification: 'device',
|
|
31
|
+
CameraOwnerName: 'author',
|
|
32
|
+
Photographer: 'author',
|
|
33
|
+
ImageUniqueID: 'device',
|
|
34
|
+
OffsetTime: 'time',
|
|
35
|
+
OffsetTimeOriginal: 'time',
|
|
36
|
+
},
|
|
37
|
+
'http://ns.adobe.com/tiff/1.0/': {
|
|
38
|
+
'*': 'technical',
|
|
39
|
+
Make: 'device',
|
|
40
|
+
Model: 'device',
|
|
41
|
+
DateTime: 'time',
|
|
42
|
+
Artist: 'author',
|
|
43
|
+
Copyright: 'author',
|
|
44
|
+
Software: 'software',
|
|
45
|
+
ImageDescription: 'descriptions',
|
|
46
|
+
},
|
|
47
|
+
'http://ns.adobe.com/exif/1.0/aux/': {
|
|
48
|
+
'*': 'device',
|
|
49
|
+
OwnerName: 'author',
|
|
50
|
+
ApproximateFocusDistance: 'settings',
|
|
51
|
+
Firmware: 'device',
|
|
52
|
+
},
|
|
53
|
+
'http://ns.adobe.com/xap/1.0/': {
|
|
54
|
+
'*': 'other',
|
|
55
|
+
CreateDate: 'time',
|
|
56
|
+
ModifyDate: 'time',
|
|
57
|
+
MetadataDate: 'time',
|
|
58
|
+
CreatorTool: 'software',
|
|
59
|
+
Rating: 'descriptions',
|
|
60
|
+
Label: 'descriptions',
|
|
61
|
+
Nickname: 'author',
|
|
62
|
+
Thumbnails: 'thumbnail',
|
|
63
|
+
},
|
|
64
|
+
'http://ns.adobe.com/xap/1.0/mm/': { '*': 'history' },
|
|
65
|
+
'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#': { '*': 'history' },
|
|
66
|
+
'http://ns.adobe.com/camera-raw-settings/1.0/': { '*': 'history' },
|
|
67
|
+
'http://ns.adobe.com/lightroom/1.0/': { '*': 'descriptions' },
|
|
68
|
+
'http://ns.adobe.com/photoshop/1.0/': {
|
|
69
|
+
'*': 'other',
|
|
70
|
+
City: 'location',
|
|
71
|
+
State: 'location',
|
|
72
|
+
Country: 'location',
|
|
73
|
+
DateCreated: 'time',
|
|
74
|
+
DocumentAncestors: 'history',
|
|
75
|
+
History: 'history',
|
|
76
|
+
AuthorsPosition: 'author',
|
|
77
|
+
CaptionWriter: 'author',
|
|
78
|
+
Credit: 'author',
|
|
79
|
+
Source: 'author',
|
|
80
|
+
Headline: 'descriptions',
|
|
81
|
+
Instructions: 'descriptions',
|
|
82
|
+
TransmissionReference: 'descriptions',
|
|
83
|
+
Category: 'descriptions',
|
|
84
|
+
SupplementalCategories: 'descriptions',
|
|
85
|
+
ColorMode: 'technical',
|
|
86
|
+
ICCProfile: 'technical',
|
|
87
|
+
},
|
|
88
|
+
'http://purl.org/dc/elements/1.1/': {
|
|
89
|
+
'*': 'descriptions',
|
|
90
|
+
creator: 'author',
|
|
91
|
+
rights: 'author',
|
|
92
|
+
publisher: 'author',
|
|
93
|
+
contributor: 'author',
|
|
94
|
+
format: 'technical',
|
|
95
|
+
},
|
|
96
|
+
'http://ns.adobe.com/xap/1.0/rights/': { '*': 'author' },
|
|
97
|
+
'http://ns.useplus.org/ldf/xmp/1.0/': { '*': 'author' },
|
|
98
|
+
'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/': {
|
|
99
|
+
'*': 'descriptions',
|
|
100
|
+
Location: 'location',
|
|
101
|
+
CountryCode: 'location',
|
|
102
|
+
CreatorContactInfo: 'author',
|
|
103
|
+
},
|
|
104
|
+
'http://iptc.org/std/Iptc4xmpExt/2008-02-29/': {
|
|
105
|
+
'*': 'descriptions',
|
|
106
|
+
LocationCreated: 'location',
|
|
107
|
+
LocationShown: 'location',
|
|
108
|
+
PersonInImage: 'people',
|
|
109
|
+
PersonInImageWDetails: 'people',
|
|
110
|
+
ArtworkOrObject: 'descriptions',
|
|
111
|
+
DigitalSourceType: 'ai',
|
|
112
|
+
},
|
|
113
|
+
'http://www.metadataworkinggroup.com/schemas/regions/': { '*': 'people' },
|
|
114
|
+
'http://ns.microsoft.com/photo/1.2/': { '*': 'people' },
|
|
115
|
+
'http://ns.microsoft.com/photo/1.0/': { '*': 'descriptions' },
|
|
116
|
+
'http://ns.google.com/photos/1.0/camera/': { '*': 'hidden' },
|
|
117
|
+
'http://ns.google.com/photos/1.0/container/': { '*': 'hidden' },
|
|
118
|
+
'http://ns.google.com/photos/1.0/container/item/': { '*': 'hidden' },
|
|
119
|
+
'http://ns.google.com/photos/1.0/depthmap/': { '*': 'hidden' },
|
|
120
|
+
'http://ns.google.com/photos/1.0/image/': { '*': 'hidden' },
|
|
121
|
+
'http://ns.google.com/photos/1.0/panorama/': { '*': 'settings' },
|
|
122
|
+
'http://ns.adobe.com/hdr-gain-map/1.0/': { '*': 'hidden' },
|
|
123
|
+
'http://ns.apple.com/HDRGainMap/1.0/': { '*': 'hidden' },
|
|
124
|
+
'http://ns.apple.com/ImageIO/1.0/': { '*': 'other' },
|
|
125
|
+
'http://ns.apple.com/pixeldatainfo/1.0/': { '*': 'hidden' },
|
|
126
|
+
'http://ns.adobe.com/xmp/1.0/DynamicMedia/': { '*': 'other' },
|
|
127
|
+
'http://ns.adobe.com/pdf/1.3/': { '*': 'software', Keywords: 'descriptions' },
|
|
128
|
+
'http://ns.adobe.com/xmp/note/': { '*': 'technical' },
|
|
129
|
+
'http://c2pa.org/': { '*': 'provenance' },
|
|
130
|
+
};
|
|
131
|
+
/** Container namespaces whose elements are structure, not properties. */
|
|
132
|
+
const STRUCTURAL = new Set(['http://www.w3.org/1999/02/22-rdf-syntax-ns#', 'adobe:ns:meta/', 'http://www.w3.org/XML/1998/namespace']);
|
|
133
|
+
function decodeEntities(s) {
|
|
134
|
+
return s
|
|
135
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
136
|
+
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
|
137
|
+
.replace(/</g, '<')
|
|
138
|
+
.replace(/>/g, '>')
|
|
139
|
+
.replace(/"/g, '"')
|
|
140
|
+
.replace(/'/g, "'")
|
|
141
|
+
.replace(/&/g, '&');
|
|
142
|
+
}
|
|
143
|
+
function* tokenize(xml) {
|
|
144
|
+
const re = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[([\s\S]*?)\]\]>|<!DOCTYPE[^>]*>|<\/\s*([^\s>]+)\s*>|<([^\s/>]+)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>|([^<]+)/g;
|
|
145
|
+
let m;
|
|
146
|
+
while ((m = re.exec(xml))) {
|
|
147
|
+
if (m[1] !== undefined)
|
|
148
|
+
yield { kind: 'text', text: m[1] };
|
|
149
|
+
else if (m[2])
|
|
150
|
+
yield { kind: 'close', name: m[2] };
|
|
151
|
+
else if (m[3]) {
|
|
152
|
+
const attrs = {};
|
|
153
|
+
const ar = /([^\s=/>]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
154
|
+
let a;
|
|
155
|
+
while ((a = ar.exec(m[4] ?? '')))
|
|
156
|
+
attrs[a[1]] = decodeEntities(a[2] ?? a[3] ?? '');
|
|
157
|
+
yield { kind: 'open', name: m[3], attrs, selfClosing: m[5] === '/' };
|
|
158
|
+
}
|
|
159
|
+
else if (m[6] !== undefined)
|
|
160
|
+
yield { kind: 'text', text: decodeEntities(m[6]) };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
export function classifyXmp(ns, name) {
|
|
164
|
+
const map = NS[ns];
|
|
165
|
+
if (!map)
|
|
166
|
+
return 'other';
|
|
167
|
+
return map[name] ?? map['*'] ?? 'other';
|
|
168
|
+
}
|
|
169
|
+
/** Parse an XMP packet into top-level properties with categories and a few summaries. */
|
|
170
|
+
export function analyzeXmp(xml) {
|
|
171
|
+
const prefixes = new Map([['xml', 'http://www.w3.org/XML/1998/namespace']]);
|
|
172
|
+
for (const m of xml.matchAll(/xmlns:([A-Za-z_][\w.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
|
|
173
|
+
prefixes.set(m[1], m[2] ?? m[3] ?? '');
|
|
174
|
+
const split = (q) => {
|
|
175
|
+
const i = q.indexOf(':');
|
|
176
|
+
if (i < 0)
|
|
177
|
+
return ['', q];
|
|
178
|
+
return [prefixes.get(q.slice(0, i)) ?? 'urn:unknown:' + q.slice(0, i), q.slice(i + 1)];
|
|
179
|
+
};
|
|
180
|
+
const out = { properties: [], categories: new Set(), people: [], names: [], software: [], motionPhoto: false };
|
|
181
|
+
const add = (ns, name, value) => {
|
|
182
|
+
if (STRUCTURAL.has(ns))
|
|
183
|
+
return;
|
|
184
|
+
const cls = classifyXmp(ns, name);
|
|
185
|
+
if (cls === 'technical')
|
|
186
|
+
return;
|
|
187
|
+
out.properties.push({ ns, name, value, category: cls });
|
|
188
|
+
out.categories.add(cls);
|
|
189
|
+
};
|
|
190
|
+
const stack = [];
|
|
191
|
+
const currentProp = () => {
|
|
192
|
+
for (let i = stack.length - 1; i >= 0; i--)
|
|
193
|
+
if (stack[i].prop)
|
|
194
|
+
return stack[i].prop;
|
|
195
|
+
return undefined;
|
|
196
|
+
};
|
|
197
|
+
const finish = (p) => {
|
|
198
|
+
const texts = p.texts.map((t) => t.trim()).filter(Boolean);
|
|
199
|
+
let value = texts.join(', ');
|
|
200
|
+
if (p.items > 1 && texts.length === 0)
|
|
201
|
+
value = `${p.items} entries`;
|
|
202
|
+
else if (p.items > 3 && value.length > 160)
|
|
203
|
+
value = `${p.items} entries: ${value.slice(0, 160)}…`;
|
|
204
|
+
else if (value.length > 300)
|
|
205
|
+
value = value.slice(0, 300) + '…';
|
|
206
|
+
add(p.ns, p.name, value);
|
|
207
|
+
collect(p.ns, p.name, texts);
|
|
208
|
+
};
|
|
209
|
+
const collect = (ns, name, texts) => {
|
|
210
|
+
const cls = classifyXmp(ns, name);
|
|
211
|
+
if (cls === 'people')
|
|
212
|
+
out.people.push(...texts.filter((t) => t.length < 120));
|
|
213
|
+
if (cls === 'author' && (name === 'creator' || name === 'Artist' || name === 'OwnerName' || name === 'CameraOwnerName'))
|
|
214
|
+
out.names.push(...texts);
|
|
215
|
+
if (name === 'CreatorTool' || (ns === 'http://ns.adobe.com/tiff/1.0/' && name === 'Software'))
|
|
216
|
+
out.software.push(...texts);
|
|
217
|
+
if (ns === 'http://ns.google.com/photos/1.0/camera/') {
|
|
218
|
+
if ((name === 'MotionPhoto' || name === 'MicroVideo') && texts[0] === '1')
|
|
219
|
+
out.motionPhoto = true;
|
|
220
|
+
if (name === 'MicroVideoOffset') {
|
|
221
|
+
const n = Number(texts[0]);
|
|
222
|
+
if (n > 0)
|
|
223
|
+
out.motionVideoLength = n;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
for (const tok of tokenize(xml)) {
|
|
228
|
+
if (tok.kind === 'text') {
|
|
229
|
+
const p = currentProp();
|
|
230
|
+
if (p && tok.text && tok.text.trim())
|
|
231
|
+
p.texts.push(tok.text);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (tok.kind === 'close') {
|
|
235
|
+
const f = stack.pop();
|
|
236
|
+
if (f?.prop && f.role === 'prop')
|
|
237
|
+
finish(f.prop);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const q = tok.name;
|
|
241
|
+
const [ns, local] = split(q);
|
|
242
|
+
const parent = stack[stack.length - 1];
|
|
243
|
+
let frame;
|
|
244
|
+
if (ns === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' && local === 'RDF')
|
|
245
|
+
frame = { q, role: 'rdf' };
|
|
246
|
+
else if (ns === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#' && local === 'Description' && parent?.role === 'rdf')
|
|
247
|
+
frame = { q, role: 'desc' };
|
|
248
|
+
else if (parent?.role === 'desc')
|
|
249
|
+
frame = { q, role: 'prop', prop: { ns, name: local, texts: [], items: 0 } };
|
|
250
|
+
else
|
|
251
|
+
frame = { q, role: 'inner' };
|
|
252
|
+
// Attributes: on a top-level description they are properties; deeper they are values.
|
|
253
|
+
for (const [k, v] of Object.entries(tok.attrs ?? {})) {
|
|
254
|
+
if (k.startsWith('xmlns') || k === 'rdf:about' || k === 'rdf:parseType' || k === 'xml:lang' || k.startsWith('x:'))
|
|
255
|
+
continue;
|
|
256
|
+
const [ans, alocal] = split(k);
|
|
257
|
+
if (frame.role === 'desc') {
|
|
258
|
+
add(ans, alocal, v.length > 300 ? v.slice(0, 300) + '…' : v);
|
|
259
|
+
collect(ans, alocal, [v]);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
const p = frame.prop ?? currentProp();
|
|
263
|
+
if (p && v)
|
|
264
|
+
p.texts.push(`${alocal}: ${v}`);
|
|
265
|
+
if (ans === 'http://ns.google.com/photos/1.0/container/item/' && alocal === 'Length') {
|
|
266
|
+
const mime = tok.attrs?.[Object.keys(tok.attrs).find((x) => x.endsWith(':Mime')) ?? ''];
|
|
267
|
+
if (mime?.startsWith('video/'))
|
|
268
|
+
out.motionVideoLength = Number(v) || out.motionVideoLength;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (q.endsWith(':li')) {
|
|
273
|
+
const p = currentProp();
|
|
274
|
+
if (p)
|
|
275
|
+
p.items++;
|
|
276
|
+
}
|
|
277
|
+
if (tok.selfClosing) {
|
|
278
|
+
if (frame.prop)
|
|
279
|
+
finish(frame.prop);
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
stack.push(frame);
|
|
283
|
+
}
|
|
284
|
+
// GPS from exif:GPSLatitude="12,58.1234N"
|
|
285
|
+
const gpsVal = (name) => out.properties.find((p) => p.ns === 'http://ns.adobe.com/exif/1.0/' && p.name === name)?.value;
|
|
286
|
+
const lat = parseXmpCoord(gpsVal('GPSLatitude'));
|
|
287
|
+
const lon = parseXmpCoord(gpsVal('GPSLongitude'));
|
|
288
|
+
if (lat !== undefined && lon !== undefined)
|
|
289
|
+
out.gps = { latitude: lat, longitude: lon };
|
|
290
|
+
for (const p of out.properties)
|
|
291
|
+
if (p.ns === 'http://ns.adobe.com/exif/1.0/' && p.name.startsWith('GPS') && p.name !== 'GPSTimeStamp') {
|
|
292
|
+
p.category = 'location';
|
|
293
|
+
out.categories.add('location');
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
function parseXmpCoord(v) {
|
|
298
|
+
if (!v)
|
|
299
|
+
return undefined;
|
|
300
|
+
const m = /^\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d+(?:\.\d+)?))?\s*([NSEW])\s*$/i.exec(v);
|
|
301
|
+
if (!m)
|
|
302
|
+
return undefined;
|
|
303
|
+
let deg = Number(m[1]) + Number(m[2]) / 60 + (m[3] ? Number(m[3]) / 3600 : 0);
|
|
304
|
+
if (/[SW]/i.test(m[4]))
|
|
305
|
+
deg = -deg;
|
|
306
|
+
return deg;
|
|
307
|
+
}
|
|
308
|
+
const PRETTY = {
|
|
309
|
+
CreatorTool: 'Created with',
|
|
310
|
+
CreateDate: 'Created',
|
|
311
|
+
ModifyDate: 'Modified',
|
|
312
|
+
MetadataDate: 'Metadata modified',
|
|
313
|
+
History: 'Edit history',
|
|
314
|
+
DocumentID: 'Document ID',
|
|
315
|
+
InstanceID: 'Instance ID',
|
|
316
|
+
OriginalDocumentID: 'Original document ID',
|
|
317
|
+
DerivedFrom: 'Derived from',
|
|
318
|
+
DocumentAncestors: 'Source documents',
|
|
319
|
+
creator: 'Creator',
|
|
320
|
+
rights: 'Rights',
|
|
321
|
+
title: 'Title',
|
|
322
|
+
description: 'Description',
|
|
323
|
+
subject: 'Keywords',
|
|
324
|
+
MotionPhoto: 'Motion photo',
|
|
325
|
+
MicroVideo: 'Motion photo (legacy)',
|
|
326
|
+
MicroVideoOffset: 'Motion video size',
|
|
327
|
+
MotionPhotoPresentationTimestampUs: 'Motion photo frame time',
|
|
328
|
+
Directory: 'Embedded items',
|
|
329
|
+
Regions: 'Face regions',
|
|
330
|
+
RegionInfo: 'Tagged regions',
|
|
331
|
+
PersonInImage: 'People in image',
|
|
332
|
+
SerialNumber: 'Camera serial number',
|
|
333
|
+
LensSerialNumber: 'Lens serial number',
|
|
334
|
+
City: 'City',
|
|
335
|
+
State: 'State',
|
|
336
|
+
Country: 'Country',
|
|
337
|
+
Location: 'Location',
|
|
338
|
+
GPSLatitude: 'GPS latitude',
|
|
339
|
+
GPSLongitude: 'GPS longitude',
|
|
340
|
+
GPSAltitude: 'GPS altitude',
|
|
341
|
+
};
|
|
342
|
+
export function xmpFindings(a, source, removable = true) {
|
|
343
|
+
return a.properties.map((p) => ({
|
|
344
|
+
category: p.category,
|
|
345
|
+
source,
|
|
346
|
+
key: `xmp.${p.name}`,
|
|
347
|
+
label: PRETTY[p.name] ?? p.name.replace(/([a-z])([A-Z])/g, '$1 $2'),
|
|
348
|
+
value: p.value || '(present)',
|
|
349
|
+
removable,
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
/** An empty but valid XMP packet, padded with spaces to an exact byte length when possible. */
|
|
353
|
+
export function emptyXmpPacket(length) {
|
|
354
|
+
const head = "<?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?><x:xmpmeta xmlns:x='adobe:ns:meta/'/>";
|
|
355
|
+
const tail = "<?xpacket end='w'?>";
|
|
356
|
+
const min = head.length + tail.length;
|
|
357
|
+
if (length < min)
|
|
358
|
+
return new Uint8Array(length).fill(0x20);
|
|
359
|
+
const s = head + ' '.repeat(length - min) + tail;
|
|
360
|
+
const out = new Uint8Array(length);
|
|
361
|
+
for (let i = 0; i < s.length; i++)
|
|
362
|
+
out[i] = s.charCodeAt(i);
|
|
363
|
+
return out;
|
|
364
|
+
}
|