@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,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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escapeAttr(text: string): string {
|
|
127
|
+
return escapeText(text).replaceAll('"', """);
|
|
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
|
+
}
|