@sweberdev/witness 0.0.0-stage → 0.1.1
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 +21 -2
- package/dist/ai-sdk.cjs +921 -0
- package/dist/ai-sdk.cjs.map +1 -0
- package/dist/ai-sdk.d.cts +95 -0
- package/dist/ai-sdk.d.ts +95 -0
- package/dist/ai-sdk.js +150 -0
- package/dist/ai-sdk.js.map +1 -0
- package/dist/bin.cjs +944 -0
- package/dist/bin.cjs.map +1 -0
- package/dist/bin.d.cts +1 -0
- package/dist/bin.d.ts +1 -0
- package/dist/bin.js +12 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-APXU3MTX.js +958 -0
- package/dist/chunk-APXU3MTX.js.map +1 -0
- package/dist/chunk-L65R2SM5.js +228 -0
- package/dist/chunk-L65R2SM5.js.map +1 -0
- package/dist/chunk-O6TADCND.js +317 -0
- package/dist/chunk-O6TADCND.js.map +1 -0
- package/dist/chunk-UBRK4CTM.js +156 -0
- package/dist/chunk-UBRK4CTM.js.map +1 -0
- package/dist/chunk-XJOQ5IUT.js +110 -0
- package/dist/chunk-XJOQ5IUT.js.map +1 -0
- package/dist/cli.cjs +964 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +7 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +9 -0
- package/dist/cli.js.map +1 -0
- package/dist/disclosure-5a3UIVQZ.d.cts +70 -0
- package/dist/disclosure-C7Y_uY73.d.ts +70 -0
- package/dist/elements-auto.cjs +649 -0
- package/dist/elements-auto.cjs.map +1 -0
- package/dist/elements-auto.d.cts +3 -0
- package/dist/elements-auto.d.ts +3 -0
- package/dist/elements-auto.js +16 -0
- package/dist/elements-auto.js.map +1 -0
- package/dist/elements.cjs +646 -0
- package/dist/elements.cjs.map +1 -0
- package/dist/elements.d.cts +47 -0
- package/dist/elements.d.ts +47 -0
- package/dist/elements.js +13 -0
- package/dist/elements.js.map +1 -0
- package/dist/image-B7YvdITx.d.ts +49 -0
- package/dist/image-BNrEO-hj.d.cts +49 -0
- package/dist/index.cjs +1344 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +77 -0
- package/dist/index.js.map +1 -0
- package/dist/types-WWHzrNlc.d.cts +90 -0
- package/dist/types-WWHzrNlc.d.ts +90 -0
- package/package.json +123 -3
- package/src/ai-sdk.ts +252 -0
- package/src/bin.ts +6 -0
- package/src/cli.ts +160 -0
- package/src/disclosure.ts +172 -0
- package/src/elements-auto.ts +5 -0
- package/src/elements.ts +363 -0
- package/src/i18n.ts +259 -0
- package/src/image.ts +552 -0
- package/src/index.ts +66 -0
- package/src/inflate.ts +189 -0
- package/src/marking.ts +237 -0
- package/src/types.ts +115 -0
- package/src/watermark.ts +165 -0
- package/styles.css +103 -0
package/src/image.ts
ADDED
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine-readable AI marking for images: an XMP packet with the IPTC Digital Source Type,
|
|
3
|
+
* written into PNG, JPEG and WebP files without re-encoding the pixels.
|
|
4
|
+
*
|
|
5
|
+
* This is unsigned metadata. It is what IPTC, Google and most photo tools read today, but it
|
|
6
|
+
* is not a C2PA manifest and is easy to strip. Files that already carry a C2PA manifest are
|
|
7
|
+
* left untouched by default, because rewriting them would break the manifest's hash binding.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { inflateZlib } from "./inflate.js";
|
|
11
|
+
import {
|
|
12
|
+
createMarking,
|
|
13
|
+
generatorName,
|
|
14
|
+
iptcSourceType,
|
|
15
|
+
isAiSourceType,
|
|
16
|
+
parseSourceType,
|
|
17
|
+
} from "./marking.js";
|
|
18
|
+
import type { Marking, MarkingInput, SourceType } from "./types.js";
|
|
19
|
+
|
|
20
|
+
export type ImageFormat = "png" | "jpeg" | "webp";
|
|
21
|
+
|
|
22
|
+
export interface MarkImageOptions {
|
|
23
|
+
/**
|
|
24
|
+
* What to do with files that already carry a C2PA manifest. `skip` (default) returns them
|
|
25
|
+
* unchanged; `overwrite` writes the XMP anyway and breaks the manifest's hash binding.
|
|
26
|
+
*/
|
|
27
|
+
c2pa?: "skip" | "overwrite";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MarkImageResult {
|
|
31
|
+
bytes: Uint8Array;
|
|
32
|
+
status: "marked" | "skipped-c2pa" | "unsupported";
|
|
33
|
+
format: ImageFormat | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ImageMarkingInfo {
|
|
37
|
+
format: ImageFormat | null;
|
|
38
|
+
/** The XMP packet, if any. */
|
|
39
|
+
xmp: string | null;
|
|
40
|
+
/** IPTC Digital Source Type from the XMP. */
|
|
41
|
+
sourceType?: SourceType;
|
|
42
|
+
/** The source type says the image was made or changed by a trained AI model. */
|
|
43
|
+
aiGenerated: boolean;
|
|
44
|
+
/** `Iptc4xmpExt:AISystemUsed` or `xmp:CreatorTool`. */
|
|
45
|
+
generator?: string;
|
|
46
|
+
/** Written by Witness. */
|
|
47
|
+
witness: boolean;
|
|
48
|
+
/** A C2PA manifest is embedded. Witness does not verify it. */
|
|
49
|
+
c2pa: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const XMP_NS = "http://ns.adobe.com/xap/1.0/\0";
|
|
53
|
+
const WITNESS_NS = "https://packages.sweber.dev/witness/ns/1.0/";
|
|
54
|
+
|
|
55
|
+
/** Detects PNG, JPEG or WebP from the first bytes. */
|
|
56
|
+
export function detectImageFormat(bytes: Uint8Array): ImageFormat | null {
|
|
57
|
+
if (bytes.length >= 8 && bytes[0] === 0x89 && ascii(bytes, 1, 3) === "PNG") return "png";
|
|
58
|
+
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
59
|
+
return "jpeg";
|
|
60
|
+
}
|
|
61
|
+
if (bytes.length >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WEBP") {
|
|
62
|
+
return "webp";
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Writes the marking as XMP into a PNG, JPEG or WebP file. */
|
|
68
|
+
export function markImage(
|
|
69
|
+
bytes: Uint8Array,
|
|
70
|
+
input: Marking | MarkingInput = {},
|
|
71
|
+
options: MarkImageOptions = {},
|
|
72
|
+
): MarkImageResult {
|
|
73
|
+
const format = detectImageFormat(bytes);
|
|
74
|
+
if (!format) return { bytes, status: "unsupported", format };
|
|
75
|
+
if (options.c2pa !== "overwrite" && hasC2pa(bytes, format)) {
|
|
76
|
+
return { bytes, status: "skipped-c2pa", format };
|
|
77
|
+
}
|
|
78
|
+
const marking =
|
|
79
|
+
"sourceType" in input && "humanReviewed" in input ? (input as Marking) : createMarking(input);
|
|
80
|
+
const existing = readXmp(bytes, format);
|
|
81
|
+
const xmp = buildXmp(marking, existing);
|
|
82
|
+
const out =
|
|
83
|
+
format === "png"
|
|
84
|
+
? writePng(bytes, xmp)
|
|
85
|
+
: format === "jpeg"
|
|
86
|
+
? writeJpeg(bytes, xmp)
|
|
87
|
+
: writeWebp(bytes, xmp);
|
|
88
|
+
return { bytes: out, status: "marked", format };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Reads the AI marking of an image, whoever wrote it. */
|
|
92
|
+
export function readImageMarking(bytes: Uint8Array): ImageMarkingInfo {
|
|
93
|
+
const format = detectImageFormat(bytes);
|
|
94
|
+
if (!format) return { format, xmp: null, aiGenerated: false, witness: false, c2pa: false };
|
|
95
|
+
const xmp = readXmp(bytes, format);
|
|
96
|
+
const sourceType = xmp
|
|
97
|
+
? parseSourceType(xmpProperty(xmp, "Iptc4xmpExt:DigitalSourceType"))
|
|
98
|
+
: undefined;
|
|
99
|
+
const generator = xmp
|
|
100
|
+
? (xmpProperty(xmp, "Iptc4xmpExt:AISystemUsed") ?? xmpProperty(xmp, "xmp:CreatorTool"))
|
|
101
|
+
: undefined;
|
|
102
|
+
const info: ImageMarkingInfo = {
|
|
103
|
+
format,
|
|
104
|
+
xmp,
|
|
105
|
+
aiGenerated: isAiSourceType(sourceType),
|
|
106
|
+
witness: xmp?.includes(WITNESS_NS) ?? false,
|
|
107
|
+
c2pa: hasC2pa(bytes, format),
|
|
108
|
+
};
|
|
109
|
+
if (sourceType) info.sourceType = sourceType;
|
|
110
|
+
if (generator) info.generator = generator;
|
|
111
|
+
return info;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The XMP packet Witness writes, merged into an existing packet if there is one. */
|
|
115
|
+
export function buildXmp(marking: Marking, existing?: string | null): string {
|
|
116
|
+
const description = buildDescription(marking);
|
|
117
|
+
if (existing?.includes("</rdf:RDF>")) {
|
|
118
|
+
// Drop the description an earlier run wrote, in its self-closing or its paired form.
|
|
119
|
+
const cleaned = existing
|
|
120
|
+
.replace(/<rdf:Description\b[^>]*witness:marker="1"[^>]*\/>\s*/g, "")
|
|
121
|
+
.replace(
|
|
122
|
+
/<rdf:Description\b[^>]*witness:marker="1"[^>]*>[\s\S]*?<\/rdf:Description>\s*/g,
|
|
123
|
+
"",
|
|
124
|
+
);
|
|
125
|
+
// Our description declares its own namespaces, so it can sit next to any other.
|
|
126
|
+
return cleaned.replace("</rdf:RDF>", `${description}\n</rdf:RDF>`);
|
|
127
|
+
}
|
|
128
|
+
return [
|
|
129
|
+
'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>',
|
|
130
|
+
'<x:xmpmeta xmlns:x="adobe:ns:meta/">',
|
|
131
|
+
'<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">',
|
|
132
|
+
description,
|
|
133
|
+
"</rdf:RDF>",
|
|
134
|
+
"</x:xmpmeta>",
|
|
135
|
+
'<?xpacket end="w"?>',
|
|
136
|
+
].join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildDescription(m: Marking): string {
|
|
140
|
+
const attributes: Array<[string, string]> = [
|
|
141
|
+
["rdf:about", ""],
|
|
142
|
+
["xmlns:Iptc4xmpExt", "http://iptc.org/std/Iptc4xmpExt/2008-02-29/"],
|
|
143
|
+
["xmlns:xmp", "http://ns.adobe.com/xap/1.0/"],
|
|
144
|
+
["xmlns:dc", "http://purl.org/dc/elements/1.1/"],
|
|
145
|
+
["xmlns:witness", WITNESS_NS],
|
|
146
|
+
["witness:marker", "1"],
|
|
147
|
+
["witness:kind", m.kind],
|
|
148
|
+
["Iptc4xmpExt:DigitalSourceType", iptcSourceType(m.sourceType)],
|
|
149
|
+
["xmp:CreateDate", m.createdAt],
|
|
150
|
+
];
|
|
151
|
+
if (m.generator) {
|
|
152
|
+
attributes.push(["Iptc4xmpExt:AISystemUsed", m.generator]);
|
|
153
|
+
attributes.push(["xmp:CreatorTool", generatorName(m)]);
|
|
154
|
+
}
|
|
155
|
+
if (m.generatorVersion) attributes.push(["Iptc4xmpExt:AISystemVersionUsed", m.generatorVersion]);
|
|
156
|
+
if (m.provider) attributes.push(["witness:provider", m.provider]);
|
|
157
|
+
if (m.disclosureUrl) attributes.push(["witness:disclosureUrl", m.disclosureUrl]);
|
|
158
|
+
if (m.humanReviewed) attributes.push(["witness:humanReviewed", "True"]);
|
|
159
|
+
const attrs = attributes.map(([name, value]) => `\n ${name}="${escapeXml(value)}"`).join("");
|
|
160
|
+
const description = m.description
|
|
161
|
+
? `\n <dc:description><rdf:Alt><rdf:li xml:lang="x-default">${escapeXml(m.description)}</rdf:li></rdf:Alt></dc:description>\n`
|
|
162
|
+
: "";
|
|
163
|
+
return description
|
|
164
|
+
? `<rdf:Description${attrs}>${description}</rdf:Description>`
|
|
165
|
+
: `<rdf:Description${attrs}/>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Reads a simple property in attribute or element form. */
|
|
169
|
+
function xmpProperty(xmp: string, name: string): string | undefined {
|
|
170
|
+
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
171
|
+
const attribute = new RegExp(`${escaped}="([^"]*)"`).exec(xmp);
|
|
172
|
+
if (attribute?.[1] !== undefined) return unescapeXml(attribute[1]);
|
|
173
|
+
const element = new RegExp(
|
|
174
|
+
`<${escaped}(?:\\s+rdf:resource="([^"]*)")?\\s*(?:/>|>([\\s\\S]*?)</${escaped}>)`,
|
|
175
|
+
).exec(xmp);
|
|
176
|
+
if (!element) return undefined;
|
|
177
|
+
if (element[1] !== undefined) return unescapeXml(element[1]);
|
|
178
|
+
const inner = element[2] ?? "";
|
|
179
|
+
const li = /<rdf:li[^>]*>([\s\S]*?)<\/rdf:li>/.exec(inner);
|
|
180
|
+
const value = (li ? li[1] : inner)?.trim();
|
|
181
|
+
return value ? unescapeXml(value) : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------- PNG
|
|
185
|
+
|
|
186
|
+
function writePng(bytes: Uint8Array, xmp: string): Uint8Array {
|
|
187
|
+
const chunks = pngChunks(bytes).filter((chunk) => !isPngXmp(bytes, chunk));
|
|
188
|
+
const itxt = pngChunk(
|
|
189
|
+
"iTXt",
|
|
190
|
+
concat(latin1("XML:com.adobe.xmp"), new Uint8Array([0, 0, 0, 0, 0]), utf8(xmp)),
|
|
191
|
+
);
|
|
192
|
+
const parts: Uint8Array[] = [bytes.subarray(0, 8)];
|
|
193
|
+
for (const chunk of chunks) {
|
|
194
|
+
parts.push(bytes.subarray(chunk.offset, chunk.end));
|
|
195
|
+
if (chunk.type === "IHDR") parts.push(itxt);
|
|
196
|
+
}
|
|
197
|
+
return concat(...parts);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface PngChunk {
|
|
201
|
+
type: string;
|
|
202
|
+
offset: number;
|
|
203
|
+
dataStart: number;
|
|
204
|
+
dataEnd: number;
|
|
205
|
+
end: number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function pngChunks(bytes: Uint8Array): PngChunk[] {
|
|
209
|
+
const chunks: PngChunk[] = [];
|
|
210
|
+
let offset = 8;
|
|
211
|
+
while (offset + 12 <= bytes.length) {
|
|
212
|
+
const length = readU32BE(bytes, offset);
|
|
213
|
+
const type = ascii(bytes, offset + 4, 4);
|
|
214
|
+
const dataStart = offset + 8;
|
|
215
|
+
const dataEnd = dataStart + length;
|
|
216
|
+
const end = dataEnd + 4;
|
|
217
|
+
if (end > bytes.length) throw new Error("Witness: truncated PNG chunk");
|
|
218
|
+
chunks.push({ type, offset, dataStart, dataEnd, end });
|
|
219
|
+
offset = end;
|
|
220
|
+
if (type === "IEND") break;
|
|
221
|
+
}
|
|
222
|
+
return chunks;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** XMP lives in iTXt per the XMP spec; libvips and some other tools write zTXt instead. */
|
|
226
|
+
function isPngXmp(bytes: Uint8Array, chunk: PngChunk): boolean {
|
|
227
|
+
return (
|
|
228
|
+
(chunk.type === "iTXt" || chunk.type === "zTXt") &&
|
|
229
|
+
ascii(bytes, chunk.dataStart, 18) === "XML:com.adobe.xmp\0"
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function readPngXmp(bytes: Uint8Array): string | null {
|
|
234
|
+
for (const chunk of pngChunks(bytes)) {
|
|
235
|
+
if (!isPngXmp(bytes, chunk)) continue;
|
|
236
|
+
let p = chunk.dataStart + 18;
|
|
237
|
+
let compressed: boolean;
|
|
238
|
+
if (chunk.type === "zTXt") {
|
|
239
|
+
compressed = true;
|
|
240
|
+
p += 1; // compression method
|
|
241
|
+
} else {
|
|
242
|
+
compressed = bytes[p] === 1;
|
|
243
|
+
p += 2;
|
|
244
|
+
while (p < chunk.dataEnd && bytes[p] !== 0) p++; // language tag
|
|
245
|
+
p++;
|
|
246
|
+
while (p < chunk.dataEnd && bytes[p] !== 0) p++; // translated keyword
|
|
247
|
+
p++;
|
|
248
|
+
}
|
|
249
|
+
const data = bytes.subarray(p, chunk.dataEnd);
|
|
250
|
+
try {
|
|
251
|
+
return new TextDecoder().decode(compressed ? inflateZlib(data) : data);
|
|
252
|
+
} catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
|
260
|
+
const out = new Uint8Array(12 + data.length);
|
|
261
|
+
writeU32BE(out, 0, data.length);
|
|
262
|
+
out.set(latin1(type), 4);
|
|
263
|
+
out.set(data, 8);
|
|
264
|
+
writeU32BE(out, 8 + data.length, crc32(out.subarray(4, 8 + data.length)));
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------- JPEG
|
|
269
|
+
|
|
270
|
+
interface JpegSegment {
|
|
271
|
+
marker: number;
|
|
272
|
+
offset: number;
|
|
273
|
+
dataStart: number;
|
|
274
|
+
end: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function jpegSegments(bytes: Uint8Array): { segments: JpegSegment[]; scanStart: number } {
|
|
278
|
+
const segments: JpegSegment[] = [];
|
|
279
|
+
let offset = 2;
|
|
280
|
+
while (offset + 4 <= bytes.length) {
|
|
281
|
+
if (bytes[offset] !== 0xff) throw new Error("Witness: invalid JPEG marker");
|
|
282
|
+
let markerOffset = offset;
|
|
283
|
+
while (bytes[markerOffset + 1] === 0xff) markerOffset++; // fill bytes
|
|
284
|
+
const marker = bytes[markerOffset + 1] ?? 0;
|
|
285
|
+
if (marker === 0xda || marker === 0xd9) return { segments, scanStart: offset };
|
|
286
|
+
const length = readU16BE(bytes, markerOffset + 2);
|
|
287
|
+
const end = markerOffset + 2 + length;
|
|
288
|
+
if (end > bytes.length) throw new Error("Witness: truncated JPEG segment");
|
|
289
|
+
segments.push({ marker, offset, dataStart: markerOffset + 4, end });
|
|
290
|
+
offset = end;
|
|
291
|
+
}
|
|
292
|
+
return { segments, scanStart: offset };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isJpegXmp(bytes: Uint8Array, segment: JpegSegment): boolean {
|
|
296
|
+
return segment.marker === 0xe1 && ascii(bytes, segment.dataStart, XMP_NS.length) === XMP_NS;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function writeJpeg(bytes: Uint8Array, xmp: string): Uint8Array {
|
|
300
|
+
const { segments, scanStart } = jpegSegments(bytes);
|
|
301
|
+
const payload = concat(latin1(XMP_NS), utf8(xmp));
|
|
302
|
+
if (payload.length + 2 > 0xffff) throw new Error("Witness: XMP packet too large for JPEG");
|
|
303
|
+
const app1 = new Uint8Array(4 + payload.length);
|
|
304
|
+
app1[0] = 0xff;
|
|
305
|
+
app1[1] = 0xe1;
|
|
306
|
+
app1[2] = ((payload.length + 2) >> 8) & 0xff;
|
|
307
|
+
app1[3] = (payload.length + 2) & 0xff;
|
|
308
|
+
app1.set(payload, 4);
|
|
309
|
+
|
|
310
|
+
const kept = segments.filter((segment) => !isJpegXmp(bytes, segment));
|
|
311
|
+
// Keep JFIF (APP0) and Exif (APP1) first, as readers expect them there.
|
|
312
|
+
let insertAt = 0;
|
|
313
|
+
while (insertAt < kept.length) {
|
|
314
|
+
const segment = kept[insertAt];
|
|
315
|
+
if (!segment) break;
|
|
316
|
+
const isJfif = segment.marker === 0xe0;
|
|
317
|
+
const isExif = segment.marker === 0xe1 && ascii(bytes, segment.dataStart, 4) === "Exif";
|
|
318
|
+
if (!isJfif && !isExif) break;
|
|
319
|
+
insertAt++;
|
|
320
|
+
}
|
|
321
|
+
const parts: Uint8Array[] = [bytes.subarray(0, 2)];
|
|
322
|
+
kept.forEach((segment, index) => {
|
|
323
|
+
if (index === insertAt) parts.push(app1);
|
|
324
|
+
parts.push(bytes.subarray(segment.offset, segment.end));
|
|
325
|
+
});
|
|
326
|
+
if (insertAt >= kept.length) parts.push(app1);
|
|
327
|
+
parts.push(bytes.subarray(scanStart));
|
|
328
|
+
return concat(...parts);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function readJpegXmp(bytes: Uint8Array): string | null {
|
|
332
|
+
for (const segment of jpegSegments(bytes).segments) {
|
|
333
|
+
if (isJpegXmp(bytes, segment)) {
|
|
334
|
+
return new TextDecoder().decode(
|
|
335
|
+
bytes.subarray(segment.dataStart + XMP_NS.length, segment.end),
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------- WebP
|
|
343
|
+
|
|
344
|
+
interface RiffChunk {
|
|
345
|
+
fourcc: string;
|
|
346
|
+
offset: number;
|
|
347
|
+
dataStart: number;
|
|
348
|
+
size: number;
|
|
349
|
+
end: number;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function webpChunks(bytes: Uint8Array): RiffChunk[] {
|
|
353
|
+
const chunks: RiffChunk[] = [];
|
|
354
|
+
const riffEnd = Math.min(bytes.length, 8 + readU32LE(bytes, 4));
|
|
355
|
+
let offset = 12;
|
|
356
|
+
while (offset + 8 <= riffEnd) {
|
|
357
|
+
const fourcc = ascii(bytes, offset, 4);
|
|
358
|
+
const size = readU32LE(bytes, offset + 4);
|
|
359
|
+
const dataStart = offset + 8;
|
|
360
|
+
const end = dataStart + size + (size % 2);
|
|
361
|
+
if (dataStart + size > bytes.length) throw new Error("Witness: truncated WebP chunk");
|
|
362
|
+
chunks.push({ fourcc, offset, dataStart, size, end: Math.min(end, bytes.length) });
|
|
363
|
+
offset = end;
|
|
364
|
+
}
|
|
365
|
+
return chunks;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function writeWebp(bytes: Uint8Array, xmp: string): Uint8Array {
|
|
369
|
+
const chunks = webpChunks(bytes).filter((chunk) => chunk.fourcc !== "XMP ");
|
|
370
|
+
const first = chunks[0];
|
|
371
|
+
if (!first) throw new Error("Witness: empty WebP file");
|
|
372
|
+
const parts: Uint8Array[] = [];
|
|
373
|
+
if (first.fourcc === "VP8X") {
|
|
374
|
+
const vp8x = bytes.slice(first.offset, first.end);
|
|
375
|
+
vp8x[8] = (vp8x[8] ?? 0) | 0x04; // XMP flag
|
|
376
|
+
parts.push(vp8x);
|
|
377
|
+
for (const chunk of chunks.slice(1)) parts.push(bytes.subarray(chunk.offset, chunk.end));
|
|
378
|
+
} else {
|
|
379
|
+
const { width, height, alpha } = webpCanvas(bytes, first);
|
|
380
|
+
const data = new Uint8Array(10);
|
|
381
|
+
data[0] = 0x04 | (alpha ? 0x10 : 0);
|
|
382
|
+
writeU24LE(data, 4, width - 1);
|
|
383
|
+
writeU24LE(data, 7, height - 1);
|
|
384
|
+
parts.push(riffChunk("VP8X", data));
|
|
385
|
+
for (const chunk of chunks) parts.push(bytes.subarray(chunk.offset, chunk.end));
|
|
386
|
+
}
|
|
387
|
+
parts.push(riffChunk("XMP ", utf8(xmp)));
|
|
388
|
+
const body = concat(...parts);
|
|
389
|
+
const out = new Uint8Array(12 + body.length);
|
|
390
|
+
out.set(latin1("RIFF"), 0);
|
|
391
|
+
writeU32LE(out, 4, 4 + body.length);
|
|
392
|
+
out.set(latin1("WEBP"), 8);
|
|
393
|
+
out.set(body, 12);
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function webpCanvas(
|
|
398
|
+
bytes: Uint8Array,
|
|
399
|
+
chunk: RiffChunk,
|
|
400
|
+
): { width: number; height: number; alpha: boolean } {
|
|
401
|
+
const d = chunk.dataStart;
|
|
402
|
+
if (chunk.fourcc === "VP8 ") {
|
|
403
|
+
if (bytes[d + 3] !== 0x9d || bytes[d + 4] !== 0x01 || bytes[d + 5] !== 0x2a) {
|
|
404
|
+
throw new Error("Witness: invalid VP8 frame");
|
|
405
|
+
}
|
|
406
|
+
return {
|
|
407
|
+
width: readU16LE(bytes, d + 6) & 0x3fff,
|
|
408
|
+
height: readU16LE(bytes, d + 8) & 0x3fff,
|
|
409
|
+
alpha: false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
if (chunk.fourcc === "VP8L") {
|
|
413
|
+
if (bytes[d] !== 0x2f) throw new Error("Witness: invalid VP8L frame");
|
|
414
|
+
const bits = readU32LE(bytes, d + 1);
|
|
415
|
+
return {
|
|
416
|
+
width: (bits & 0x3fff) + 1,
|
|
417
|
+
height: ((bits >>> 14) & 0x3fff) + 1,
|
|
418
|
+
alpha: ((bits >>> 28) & 1) === 1,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`Witness: unexpected WebP chunk ${chunk.fourcc}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function readWebpXmp(bytes: Uint8Array): string | null {
|
|
425
|
+
const chunk = webpChunks(bytes).find((c) => c.fourcc === "XMP ");
|
|
426
|
+
return chunk
|
|
427
|
+
? new TextDecoder().decode(bytes.subarray(chunk.dataStart, chunk.dataStart + chunk.size))
|
|
428
|
+
: null;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function riffChunk(fourcc: string, data: Uint8Array): Uint8Array {
|
|
432
|
+
const out = new Uint8Array(8 + data.length + (data.length % 2));
|
|
433
|
+
out.set(latin1(fourcc), 0);
|
|
434
|
+
writeU32LE(out, 4, data.length);
|
|
435
|
+
out.set(data, 8);
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------- shared
|
|
440
|
+
|
|
441
|
+
function readXmp(bytes: Uint8Array, format: ImageFormat): string | null {
|
|
442
|
+
if (format === "png") return readPngXmp(bytes);
|
|
443
|
+
if (format === "jpeg") return readJpegXmp(bytes);
|
|
444
|
+
return readWebpXmp(bytes);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** C2PA manifests live in caBX (PNG), APP11 JUMBF (JPEG) or C2PA (WebP) chunks. */
|
|
448
|
+
function hasC2pa(bytes: Uint8Array, format: ImageFormat): boolean {
|
|
449
|
+
if (format === "png") return pngChunks(bytes).some((chunk) => chunk.type === "caBX");
|
|
450
|
+
if (format === "webp") return webpChunks(bytes).some((chunk) => chunk.fourcc === "C2PA");
|
|
451
|
+
return jpegSegments(bytes).segments.some(
|
|
452
|
+
(segment) =>
|
|
453
|
+
segment.marker === 0xeb &&
|
|
454
|
+
ascii(bytes, segment.dataStart, Math.min(64, segment.end - segment.dataStart)).includes(
|
|
455
|
+
"c2pa",
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function ascii(bytes: Uint8Array, offset: number, length: number): string {
|
|
461
|
+
let out = "";
|
|
462
|
+
for (let i = offset; i < offset + length && i < bytes.length; i++) {
|
|
463
|
+
out += String.fromCharCode(bytes[i] ?? 0);
|
|
464
|
+
}
|
|
465
|
+
return out;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function latin1(text: string): Uint8Array {
|
|
469
|
+
const out = new Uint8Array(text.length);
|
|
470
|
+
for (let i = 0; i < text.length; i++) out[i] = text.charCodeAt(i) & 0xff;
|
|
471
|
+
return out;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function utf8(text: string): Uint8Array {
|
|
475
|
+
return new TextEncoder().encode(text);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function concat(...parts: Uint8Array[]): Uint8Array {
|
|
479
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
480
|
+
let offset = 0;
|
|
481
|
+
for (const part of parts) {
|
|
482
|
+
out.set(part, offset);
|
|
483
|
+
offset += part.length;
|
|
484
|
+
}
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function readU16BE(b: Uint8Array, o: number): number {
|
|
489
|
+
return ((b[o] ?? 0) << 8) | (b[o + 1] ?? 0);
|
|
490
|
+
}
|
|
491
|
+
function readU16LE(b: Uint8Array, o: number): number {
|
|
492
|
+
return (b[o] ?? 0) | ((b[o + 1] ?? 0) << 8);
|
|
493
|
+
}
|
|
494
|
+
function readU32BE(b: Uint8Array, o: number): number {
|
|
495
|
+
return (
|
|
496
|
+
(((b[o] ?? 0) << 24) | ((b[o + 1] ?? 0) << 16) | ((b[o + 2] ?? 0) << 8) | (b[o + 3] ?? 0)) >>> 0
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
function readU32LE(b: Uint8Array, o: number): number {
|
|
500
|
+
return (
|
|
501
|
+
((b[o] ?? 0) | ((b[o + 1] ?? 0) << 8) | ((b[o + 2] ?? 0) << 16) | ((b[o + 3] ?? 0) << 24)) >>> 0
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
function writeU32BE(b: Uint8Array, o: number, v: number): void {
|
|
505
|
+
b[o] = (v >>> 24) & 0xff;
|
|
506
|
+
b[o + 1] = (v >>> 16) & 0xff;
|
|
507
|
+
b[o + 2] = (v >>> 8) & 0xff;
|
|
508
|
+
b[o + 3] = v & 0xff;
|
|
509
|
+
}
|
|
510
|
+
function writeU32LE(b: Uint8Array, o: number, v: number): void {
|
|
511
|
+
b[o] = v & 0xff;
|
|
512
|
+
b[o + 1] = (v >>> 8) & 0xff;
|
|
513
|
+
b[o + 2] = (v >>> 16) & 0xff;
|
|
514
|
+
b[o + 3] = (v >>> 24) & 0xff;
|
|
515
|
+
}
|
|
516
|
+
function writeU24LE(b: Uint8Array, o: number, v: number): void {
|
|
517
|
+
b[o] = v & 0xff;
|
|
518
|
+
b[o + 1] = (v >>> 8) & 0xff;
|
|
519
|
+
b[o + 2] = (v >>> 16) & 0xff;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
let crcTable: Uint32Array | null = null;
|
|
523
|
+
function crc32(data: Uint8Array): number {
|
|
524
|
+
if (!crcTable) {
|
|
525
|
+
crcTable = new Uint32Array(256);
|
|
526
|
+
for (let n = 0; n < 256; n++) {
|
|
527
|
+
let c = n;
|
|
528
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
529
|
+
crcTable[n] = c >>> 0;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
let crc = 0xffffffff;
|
|
533
|
+
for (const byte of data) crc = (crcTable[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
|
534
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function escapeXml(value: string): string {
|
|
538
|
+
return value
|
|
539
|
+
.replace(/&/g, "&")
|
|
540
|
+
.replace(/</g, "<")
|
|
541
|
+
.replace(/>/g, ">")
|
|
542
|
+
.replace(/"/g, """);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function unescapeXml(value: string): string {
|
|
546
|
+
return value
|
|
547
|
+
.replace(/"/g, '"')
|
|
548
|
+
.replace(/</g, "<")
|
|
549
|
+
.replace(/>/g, ">")
|
|
550
|
+
.replace(/'/g, "'")
|
|
551
|
+
.replace(/&/g, "&");
|
|
552
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createDisclosure,
|
|
3
|
+
type Disclosure,
|
|
4
|
+
type DisclosureEvent,
|
|
5
|
+
type DisclosureOptions,
|
|
6
|
+
type DisclosureStorage,
|
|
7
|
+
memoryStorage,
|
|
8
|
+
} from "./disclosure.js";
|
|
9
|
+
export {
|
|
10
|
+
BUILT_IN_LOCALES,
|
|
11
|
+
disclosureText,
|
|
12
|
+
format,
|
|
13
|
+
getMessages,
|
|
14
|
+
registeredLocales,
|
|
15
|
+
registerLocale,
|
|
16
|
+
resolveLocale,
|
|
17
|
+
} from "./i18n.js";
|
|
18
|
+
export {
|
|
19
|
+
buildXmp,
|
|
20
|
+
detectImageFormat,
|
|
21
|
+
type ImageFormat,
|
|
22
|
+
type ImageMarkingInfo,
|
|
23
|
+
type MarkImageOptions,
|
|
24
|
+
type MarkImageResult,
|
|
25
|
+
markImage,
|
|
26
|
+
readImageMarking,
|
|
27
|
+
} from "./image.js";
|
|
28
|
+
export {
|
|
29
|
+
createMarking,
|
|
30
|
+
escapeHtml,
|
|
31
|
+
generatorName,
|
|
32
|
+
iptcSourceType,
|
|
33
|
+
isAiSourceType,
|
|
34
|
+
type LabelHtmlOptions,
|
|
35
|
+
labelHtml,
|
|
36
|
+
labelText,
|
|
37
|
+
markingAttributes,
|
|
38
|
+
markingJsonLd,
|
|
39
|
+
markingMetaTags,
|
|
40
|
+
nextMetadata,
|
|
41
|
+
parseSourceType,
|
|
42
|
+
renderJsonLd,
|
|
43
|
+
renderMetaTags,
|
|
44
|
+
schemaOrgSourceType,
|
|
45
|
+
} from "./marking.js";
|
|
46
|
+
export {
|
|
47
|
+
type ContentKind,
|
|
48
|
+
DISCLOSURE_KINDS,
|
|
49
|
+
type DisclosureKind,
|
|
50
|
+
type DisclosureText,
|
|
51
|
+
type LocaleMessages,
|
|
52
|
+
type LocaleOverride,
|
|
53
|
+
type Marking,
|
|
54
|
+
type MarkingInput,
|
|
55
|
+
type SourceType,
|
|
56
|
+
type UiText,
|
|
57
|
+
} from "./types.js";
|
|
58
|
+
export {
|
|
59
|
+
hasTextWatermark,
|
|
60
|
+
type ReadTextWatermark,
|
|
61
|
+
readTextWatermark,
|
|
62
|
+
stripTextWatermark,
|
|
63
|
+
type TextWatermark,
|
|
64
|
+
watermarkSuffix,
|
|
65
|
+
watermarkText,
|
|
66
|
+
} from "./watermark.js";
|