@jxsuite/site 2.5.0 → 2.6.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/site",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "What a Jx project's page at a route consists of — routing, layout, context, head and the document that boots it",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,7 +22,9 @@
22
22
  "./routes": "./src/routes.ts",
23
23
  "./serve": "./src/serve.ts",
24
24
  "./shell": "./src/shell.ts",
25
- "./site-style": "./src/site-style.ts"
25
+ "./site-style": "./src/site-style.ts",
26
+ "./xmp-containers": "./src/xmp-containers.ts",
27
+ "./xmp-packet": "./src/xmp-packet.ts"
26
28
  },
27
29
  "publishConfig": {
28
30
  "provenance": true
@@ -32,7 +34,7 @@
32
34
  "test:coverage": "bun test --isolate --coverage"
33
35
  },
34
36
  "dependencies": {
35
- "@jxsuite/runtime": "^6.2.1",
36
- "@jxsuite/schema": "^5.4.0"
37
+ "@jxsuite/runtime": "^6.3.0",
38
+ "@jxsuite/schema": "^5.6.0"
37
39
  }
38
40
  }
package/src/shell.ts CHANGED
@@ -47,6 +47,13 @@ export interface ShellOptions {
47
47
  * host that omits this should expect a collection to render as an empty list.
48
48
  */
49
49
  resolveToken?: string | undefined;
50
+ /**
51
+ * URL of the body eraser (`@jxsuite/erase/browser`, built as its own file) on this origin. With
52
+ * it, the page fetches the eraser only when a function body is TypeScript (`spec.md` §23.1);
53
+ * without it, a typed body fails to compile and says so. This package never imports the eraser,
54
+ * so a host that cannot run it still loads this one.
55
+ */
56
+ eraserUrl?: string | undefined;
50
57
  }
51
58
 
52
59
  /** Escape a string for an HTML attribute value. */
@@ -70,10 +77,17 @@ function jsonPayload(doc: JxDocument): string {
70
77
 
71
78
  /** The document for one composed page. */
72
79
  export function pageShell(page: ComposedPage, options: ShellOptions): string {
73
- const imports = options.resolveToken ? "Jx, setResolveToken" : "Jx";
74
- const setToken = options.resolveToken
75
- ? `\nsetResolveToken(${JSON.stringify(options.resolveToken)});`
76
- : "";
80
+ const names = ["Jx"];
81
+ let setup = "";
82
+ if (options.resolveToken) {
83
+ names.push("setResolveToken");
84
+ setup += `\nsetResolveToken(${JSON.stringify(options.resolveToken)});`;
85
+ }
86
+ if (options.eraserUrl) {
87
+ names.push("setBodyEraserLoader");
88
+ setup += `\nsetBodyEraserLoader(() => import(${JSON.stringify(options.eraserUrl)}));`;
89
+ }
90
+ const imports = names.join(", ");
77
91
  const style = options.styleUrl
78
92
  ? `\n <link rel="stylesheet" href="${attr(options.styleUrl)}">`
79
93
  : "";
@@ -93,7 +107,7 @@ export function pageShell(page: ComposedPage, options: ShellOptions): string {
93
107
  <body>
94
108
  <script type="application/json" id="jx-page-document">${jsonPayload(page.doc)}</script>
95
109
  <script type="module">
96
- import { ${imports} } from "${attr(options.runtimeUrl)}";${setToken}
110
+ import { ${imports} } from "${attr(options.runtimeUrl)}";${setup}
97
111
  const source = document.getElementById("jx-page-document").textContent;
98
112
  await Jx(JSON.parse(source), document.body, { base: ${JSON.stringify(options.base)} });
99
113
  </script>${client}
@@ -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
+ }
@@ -0,0 +1,727 @@
1
+ /**
2
+ * The XMP packet a media file carries, read and rewritten for the two properties Studio edits.
3
+ *
4
+ * Studio keeps what a picture IS inside the picture (studio.md §22.6), in the fields every photo
5
+ * tool already reads and writes, so a file moved, renamed, copied or opened in another program
6
+ * takes its description with it and no index beside it can drift:
7
+ *
8
+ * - **Alt text** is the IPTC Photo Metadata Standard's `Iptc4xmpCore:AltTextAccessibility`, a
9
+ * language alternative whose `x-default` entry is the text. Other languages are kept as found.
10
+ * - **Tags** are `dc:subject`, the unordered bag IPTC's Keywords maps to in XMP.
11
+ * - **The focal point** is one IPTC Image Region (`Iptc4xmpExt:ImageRegion`): a polygon of a single
12
+ * vertex in relative units (the standard's own way to say "a point"), whose role is the IPTC
13
+ * NewsCodes `areaOfInterest`. Every other region in the bag is kept.
14
+ *
15
+ * **Everything else in the packet is kept.** A rewrite parses the packet into a small tree,
16
+ * replaces those two properties on the first `rdf:Description` (removing them from any other, so a
17
+ * file never says two things), and serializes the tree back: a camera's EXIF mirror, a licence, a
18
+ * Lightroom develop setting all survive byte-for-byte in meaning. A packet that does not parse is
19
+ * never "repaired" into a fresh one, which would silently delete whatever it held; it is refused,
20
+ * and the caller says so.
21
+ *
22
+ * The parser is deliberately DOM-free: XMP is a constrained XML (no DTD, no external entities), the
23
+ * tree has to round-trip text it does not understand exactly, and a pure module tests and runs the
24
+ * same everywhere.
25
+ *
26
+ * @docs studio/projects/media
27
+ */
28
+
29
+ /** What Studio reads from, and writes to, one media file. */
30
+ export interface MediaRecord {
31
+ /** The file's alt text, `""` when it has none. */
32
+ alt: string;
33
+ /** Its tags, in the order the file lists them, without duplicates or blanks. */
34
+ tags: string[];
35
+ /** The point a crop of the picture keeps in view, or `null` when it has none. */
36
+ focus: FocalPoint | null;
37
+ }
38
+
39
+ /** A point on a picture, each coordinate a fraction of its width or height from the top left. */
40
+ export interface FocalPoint {
41
+ readonly x: number;
42
+ readonly y: number;
43
+ }
44
+
45
+ export const RDF_NS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
46
+ export const DC_NS = "http://purl.org/dc/elements/1.1/";
47
+ export const IPTC_CORE_NS = "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/";
48
+ export const IPTC_EXT_NS = "http://iptc.org/std/Iptc4xmpExt/2008-02-29/";
49
+ export const XMP_NS = "http://ns.adobe.com/xap/1.0/";
50
+ /** The IPTC Image Region Role a focal point is written with. */
51
+ export const AREA_OF_INTEREST = "http://cv.iptc.org/newscodes/imageregionrole/areaOfInterest";
52
+ const XML_NS = "http://www.w3.org/XML/1998/namespace";
53
+ const X_NS = "adobe:ns:meta/";
54
+
55
+ /** The empty record: no alt text, no tags. */
56
+ export function emptyMediaRecord(): MediaRecord {
57
+ return { alt: "", focus: null, tags: [] };
58
+ }
59
+
60
+ // ─── A small XML tree ────────────────────────────────────────────────────────
61
+
62
+ interface XmlElement {
63
+ type: "element";
64
+ /** The qualified name as written (`dc:subject`). */
65
+ name: string;
66
+ /** Attributes in source order, values decoded. */
67
+ attrs: [string, string][];
68
+ children: XmlNode[];
69
+ }
70
+
71
+ interface XmlText {
72
+ type: "text";
73
+ /** Decoded text. */
74
+ value: string;
75
+ }
76
+
77
+ /** A node kept verbatim: a comment, a processing instruction, a CDATA section. */
78
+ interface XmlRaw {
79
+ type: "raw";
80
+ source: string;
81
+ }
82
+
83
+ type XmlNode = XmlElement | XmlText | XmlRaw;
84
+
85
+ /** Why a packet could not be read: the message a caller reports. */
86
+ export class XmpParseError extends Error {
87
+ override name = "XmpParseError";
88
+ }
89
+
90
+ const ENTITIES = new Map([
91
+ ["amp", "&"],
92
+ ["apos", "'"],
93
+ ["gt", ">"],
94
+ ["lt", "<"],
95
+ ["quot", '"'],
96
+ ]);
97
+
98
+ /** The code point a numeric character reference names, or `null` for a named one. */
99
+ function codePointOf(ref: string): number | null {
100
+ if (ref.startsWith("#x") || ref.startsWith("#X")) {
101
+ return Number.parseInt(ref.slice(2), 16);
102
+ }
103
+ return ref.startsWith("#") ? Number(ref.slice(1)) : null;
104
+ }
105
+
106
+ function decodeEntities(text: string): string {
107
+ return text.replaceAll(/&(#x[\da-f]+|#\d+|\w+);/gi, (whole, ref: string) => {
108
+ const code = codePointOf(ref);
109
+ const decoded =
110
+ code === null
111
+ ? ENTITIES.get(ref)
112
+ : code <= 0x10_ff_ff
113
+ ? String.fromCodePoint(code)
114
+ : undefined;
115
+ if (decoded === undefined) {
116
+ throw new XmpParseError(`Unknown entity ${whole}`);
117
+ }
118
+ return decoded;
119
+ });
120
+ }
121
+
122
+ function escapeText(text: string): string {
123
+ return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
124
+ }
125
+
126
+ function escapeAttr(text: string): string {
127
+ return escapeText(text).replaceAll('"', "&quot;");
128
+ }
129
+
130
+ const NAME = /^[A-Za-z_][\w.:-]*/;
131
+
132
+ /** Parse markup into nodes. Throws {@link XmpParseError} on anything that is not well formed. */
133
+ function parseXml(source: string): XmlNode[] {
134
+ const root: XmlElement = { attrs: [], children: [], name: "#root", type: "element" };
135
+ const stack: XmlElement[] = [root];
136
+ let at = 0;
137
+ const top = (): XmlElement => stack.at(-1) ?? root;
138
+ while (at < source.length) {
139
+ const lt = source.indexOf("<", at);
140
+ if (lt === -1) {
141
+ top().children.push({ type: "text", value: decodeEntities(source.slice(at)) });
142
+ break;
143
+ }
144
+ if (lt > at) {
145
+ top().children.push({ type: "text", value: decodeEntities(source.slice(at, lt)) });
146
+ }
147
+ const rest = source.slice(lt, lt + 9);
148
+ let end: number;
149
+ if (rest.startsWith("<!--")) {
150
+ end = source.indexOf("-->", lt);
151
+ end = end === -1 ? -1 : end + 3;
152
+ } else if (rest.startsWith("<![CDATA[")) {
153
+ end = source.indexOf("]]>", lt);
154
+ end = end === -1 ? -1 : end + 3;
155
+ } else if (rest.startsWith("<?")) {
156
+ end = source.indexOf("?>", lt);
157
+ end = end === -1 ? -1 : end + 2;
158
+ } else if (rest.startsWith("<!")) {
159
+ throw new XmpParseError("A packet may not declare a document type.");
160
+ } else if (rest.startsWith("</")) {
161
+ const close = source.indexOf(">", lt);
162
+ if (close === -1) {
163
+ throw new XmpParseError("Unclosed end tag.");
164
+ }
165
+ const name = source.slice(lt + 2, close).trim();
166
+ const open = stack.pop();
167
+ if (open === undefined || open === root || open.name !== name) {
168
+ throw new XmpParseError(`Unexpected </${name}>.`);
169
+ }
170
+ at = close + 1;
171
+ continue;
172
+ } else {
173
+ at = parseStartTag(source, lt, stack);
174
+ continue;
175
+ }
176
+ if (end === -1) {
177
+ throw new XmpParseError("Unterminated markup.");
178
+ }
179
+ top().children.push({ source: source.slice(lt, end), type: "raw" });
180
+ at = end;
181
+ }
182
+ if (stack.length !== 1) {
183
+ throw new XmpParseError(`<${top().name}> is never closed.`);
184
+ }
185
+ return root.children;
186
+ }
187
+
188
+ /** Parse the start tag at `lt` onto `stack`, returning where parsing continues. */
189
+ function parseStartTag(source: string, lt: number, stack: XmlElement[]): number {
190
+ let at = lt + 1;
191
+ const nameMatch = NAME.exec(source.slice(at));
192
+ if (nameMatch === null) {
193
+ throw new XmpParseError("A tag has no name.");
194
+ }
195
+ const element: XmlElement = { attrs: [], children: [], name: nameMatch[0], type: "element" };
196
+ at += nameMatch[0].length;
197
+ for (;;) {
198
+ while (/\s/.test(source[at] ?? "")) {
199
+ at += 1;
200
+ }
201
+ const ch = source[at];
202
+ if (ch === undefined) {
203
+ throw new XmpParseError(`<${element.name}> is never finished.`);
204
+ }
205
+ if (ch === ">" || source.startsWith("/>", at)) {
206
+ stack.at(-1)?.children.push(element);
207
+ if (ch === ">") {
208
+ stack.push(element);
209
+ return at + 1;
210
+ }
211
+ return at + 2;
212
+ }
213
+ const attrName = NAME.exec(source.slice(at));
214
+ if (attrName === null) {
215
+ throw new XmpParseError(`<${element.name}> has a malformed attribute.`);
216
+ }
217
+ at += attrName[0].length;
218
+ while (/\s/.test(source[at] ?? "")) {
219
+ at += 1;
220
+ }
221
+ if (source[at] !== "=") {
222
+ throw new XmpParseError(`${attrName[0]} has no value.`);
223
+ }
224
+ at += 1;
225
+ while (/\s/.test(source[at] ?? "")) {
226
+ at += 1;
227
+ }
228
+ const quote = source[at];
229
+ if (quote !== '"' && quote !== "'") {
230
+ throw new XmpParseError(`${attrName[0]} is not quoted.`);
231
+ }
232
+ const close = source.indexOf(quote, at + 1);
233
+ if (close === -1) {
234
+ throw new XmpParseError(`${attrName[0]} is never closed.`);
235
+ }
236
+ element.attrs.push([attrName[0], decodeEntities(source.slice(at + 1, close))]);
237
+ at = close + 1;
238
+ }
239
+ }
240
+
241
+ function serializeXml(nodes: readonly XmlNode[]): string {
242
+ return nodes
243
+ .map((node) => {
244
+ if (node.type === "text") {
245
+ return escapeText(node.value);
246
+ }
247
+ if (node.type === "raw") {
248
+ return node.source;
249
+ }
250
+ const attrs = node.attrs.map(([k, v]) => ` ${k}="${escapeAttr(v)}"`).join("");
251
+ return node.children.length === 0
252
+ ? `<${node.name}${attrs}/>`
253
+ : `<${node.name}${attrs}>${serializeXml(node.children)}</${node.name}>`;
254
+ })
255
+ .join("");
256
+ }
257
+
258
+ // ─── Namespaces ──────────────────────────────────────────────────────────────
259
+
260
+ /** Prefix → URI bindings in scope. */
261
+ type Scope = ReadonlyMap<string, string>;
262
+
263
+ function scopeOf(element: XmlElement, parent: Scope): Scope {
264
+ const declared = element.attrs.filter(([k]) => k === "xmlns" || k.startsWith("xmlns:"));
265
+ if (declared.length === 0) {
266
+ return parent;
267
+ }
268
+ const scope = new Map(parent);
269
+ for (const [k, v] of declared) {
270
+ scope.set(k === "xmlns" ? "" : k.slice(6), v);
271
+ }
272
+ return scope;
273
+ }
274
+
275
+ /** A qualified name's namespace URI and local name, or `null` when its prefix is unbound. */
276
+ function resolve(qname: string, scope: Scope, isAttr = false): [string, string] | null {
277
+ const colon = qname.indexOf(":");
278
+ if (colon === -1) {
279
+ // An unprefixed attribute is in no namespace; an unprefixed element takes the default.
280
+ return isAttr ? ["", qname] : [scope.get("") ?? "", qname];
281
+ }
282
+ const prefix = qname.slice(0, colon);
283
+ const uri = prefix === "xml" ? XML_NS : scope.get(prefix);
284
+ return uri === undefined ? null : [uri, qname.slice(colon + 1)];
285
+ }
286
+
287
+ function isNamed(qname: string, scope: Scope, ns: string, local: string, isAttr = false): boolean {
288
+ const resolved = resolve(qname, scope, isAttr);
289
+ return resolved !== null && resolved[0] === ns && resolved[1] === local;
290
+ }
291
+
292
+ function elements(nodes: readonly XmlNode[]): XmlElement[] {
293
+ return nodes.filter((node): node is XmlElement => node.type === "element");
294
+ }
295
+
296
+ function textOf(element: XmlElement): string {
297
+ return element.children
298
+ .map((node) =>
299
+ node.type === "text" ? node.value : node.type === "element" ? textOf(node) : "",
300
+ )
301
+ .join("");
302
+ }
303
+
304
+ /** Every `rdf:Description`, with the scope in force inside it, in document order. */
305
+ function descriptions(nodes: readonly XmlNode[], scope: Scope = new Map()): [XmlElement, Scope][] {
306
+ const found: [XmlElement, Scope][] = [];
307
+ for (const element of elements(nodes)) {
308
+ const inner = scopeOf(element, scope);
309
+ if (isNamed(element.name, inner, RDF_NS, "Description")) {
310
+ found.push([element, inner]);
311
+ } else {
312
+ found.push(...descriptions(element.children, inner));
313
+ }
314
+ }
315
+ return found;
316
+ }
317
+
318
+ // ─── Reading ─────────────────────────────────────────────────────────────────
319
+
320
+ /** The `rdf:li` items of a property's container (`rdf:Bag`, `rdf:Seq` or `rdf:Alt`). */
321
+ function listItems(property: XmlElement, scope: Scope): [XmlElement, Scope][] {
322
+ const items: [XmlElement, Scope][] = [];
323
+ for (const container of elements(property.children)) {
324
+ const inner = scopeOf(container, scope);
325
+ for (const li of elements(container.children)) {
326
+ const liScope = scopeOf(li, inner);
327
+ if (isNamed(li.name, liScope, RDF_NS, "li")) {
328
+ items.push([li, liScope]);
329
+ }
330
+ }
331
+ }
332
+ return items;
333
+ }
334
+
335
+ function langOf(li: XmlElement, scope: Scope): string | null {
336
+ const attr = li.attrs.find(([k]) => isNamed(k, scope, XML_NS, "lang", true));
337
+ return attr === undefined ? null : attr[1];
338
+ }
339
+
340
+ /** The alt text in a lang-alt property: its `x-default` entry, else its first. */
341
+ function altFrom(property: XmlElement, scope: Scope): string {
342
+ const items = listItems(property, scope);
343
+ const chosen = items.find(([li, s]) => langOf(li, s)?.toLowerCase() === "x-default") ?? items[0];
344
+ return chosen === undefined ? textOf(property).trim() : textOf(chosen[0]);
345
+ }
346
+
347
+ // ─── Image regions ───────────────────────────────────────────────────────────
348
+
349
+ /**
350
+ * Where a structure's fields are: on the element itself when it is written
351
+ * `rdf:parseType="Resource"` (or as attributes, the shorthand form), else on the `rdf:Description`
352
+ * inside it.
353
+ */
354
+ function structOf(element: XmlElement, scope: Scope): [XmlElement, Scope] {
355
+ const resource = element.attrs.some(
356
+ ([k, v]) => v === "Resource" && isNamed(k, scope, RDF_NS, "parseType", true),
357
+ );
358
+ if (!resource) {
359
+ for (const child of elements(element.children)) {
360
+ const inner = scopeOf(child, scope);
361
+ if (isNamed(child.name, inner, RDF_NS, "Description")) {
362
+ return [child, inner];
363
+ }
364
+ }
365
+ }
366
+ return [element, scope];
367
+ }
368
+
369
+ /** A structure's field written as an element, with the scope inside it. */
370
+ function fieldElement(
371
+ holder: XmlElement,
372
+ scope: Scope,
373
+ ns: string,
374
+ local: string,
375
+ ): [XmlElement, Scope] | null {
376
+ for (const child of elements(holder.children)) {
377
+ const inner = scopeOf(child, scope);
378
+ if (isNamed(child.name, inner, ns, local)) {
379
+ return [child, inner];
380
+ }
381
+ }
382
+ return null;
383
+ }
384
+
385
+ /** A structure's simple field, as an attribute or an element, trimmed; `null` when absent. */
386
+ function fieldText(holder: XmlElement, scope: Scope, ns: string, local: string): string | null {
387
+ const attr = holder.attrs.find(([k]) => isNamed(k, scope, ns, local, true));
388
+ if (attr !== undefined) {
389
+ return attr[1].trim();
390
+ }
391
+ const found = fieldElement(holder, scope, ns, local);
392
+ return found === null ? null : textOf(found[0]).trim();
393
+ }
394
+
395
+ /** A relative coordinate, or `null` when it is missing, not a number, or off the picture. */
396
+ function coordinate(text: string | null): number | null {
397
+ if (text === null || text === "") {
398
+ return null;
399
+ }
400
+ const value = Number(text);
401
+ return Number.isFinite(value) && value >= 0 && value <= 1 ? value : null;
402
+ }
403
+
404
+ /** Whether a region's roles include the area of interest, by its NewsCodes URI (http or https). */
405
+ function isAreaOfInterest(region: XmlElement, scope: Scope): boolean {
406
+ const roles = fieldElement(region, scope, IPTC_EXT_NS, "rRole");
407
+ if (roles === null) {
408
+ return false;
409
+ }
410
+ return listItems(...roles).some(([li, liScope]) => {
411
+ const [role, roleScope] = structOf(li, liScope);
412
+ const ids = fieldElement(role, roleScope, XMP_NS, "Identifier");
413
+ const values =
414
+ ids === null
415
+ ? [fieldText(role, roleScope, XMP_NS, "Identifier") ?? ""]
416
+ : listItems(...ids).map(([id]) => textOf(id).trim());
417
+ return values.some((value) => value.replace(/^https:/, "http:") === AREA_OF_INTEREST);
418
+ });
419
+ }
420
+
421
+ /**
422
+ * The focal point an image region states: a relative polygon of exactly one vertex whose role is
423
+ * the area of interest. Any other region (a face, a crop, a rectangle) is `null`, and kept.
424
+ */
425
+ function focusOf(li: XmlElement, scope: Scope): FocalPoint | null {
426
+ const [region, regionScope] = structOf(li, scope);
427
+ const boundaryField = fieldElement(region, regionScope, IPTC_EXT_NS, "RegionBoundary");
428
+ if (boundaryField === null || !isAreaOfInterest(region, regionScope)) {
429
+ return null;
430
+ }
431
+ const [boundary, boundaryScope] = structOf(...boundaryField);
432
+ const vertices = fieldElement(boundary, boundaryScope, IPTC_EXT_NS, "rbVertices");
433
+ if (
434
+ fieldText(boundary, boundaryScope, IPTC_EXT_NS, "rbShape") !== "polygon" ||
435
+ fieldText(boundary, boundaryScope, IPTC_EXT_NS, "rbUnit") !== "relative" ||
436
+ vertices === null
437
+ ) {
438
+ return null;
439
+ }
440
+ const points = listItems(...vertices);
441
+ if (points.length !== 1) {
442
+ return null;
443
+ }
444
+ const [point, pointScope] = structOf(...points[0]!);
445
+ const x = coordinate(fieldText(point, pointScope, IPTC_EXT_NS, "rbX"));
446
+ const y = coordinate(fieldText(point, pointScope, IPTC_EXT_NS, "rbY"));
447
+ return x === null || y === null ? null : { x, y };
448
+ }
449
+
450
+ /** Tags without blanks or repeats, in order. */
451
+ export function cleanTags(tags: readonly string[]): string[] {
452
+ const seen = new Set<string>();
453
+ const out: string[] = [];
454
+ for (const raw of tags) {
455
+ const tag = raw.trim();
456
+ const key = tag.toLowerCase();
457
+ if (tag !== "" && !seen.has(key)) {
458
+ seen.add(key);
459
+ out.push(tag);
460
+ }
461
+ }
462
+ return out;
463
+ }
464
+
465
+ /**
466
+ * What a packet says about the file: its alt text and tags. `null` (no packet) is the empty record.
467
+ * Throws {@link XmpParseError} when the packet is not well formed.
468
+ */
469
+ export function readMediaRecord(packet: string | null): MediaRecord {
470
+ if (packet === null) {
471
+ return emptyMediaRecord();
472
+ }
473
+ const record = emptyMediaRecord();
474
+ let altFound = false;
475
+ for (const [description, scope] of descriptions(parseXml(packet))) {
476
+ for (const [name, value] of description.attrs) {
477
+ // The attribute form a simple-valued property may take: never a list, but tolerated.
478
+ if (!altFound && isNamed(name, scope, IPTC_CORE_NS, "AltTextAccessibility", true)) {
479
+ record.alt = value;
480
+ altFound = true;
481
+ }
482
+ }
483
+ for (const property of elements(description.children)) {
484
+ const inner = scopeOf(property, scope);
485
+ if (!altFound && isNamed(property.name, inner, IPTC_CORE_NS, "AltTextAccessibility")) {
486
+ record.alt = altFrom(property, inner);
487
+ altFound = true;
488
+ } else if (isNamed(property.name, inner, DC_NS, "subject")) {
489
+ record.tags.push(...listItems(property, inner).map(([li]) => textOf(li)));
490
+ } else if (
491
+ record.focus === null &&
492
+ isNamed(property.name, inner, IPTC_EXT_NS, "ImageRegion")
493
+ ) {
494
+ for (const [li, liScope] of listItems(property, inner)) {
495
+ record.focus ??= focusOf(li, liScope);
496
+ }
497
+ }
498
+ }
499
+ }
500
+ record.tags = cleanTags(record.tags);
501
+ record.alt = record.alt.trim();
502
+ return record;
503
+ }
504
+
505
+ // ─── Writing ─────────────────────────────────────────────────────────────────
506
+
507
+ const PACKET_BEGIN = `<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>`;
508
+ const PACKET_END = `<?xpacket end="w"?>`;
509
+
510
+ /** A fresh packet holding one empty description: what a file with no XMP is given. */
511
+ function freshPacket(): string {
512
+ return (
513
+ `${PACKET_BEGIN}<x:xmpmeta xmlns:x="${X_NS}"><rdf:RDF xmlns:rdf="${RDF_NS}">` +
514
+ `<rdf:Description rdf:about=""/></rdf:RDF></x:xmpmeta>${PACKET_END}`
515
+ );
516
+ }
517
+
518
+ /** A prefix bound to `ns` in scope, else bind `preferred` (or a numbered variant) on `element`. */
519
+ function prefixFor(element: XmlElement, scope: Scope, ns: string, preferred: string): string {
520
+ for (const [prefix, uri] of scope) {
521
+ if (uri === ns && prefix !== "") {
522
+ return prefix;
523
+ }
524
+ }
525
+ let prefix = preferred;
526
+ for (let n = 1; scope.has(prefix) || element.attrs.some(([k]) => k === `xmlns:${prefix}`); n++) {
527
+ prefix = `${preferred}${n}`;
528
+ }
529
+ element.attrs.push([`xmlns:${prefix}`, ns]);
530
+ return prefix;
531
+ }
532
+
533
+ function el(name: string, attrs: [string, string][], children: XmlNode[]): XmlElement {
534
+ return { attrs, children, name, type: "element" };
535
+ }
536
+
537
+ /** Remove one property, in element and attribute form, from a description. */
538
+ function dropProperty(description: XmlElement, scope: Scope, ns: string, local: string): void {
539
+ description.attrs = description.attrs.filter(([k]) => !isNamed(k, scope, ns, local, true));
540
+ description.children = description.children.filter(
541
+ (node) => node.type !== "element" || !isNamed(node.name, scopeOf(node, scope), ns, local),
542
+ );
543
+ }
544
+
545
+ /**
546
+ * The existing lang-alt entries other than `x-default`, so a rewrite of the alt text keeps the
547
+ * translations another tool wrote.
548
+ */
549
+ function otherLanguages(description: XmlElement, scope: Scope): { lang: string; text: string }[] {
550
+ const property = elements(description.children).find((node) =>
551
+ isNamed(node.name, scopeOf(node, scope), IPTC_CORE_NS, "AltTextAccessibility"),
552
+ );
553
+ if (property === undefined) {
554
+ return [];
555
+ }
556
+ return listItems(property, scopeOf(property, scope))
557
+ .filter(([li, s]) => (langOf(li, s) ?? "x-default").toLowerCase() !== "x-default")
558
+ .map(([li, s]) => ({ lang: langOf(li, s) ?? "", text: textOf(li) }));
559
+ }
560
+
561
+ /**
562
+ * The packet with its alt text and tags set to `record`, everything else kept.
563
+ *
564
+ * `null` (the file has no packet) starts a fresh one. Throws {@link XmpParseError} when the existing
565
+ * packet does not parse: a rewrite never replaces what it could not read.
566
+ */
567
+ export function writeMediaRecord(packet: string | null, record: MediaRecord): string {
568
+ const nodes = parseXml(packet ?? freshPacket());
569
+ const all = descriptions(nodes);
570
+ const [target, scope] = all[0] ?? addDescription(nodes);
571
+ const translations = otherLanguages(target, scope);
572
+ for (const [description, s] of all) {
573
+ dropProperty(description, s, IPTC_CORE_NS, "AltTextAccessibility");
574
+ dropProperty(description, s, DC_NS, "subject");
575
+ dropFocus(description, s);
576
+ }
577
+ const rdf = prefixFor(target, scope, RDF_NS, "rdf");
578
+ const alt = record.alt.trim();
579
+ if (alt !== "" || translations.length > 0) {
580
+ const iptc = prefixFor(target, scope, IPTC_CORE_NS, "Iptc4xmpCore");
581
+ // Re-homed under a new container, each language is written with `xml:`, which is always bound.
582
+ const entries = alt === "" ? translations : [{ lang: "x-default", text: alt }, ...translations];
583
+ const items = entries.map(({ lang, text }) =>
584
+ el(`${rdf}:li`, [["xml:lang", lang]], [{ type: "text", value: text }]),
585
+ );
586
+ target.children.push(el(`${iptc}:AltTextAccessibility`, [], [el(`${rdf}:Alt`, [], items)]));
587
+ }
588
+ const tags = cleanTags(record.tags);
589
+ if (tags.length > 0) {
590
+ const dc = prefixFor(target, scope, DC_NS, "dc");
591
+ const items = tags.map((tag) => el(`${rdf}:li`, [], [{ type: "text", value: tag }]));
592
+ target.children.push(el(`${dc}:subject`, [], [el(`${rdf}:Bag`, [], items)]));
593
+ }
594
+ if (record.focus !== null) {
595
+ writeFocus(all.length === 0 ? [[target, scope]] : all, record.focus);
596
+ }
597
+ return serializeXml(nodes);
598
+ }
599
+
600
+ /**
601
+ * Remove every focal-point region from a description's image regions, keeping the others, and the
602
+ * property itself when it no longer holds any.
603
+ */
604
+ function dropFocus(description: XmlElement, scope: Scope): void {
605
+ description.children = description.children.filter((node) => {
606
+ if (node.type !== "element") {
607
+ return true;
608
+ }
609
+ const inner = scopeOf(node, scope);
610
+ if (!isNamed(node.name, inner, IPTC_EXT_NS, "ImageRegion")) {
611
+ return true;
612
+ }
613
+ for (const container of elements(node.children)) {
614
+ const containerScope = scopeOf(container, inner);
615
+ container.children = container.children.filter(
616
+ (li) => li.type !== "element" || focusOf(li, scopeOf(li, containerScope)) === null,
617
+ );
618
+ }
619
+ return listItems(node, inner).length > 0;
620
+ });
621
+ }
622
+
623
+ /** A decimal short enough to read, exact enough for any picture: four places. */
624
+ function decimal(value: number): string {
625
+ return String(Math.round(value * 10_000) / 10_000);
626
+ }
627
+
628
+ /**
629
+ * Add the focal-point region: into the image regions a description already has (the first one with
630
+ * any), else as a new bag on the first description, so a file never carries the property twice.
631
+ */
632
+ function writeFocus(all: readonly [XmlElement, Scope][], focus: FocalPoint): void {
633
+ let home: [XmlElement, Scope] = all[0]!;
634
+ let bag: XmlElement | null = null;
635
+ for (const [description, scope] of all) {
636
+ const found = fieldElement(description, scope, IPTC_EXT_NS, "ImageRegion");
637
+ const container = found === null ? undefined : elements(found[0].children)[0];
638
+ if (container !== undefined) {
639
+ home = [description, scope];
640
+ bag = container;
641
+ break;
642
+ }
643
+ }
644
+ const [description, scope] = home;
645
+ const rdf = prefixFor(description, scope, RDF_NS, "rdf");
646
+ const ext = prefixFor(description, scope, IPTC_EXT_NS, "Iptc4xmpExt");
647
+ const xmp = prefixFor(description, scope, XMP_NS, "xmp");
648
+ const resource = (): [string, string][] => [[`${rdf}:parseType`, "Resource"]];
649
+ const text = (name: string, value: string): XmlElement => el(name, [], [{ type: "text", value }]);
650
+ const point = el(`${rdf}:li`, resource(), [
651
+ text(`${ext}:rbX`, decimal(focus.x)),
652
+ text(`${ext}:rbY`, decimal(focus.y)),
653
+ ]);
654
+ const boundary = el(`${ext}:RegionBoundary`, resource(), [
655
+ text(`${ext}:rbShape`, "polygon"),
656
+ text(`${ext}:rbUnit`, "relative"),
657
+ el(`${ext}:rbVertices`, [], [el(`${rdf}:Seq`, [], [point])]),
658
+ ]);
659
+ const roleId = el(`${rdf}:Bag`, [], [text(`${rdf}:li`, AREA_OF_INTEREST)]);
660
+ const roleName = el(
661
+ `${rdf}:li`,
662
+ [["xml:lang", "x-default"]],
663
+ [{ type: "text", value: "Focal point" }],
664
+ );
665
+ const role = el(`${rdf}:li`, resource(), [
666
+ el(`${xmp}:Identifier`, [], [roleId]),
667
+ el(`${ext}:Name`, [], [el(`${rdf}:Alt`, [], [roleName])]),
668
+ ]);
669
+ const region = el(`${rdf}:li`, resource(), [
670
+ boundary,
671
+ el(`${ext}:rRole`, [], [el(`${rdf}:Bag`, [], [role])]),
672
+ ]);
673
+ if (bag === null) {
674
+ description.children.push(el(`${ext}:ImageRegion`, [], [el(`${rdf}:Bag`, [], [region])]));
675
+ } else {
676
+ bag.children.push(region);
677
+ }
678
+ }
679
+
680
+ /** Whether two focal points are the same point, as written (to four places). */
681
+ export function sameFocus(a: FocalPoint | null, b: FocalPoint | null): boolean {
682
+ return a === null || b === null
683
+ ? a === b
684
+ : decimal(a.x) === decimal(b.x) && decimal(a.y) === decimal(b.y);
685
+ }
686
+
687
+ /** The `rdf:RDF` element and the scope inside it, or `null` when the packet has none. */
688
+ function findRdf(nodes: readonly XmlNode[], scope: Scope = new Map()): [XmlElement, Scope] | null {
689
+ for (const element of elements(nodes)) {
690
+ const inner = scopeOf(element, scope);
691
+ if (isNamed(element.name, inner, RDF_NS, "RDF")) {
692
+ return [element, inner];
693
+ }
694
+ const found = findRdf(element.children, inner);
695
+ if (found !== null) {
696
+ return found;
697
+ }
698
+ }
699
+ return null;
700
+ }
701
+
702
+ /**
703
+ * Give a packet with no description one, under its `rdf:RDF` and in that element's own prefix.
704
+ * Refuses a packet with no RDF at all: there is nowhere a property could go.
705
+ */
706
+ function addDescription(nodes: readonly XmlNode[]): [XmlElement, Scope] {
707
+ const found = findRdf(nodes);
708
+ if (found === null) {
709
+ throw new XmpParseError("The packet has no rdf:RDF element.");
710
+ }
711
+ const [rdf, scope] = found;
712
+ const colon = rdf.name.indexOf(":");
713
+ const prefix = colon === -1 ? "" : `${rdf.name.slice(0, colon)}:`;
714
+ const description = el(`${prefix}Description`, [[`${prefix}about`, ""]], []);
715
+ rdf.children.push(description);
716
+ return [description, scope];
717
+ }
718
+
719
+ /** Whether two records say the same thing. */
720
+ export function sameMediaRecord(a: MediaRecord, b: MediaRecord): boolean {
721
+ return (
722
+ a.alt.trim() === b.alt.trim() &&
723
+ sameFocus(a.focus, b.focus) &&
724
+ a.tags.length === b.tags.length &&
725
+ a.tags.every((tag, index) => tag === b.tags[index])
726
+ );
727
+ }