@jxsuite/site 2.5.1 → 2.7.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/package.json +6 -4
- package/src/compose.ts +35 -5
- package/src/context.ts +33 -9
- package/src/serve.ts +50 -4
- package/src/shell.ts +29 -7
- package/src/xmp-containers.ts +542 -0
- package/src/xmp-packet.ts +727 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where each image format keeps its XMP packet, and how to put a new one there.
|
|
3
|
+
*
|
|
4
|
+
* Each format has exactly one place the XMP specification (Part 3, "Storage in files") or the
|
|
5
|
+
* format's own specification names, and that is the only place read or written:
|
|
6
|
+
*
|
|
7
|
+
* - **JPEG**: an `APP1` segment whose payload starts `http://ns.adobe.com/xap/1.0/\0`, placed after
|
|
8
|
+
* the `APP0`/Exif `APP1` segments at the head of the file. Extended XMP segments are left alone.
|
|
9
|
+
* - **PNG**: an `iTXt` chunk with the keyword `XML:com.adobe.xmp`, uncompressed, before `IDAT`.
|
|
10
|
+
* - **WebP**: an `XMP ` chunk in the extended file format, flagged in `VP8X`; a simple (lossy or
|
|
11
|
+
* lossless) file is converted to the extended form, its canvas taken from the bitstream header.
|
|
12
|
+
* - **SVG**: an `x:xmpmeta` inside the root's `<metadata>` element. The file is text, so the packet
|
|
13
|
+
* is spliced into the source rather than re-serialized: the author's own formatting is kept.
|
|
14
|
+
*
|
|
15
|
+
* Any other format (GIF, AVIF, HEIC, TIFF, every video) has no writer here, and {@link xmpFormatOf}
|
|
16
|
+
* answers `null` for it: the Media workspace then says the file cannot hold alt text rather than
|
|
17
|
+
* pretending to save it.
|
|
18
|
+
*
|
|
19
|
+
* @docs studio/projects/media
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export type XmpFormat = "jpeg" | "png" | "webp" | "svg";
|
|
23
|
+
|
|
24
|
+
/** Why bytes could not be read or rewritten. */
|
|
25
|
+
export class XmpContainerError extends Error {
|
|
26
|
+
override name = "XmpContainerError";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const FORMAT_BY_EXTENSION = new Map<string, XmpFormat>([
|
|
30
|
+
[".jpeg", "jpeg"],
|
|
31
|
+
[".jpg", "jpeg"],
|
|
32
|
+
[".png", "png"],
|
|
33
|
+
[".svg", "svg"],
|
|
34
|
+
[".webp", "webp"],
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/** The container a path's extension names, when Studio can read and write its XMP. */
|
|
38
|
+
export function xmpFormatOf(path: string): XmpFormat | null {
|
|
39
|
+
const dot = path.lastIndexOf(".");
|
|
40
|
+
return FORMAT_BY_EXTENSION.get(dot > 0 ? path.slice(dot).toLowerCase() : "") ?? null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const utf8 = new TextEncoder();
|
|
44
|
+
const fromUtf8 = new TextDecoder("utf-8", { fatal: true });
|
|
45
|
+
|
|
46
|
+
function decode(bytes: Uint8Array): string {
|
|
47
|
+
try {
|
|
48
|
+
return fromUtf8.decode(bytes);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new XmpContainerError("The metadata is not valid UTF-8.");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function concat(parts: readonly Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
55
|
+
const out = new Uint8Array(parts.reduce((n, part) => n + part.length, 0));
|
|
56
|
+
let at = 0;
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
out.set(part, at);
|
|
59
|
+
at += part.length;
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function view(bytes: Uint8Array): DataView {
|
|
65
|
+
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a flag bit is set in a byte: a bit test by arithmetic, as the lint asks. */
|
|
69
|
+
function hasFlag(byte: number, flag: number): boolean {
|
|
70
|
+
return Math.floor(byte / flag) % 2 === 1;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ascii(bytes: Uint8Array, at: number, length: number): string {
|
|
74
|
+
return String.fromCodePoint(...bytes.subarray(at, at + length));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The packet in `bytes`, or `null` when the file has none. */
|
|
78
|
+
export function extractXmp(bytes: Uint8Array, format: XmpFormat): string | null {
|
|
79
|
+
switch (format) {
|
|
80
|
+
case "jpeg": {
|
|
81
|
+
return jpegXmp(bytes).packet;
|
|
82
|
+
}
|
|
83
|
+
case "png": {
|
|
84
|
+
return pngXmp(bytes).packet;
|
|
85
|
+
}
|
|
86
|
+
case "webp": {
|
|
87
|
+
return webpXmp(bytes).packet;
|
|
88
|
+
}
|
|
89
|
+
default: {
|
|
90
|
+
return svgXmp(decode(bytes)).packet;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `bytes` with its packet replaced by `packet` (or given one). */
|
|
96
|
+
export function embedXmp(
|
|
97
|
+
bytes: Uint8Array,
|
|
98
|
+
format: XmpFormat,
|
|
99
|
+
packet: string,
|
|
100
|
+
): Uint8Array<ArrayBuffer> {
|
|
101
|
+
switch (format) {
|
|
102
|
+
case "jpeg": {
|
|
103
|
+
return jpegEmbed(bytes, packet);
|
|
104
|
+
}
|
|
105
|
+
case "png": {
|
|
106
|
+
return pngEmbed(bytes, packet);
|
|
107
|
+
}
|
|
108
|
+
case "webp": {
|
|
109
|
+
return webpEmbed(bytes, packet);
|
|
110
|
+
}
|
|
111
|
+
default: {
|
|
112
|
+
return utf8.encode(svgEmbed(decode(bytes), packet));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── JPEG ────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const JPEG_XMP_HEADER = utf8.encode("http://ns.adobe.com/xap/1.0/\0");
|
|
120
|
+
/** The largest payload one segment holds: a 16-bit length that counts its own two bytes. */
|
|
121
|
+
const JPEG_SEGMENT_MAX = 0xff_ff - 2;
|
|
122
|
+
|
|
123
|
+
interface JpegSegment {
|
|
124
|
+
marker: number;
|
|
125
|
+
start: number;
|
|
126
|
+
end: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The marker segments before the scan data. */
|
|
130
|
+
function jpegSegments(bytes: Uint8Array): JpegSegment[] {
|
|
131
|
+
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
|
132
|
+
throw new XmpContainerError("Not a JPEG file.");
|
|
133
|
+
}
|
|
134
|
+
const segments: JpegSegment[] = [];
|
|
135
|
+
let at = 2;
|
|
136
|
+
while (at + 4 <= bytes.length) {
|
|
137
|
+
if (bytes[at] !== 0xff) {
|
|
138
|
+
throw new XmpContainerError("The JPEG's markers are damaged.");
|
|
139
|
+
}
|
|
140
|
+
const marker = bytes[at + 1] ?? 0;
|
|
141
|
+
if (marker === 0xff) {
|
|
142
|
+
// Fill byte.
|
|
143
|
+
at += 1;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (marker === 0xda || marker === 0xd9) {
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
const length = view(bytes).getUint16(at + 2);
|
|
150
|
+
const end = at + 2 + length;
|
|
151
|
+
if (length < 2 || end > bytes.length) {
|
|
152
|
+
throw new XmpContainerError("The JPEG's markers are damaged.");
|
|
153
|
+
}
|
|
154
|
+
segments.push({ end, marker, start: at });
|
|
155
|
+
at = end;
|
|
156
|
+
}
|
|
157
|
+
return segments;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isJpegXmp(bytes: Uint8Array, segment: JpegSegment): boolean {
|
|
161
|
+
const payload = segment.start + 4;
|
|
162
|
+
return (
|
|
163
|
+
segment.marker === 0xe1 &&
|
|
164
|
+
segment.end - payload >= JPEG_XMP_HEADER.length &&
|
|
165
|
+
JPEG_XMP_HEADER.every((byte, i) => bytes[payload + i] === byte)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
interface Found<T> {
|
|
170
|
+
packet: string | null;
|
|
171
|
+
parts: T[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function jpegXmp(bytes: Uint8Array): Found<JpegSegment> {
|
|
175
|
+
const segments = jpegSegments(bytes);
|
|
176
|
+
const found = segments.find((segment) => isJpegXmp(bytes, segment));
|
|
177
|
+
const packet =
|
|
178
|
+
found === undefined
|
|
179
|
+
? null
|
|
180
|
+
: decode(bytes.subarray(found.start + 4 + JPEG_XMP_HEADER.length, found.end));
|
|
181
|
+
return { packet, parts: segments };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function jpegEmbed(bytes: Uint8Array, packet: string): Uint8Array<ArrayBuffer> {
|
|
185
|
+
const { parts: segments } = jpegXmp(bytes);
|
|
186
|
+
const body = utf8.encode(packet);
|
|
187
|
+
const length = JPEG_XMP_HEADER.length + body.length;
|
|
188
|
+
if (length > JPEG_SEGMENT_MAX) {
|
|
189
|
+
throw new XmpContainerError("The metadata is too large for one JPEG segment.");
|
|
190
|
+
}
|
|
191
|
+
const head = Uint8Array.of(0xff, 0xe1, 0, 0);
|
|
192
|
+
view(head).setUint16(2, length + 2);
|
|
193
|
+
const segment = concat([head, JPEG_XMP_HEADER, body]);
|
|
194
|
+
// After the leading APP0 (JFIF) and Exif APP1 segments, where readers look first.
|
|
195
|
+
let insertAt = 2;
|
|
196
|
+
for (const each of segments) {
|
|
197
|
+
if ((each.marker === 0xe0 || each.marker === 0xe1) && !isJpegXmp(bytes, each)) {
|
|
198
|
+
insertAt = each.end;
|
|
199
|
+
} else {
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const parts: Uint8Array[] = [bytes.subarray(0, insertAt), segment];
|
|
204
|
+
let at = insertAt;
|
|
205
|
+
for (const each of segments) {
|
|
206
|
+
if (each.start >= insertAt && isJpegXmp(bytes, each)) {
|
|
207
|
+
parts.push(bytes.subarray(at, each.start));
|
|
208
|
+
at = each.end;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
parts.push(bytes.subarray(at));
|
|
212
|
+
return concat(parts);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ─── PNG ─────────────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const PNG_SIGNATURE = Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a);
|
|
218
|
+
const PNG_XMP_KEYWORD = "XML:com.adobe.xmp";
|
|
219
|
+
|
|
220
|
+
interface PngChunk {
|
|
221
|
+
type: string;
|
|
222
|
+
start: number;
|
|
223
|
+
dataStart: number;
|
|
224
|
+
end: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let crcTable: Uint32Array | null = null;
|
|
228
|
+
|
|
229
|
+
/** CRC-32 as PNG computes it (ISO 3309), over the chunk type and data. */
|
|
230
|
+
/* oxlint-disable no-bitwise -- CRC-32 IS bitwise arithmetic over 32-bit words; written any other
|
|
231
|
+
way it is the same operations, slower and harder to check against the specification. */
|
|
232
|
+
export function crc32(bytes: Uint8Array): number {
|
|
233
|
+
if (crcTable === null) {
|
|
234
|
+
crcTable = new Uint32Array(256);
|
|
235
|
+
for (let n = 0; n < 256; n++) {
|
|
236
|
+
let c = n;
|
|
237
|
+
for (let k = 0; k < 8; k++) {
|
|
238
|
+
c = c & 1 ? 0xed_b8_83_20 ^ (c >>> 1) : c >>> 1;
|
|
239
|
+
}
|
|
240
|
+
crcTable[n] = c >>> 0;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let crc = 0xff_ff_ff_ff;
|
|
244
|
+
for (const byte of bytes) {
|
|
245
|
+
crc = (crcTable[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
|
246
|
+
}
|
|
247
|
+
return (crc ^ 0xff_ff_ff_ff) >>> 0;
|
|
248
|
+
}
|
|
249
|
+
/* oxlint-enable no-bitwise */
|
|
250
|
+
|
|
251
|
+
function pngChunks(bytes: Uint8Array): PngChunk[] {
|
|
252
|
+
if (!PNG_SIGNATURE.every((byte, i) => bytes[i] === byte)) {
|
|
253
|
+
throw new XmpContainerError("Not a PNG file.");
|
|
254
|
+
}
|
|
255
|
+
const chunks: PngChunk[] = [];
|
|
256
|
+
let at = PNG_SIGNATURE.length;
|
|
257
|
+
while (at + 12 <= bytes.length) {
|
|
258
|
+
const length = view(bytes).getUint32(at);
|
|
259
|
+
const end = at + 12 + length;
|
|
260
|
+
if (end > bytes.length) {
|
|
261
|
+
throw new XmpContainerError("The PNG's chunks are damaged.");
|
|
262
|
+
}
|
|
263
|
+
const type = ascii(bytes, at + 4, 4);
|
|
264
|
+
chunks.push({ dataStart: at + 8, end, start: at, type });
|
|
265
|
+
at = end;
|
|
266
|
+
if (type === "IEND") {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return chunks;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** An `iTXt` chunk's text when its keyword is XMP's, else `null`. */
|
|
274
|
+
function pngChunkXmp(bytes: Uint8Array, chunk: PngChunk): string | null {
|
|
275
|
+
if (chunk.type !== "iTXt") {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const data = bytes.subarray(chunk.dataStart, chunk.end - 4);
|
|
279
|
+
const keywordEnd = data.indexOf(0);
|
|
280
|
+
if (keywordEnd === -1 || ascii(data, 0, keywordEnd) !== PNG_XMP_KEYWORD) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
if (data[keywordEnd + 1] !== 0) {
|
|
284
|
+
throw new XmpContainerError("The PNG's metadata is compressed, which Studio does not rewrite.");
|
|
285
|
+
}
|
|
286
|
+
// Keyword \0, compression flag, method, language \0, translated keyword \0, then the text.
|
|
287
|
+
const languageEnd = data.indexOf(0, keywordEnd + 3);
|
|
288
|
+
const translatedEnd = languageEnd === -1 ? -1 : data.indexOf(0, languageEnd + 1);
|
|
289
|
+
if (translatedEnd === -1) {
|
|
290
|
+
throw new XmpContainerError("The PNG's metadata chunk is damaged.");
|
|
291
|
+
}
|
|
292
|
+
return decode(data.subarray(translatedEnd + 1));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function pngXmp(bytes: Uint8Array): Found<PngChunk> {
|
|
296
|
+
const chunks = pngChunks(bytes);
|
|
297
|
+
const packets = chunks.map((chunk) => pngChunkXmp(bytes, chunk));
|
|
298
|
+
return { packet: packets.find((packet) => packet !== null) ?? null, parts: chunks };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function pngEmbed(bytes: Uint8Array, packet: string): Uint8Array<ArrayBuffer> {
|
|
302
|
+
const { parts: chunks } = pngXmp(bytes);
|
|
303
|
+
const data = concat([
|
|
304
|
+
utf8.encode(PNG_XMP_KEYWORD),
|
|
305
|
+
Uint8Array.of(0, 0, 0, 0, 0),
|
|
306
|
+
utf8.encode(packet),
|
|
307
|
+
]);
|
|
308
|
+
const typeAndData = concat([utf8.encode("iTXt"), data]);
|
|
309
|
+
const chunk = new Uint8Array(12 + data.length);
|
|
310
|
+
view(chunk).setUint32(0, data.length);
|
|
311
|
+
chunk.set(typeAndData, 4);
|
|
312
|
+
view(chunk).setUint32(8 + data.length, crc32(typeAndData));
|
|
313
|
+
const firstData = chunks.find((each) => each.type === "IDAT");
|
|
314
|
+
if (firstData === undefined) {
|
|
315
|
+
throw new XmpContainerError("The PNG has no image data.");
|
|
316
|
+
}
|
|
317
|
+
const parts: Uint8Array[] = [bytes.subarray(0, PNG_SIGNATURE.length)];
|
|
318
|
+
for (const each of chunks) {
|
|
319
|
+
if (each === firstData) {
|
|
320
|
+
parts.push(chunk);
|
|
321
|
+
}
|
|
322
|
+
if (pngChunkXmp(bytes, each) === null) {
|
|
323
|
+
parts.push(bytes.subarray(each.start, each.end));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const last = chunks.at(-1);
|
|
327
|
+
parts.push(bytes.subarray(last === undefined ? PNG_SIGNATURE.length : last.end));
|
|
328
|
+
return concat(parts);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── WebP ────────────────────────────────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
interface RiffChunk {
|
|
334
|
+
fourcc: string;
|
|
335
|
+
start: number;
|
|
336
|
+
dataStart: number;
|
|
337
|
+
size: number;
|
|
338
|
+
/** The end including the pad byte. */
|
|
339
|
+
end: number;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const VP8X_XMP = 0x04;
|
|
343
|
+
const VP8X_ALPHA = 0x10;
|
|
344
|
+
|
|
345
|
+
function webpChunks(bytes: Uint8Array): RiffChunk[] {
|
|
346
|
+
if (ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") {
|
|
347
|
+
throw new XmpContainerError("Not a WebP file.");
|
|
348
|
+
}
|
|
349
|
+
const riffEnd = Math.min(bytes.length, 8 + view(bytes).getUint32(4, true));
|
|
350
|
+
const chunks: RiffChunk[] = [];
|
|
351
|
+
let at = 12;
|
|
352
|
+
while (at + 8 <= riffEnd) {
|
|
353
|
+
const size = view(bytes).getUint32(at + 4, true);
|
|
354
|
+
const dataStart = at + 8;
|
|
355
|
+
const end = dataStart + size + (size % 2);
|
|
356
|
+
if (dataStart + size > riffEnd) {
|
|
357
|
+
throw new XmpContainerError("The WebP's chunks are damaged.");
|
|
358
|
+
}
|
|
359
|
+
chunks.push({
|
|
360
|
+
dataStart,
|
|
361
|
+
end: Math.min(end, riffEnd),
|
|
362
|
+
fourcc: ascii(bytes, at, 4),
|
|
363
|
+
size,
|
|
364
|
+
start: at,
|
|
365
|
+
});
|
|
366
|
+
at = end;
|
|
367
|
+
}
|
|
368
|
+
return chunks;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function webpXmp(bytes: Uint8Array): Found<RiffChunk> {
|
|
372
|
+
const chunks = webpChunks(bytes);
|
|
373
|
+
const found = chunks.find((chunk) => chunk.fourcc === "XMP ");
|
|
374
|
+
const packet =
|
|
375
|
+
found === undefined
|
|
376
|
+
? null
|
|
377
|
+
: decode(bytes.subarray(found.dataStart, found.dataStart + found.size));
|
|
378
|
+
return { packet, parts: chunks };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function riffChunk(fourcc: string, data: Uint8Array): Uint8Array {
|
|
382
|
+
const chunk = new Uint8Array(8 + data.length + (data.length % 2));
|
|
383
|
+
chunk.set(utf8.encode(fourcc), 0);
|
|
384
|
+
view(chunk).setUint32(4, data.length, true);
|
|
385
|
+
chunk.set(data, 8);
|
|
386
|
+
return chunk;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** A `VP8X` header for a simple file, its canvas and alpha read from the bitstream. */
|
|
390
|
+
function vp8xFor(bytes: Uint8Array, image: RiffChunk): Uint8Array {
|
|
391
|
+
const data = bytes.subarray(image.dataStart, image.dataStart + image.size);
|
|
392
|
+
let width: number;
|
|
393
|
+
let height: number;
|
|
394
|
+
let alpha = false;
|
|
395
|
+
if (image.fourcc === "VP8 ") {
|
|
396
|
+
// Frame tag (3 bytes), start code 9d 01 2a, then 14-bit width and height.
|
|
397
|
+
if (data.length < 10 || data[3] !== 0x9d || data[4] !== 0x01 || data[5] !== 0x2a) {
|
|
398
|
+
throw new XmpContainerError("The WebP's image data is damaged.");
|
|
399
|
+
}
|
|
400
|
+
// The top two bits of each are a scale, not part of the size.
|
|
401
|
+
width = view(data).getUint16(6, true) % 0x40_00;
|
|
402
|
+
height = view(data).getUint16(8, true) % 0x40_00;
|
|
403
|
+
} else if (image.fourcc === "VP8L") {
|
|
404
|
+
if (data.length < 5 || data[0] !== 0x2f) {
|
|
405
|
+
throw new XmpContainerError("The WebP's image data is damaged.");
|
|
406
|
+
}
|
|
407
|
+
// 14 bits of width - 1, 14 of height - 1, then the alpha hint, little-endian.
|
|
408
|
+
const bits = view(data).getUint32(1, true);
|
|
409
|
+
width = (bits % 0x40_00) + 1;
|
|
410
|
+
height = (Math.floor(bits / 0x40_00) % 0x40_00) + 1;
|
|
411
|
+
alpha = hasFlag(bits, 2 ** 28);
|
|
412
|
+
} else {
|
|
413
|
+
throw new XmpContainerError("The WebP has no image data.");
|
|
414
|
+
}
|
|
415
|
+
const header = new Uint8Array(10);
|
|
416
|
+
header[0] = VP8X_XMP + (alpha ? VP8X_ALPHA : 0);
|
|
417
|
+
// Canvas width - 1 and height - 1, each 24 bits little-endian; a 14-bit size fits in the low 16.
|
|
418
|
+
view(header).setUint16(4, width - 1, true);
|
|
419
|
+
view(header).setUint16(7, height - 1, true);
|
|
420
|
+
return riffChunk("VP8X", header);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function webpEmbed(bytes: Uint8Array, packet: string): Uint8Array<ArrayBuffer> {
|
|
424
|
+
const { parts: chunks } = webpXmp(bytes);
|
|
425
|
+
const parts: Uint8Array[] = [];
|
|
426
|
+
const vp8x = chunks.find((chunk) => chunk.fourcc === "VP8X");
|
|
427
|
+
if (vp8x === undefined) {
|
|
428
|
+
const [image] = chunks;
|
|
429
|
+
if (image === undefined) {
|
|
430
|
+
throw new XmpContainerError("The WebP has no image data.");
|
|
431
|
+
}
|
|
432
|
+
parts.push(vp8xFor(bytes, image));
|
|
433
|
+
}
|
|
434
|
+
for (const chunk of chunks) {
|
|
435
|
+
if (chunk.fourcc === "XMP ") {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (chunk === vp8x) {
|
|
439
|
+
const copy = bytes.slice(chunk.start, chunk.end);
|
|
440
|
+
const flags = copy[8] ?? 0;
|
|
441
|
+
copy[8] = hasFlag(flags, VP8X_XMP) ? flags : flags + VP8X_XMP;
|
|
442
|
+
parts.push(copy);
|
|
443
|
+
} else {
|
|
444
|
+
parts.push(bytes.subarray(chunk.start, chunk.end));
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// XMP is the last chunk of an extended file.
|
|
448
|
+
parts.push(riffChunk("XMP ", utf8.encode(packet)));
|
|
449
|
+
const body = concat(parts);
|
|
450
|
+
const out = new Uint8Array(12 + body.length);
|
|
451
|
+
out.set(utf8.encode("RIFF"), 0);
|
|
452
|
+
view(out).setUint32(4, 4 + body.length, true);
|
|
453
|
+
out.set(utf8.encode("WEBP"), 8);
|
|
454
|
+
out.set(body, 12);
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ─── SVG ─────────────────────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
const XMPMETA = /<([\w.-]+:)?xmpmeta\b[\s\S]*?<\/\1?xmpmeta\s*>/;
|
|
461
|
+
|
|
462
|
+
/** The end of the start tag that begins at `lt`, honouring quoted attribute values. */
|
|
463
|
+
function tagEnd(text: string, lt: number): number {
|
|
464
|
+
let quote: string | null = null;
|
|
465
|
+
for (let at = lt + 1; at < text.length; at++) {
|
|
466
|
+
const ch = text[at];
|
|
467
|
+
if (quote !== null) {
|
|
468
|
+
if (ch === quote) {
|
|
469
|
+
quote = null;
|
|
470
|
+
}
|
|
471
|
+
} else if (ch === '"' || ch === "'") {
|
|
472
|
+
quote = ch;
|
|
473
|
+
} else if (ch === ">") {
|
|
474
|
+
return at;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return -1;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/** The root `<svg …>` start tag: where it begins, where its `>` is, and whether it closes itself. */
|
|
481
|
+
interface SvgRootTag {
|
|
482
|
+
start: number;
|
|
483
|
+
end: number;
|
|
484
|
+
selfClosing: boolean;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The `x:xmpmeta` element an SVG holds, with its range in the source; `null` and -1 when none. */
|
|
488
|
+
interface SvgPacket {
|
|
489
|
+
packet: string | null;
|
|
490
|
+
start: number;
|
|
491
|
+
end: number;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The root `<svg …>` start tag's range. */
|
|
495
|
+
function svgRoot(text: string): SvgRootTag {
|
|
496
|
+
const match = /<svg[\s>/]/.exec(text);
|
|
497
|
+
if (match === null) {
|
|
498
|
+
throw new XmpContainerError("Not an SVG file.");
|
|
499
|
+
}
|
|
500
|
+
const end = tagEnd(text, match.index);
|
|
501
|
+
if (end === -1) {
|
|
502
|
+
throw new XmpContainerError("The SVG's root element is damaged.");
|
|
503
|
+
}
|
|
504
|
+
return { end, selfClosing: text[end - 1] === "/", start: match.index };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The `x:xmpmeta` element's source and range, inside the root's metadata. */
|
|
508
|
+
function svgXmp(text: string): SvgPacket {
|
|
509
|
+
svgRoot(text);
|
|
510
|
+
const match = XMPMETA.exec(text);
|
|
511
|
+
return match === null
|
|
512
|
+
? { end: -1, packet: null, start: -1 }
|
|
513
|
+
: { end: match.index + match[0].length, packet: match[0], start: match.index };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The packet without its `xpacket` wrapper: SVG is already XML, and holds the element alone. */
|
|
517
|
+
function bareXmpmeta(packet: string): string {
|
|
518
|
+
const match = XMPMETA.exec(packet);
|
|
519
|
+
if (match === null) {
|
|
520
|
+
throw new XmpContainerError("The metadata has no x:xmpmeta element.");
|
|
521
|
+
}
|
|
522
|
+
return match[0];
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function svgEmbed(text: string, packet: string): string {
|
|
526
|
+
const meta = bareXmpmeta(packet);
|
|
527
|
+
const found = svgXmp(text);
|
|
528
|
+
if (found.packet !== null) {
|
|
529
|
+
return text.slice(0, found.start) + meta + text.slice(found.end);
|
|
530
|
+
}
|
|
531
|
+
const root = svgRoot(text);
|
|
532
|
+
if (root.selfClosing) {
|
|
533
|
+
const open = text.slice(root.start, root.end - 1).trimEnd();
|
|
534
|
+
return `${text.slice(0, root.start)}${open}><metadata>${meta}</metadata></svg>${text.slice(root.end + 1)}`;
|
|
535
|
+
}
|
|
536
|
+
const existing = /<metadata(\s[^>]*)?>/.exec(text.slice(root.end));
|
|
537
|
+
if (existing !== null) {
|
|
538
|
+
const at = root.end + existing.index + existing[0].length;
|
|
539
|
+
return text.slice(0, at) + meta + text.slice(at);
|
|
540
|
+
}
|
|
541
|
+
return `${text.slice(0, root.end + 1)}<metadata>${meta}</metadata>${text.slice(root.end + 1)}`;
|
|
542
|
+
}
|