@aglyn/aglyn 1.0.0-beta.197 → 1.0.0-beta.199

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.
Files changed (80) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
  3. package/src/lib/app-utils/docs-help.generated.js +1 -0
  4. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  5. package/src/lib/app-utils/docs-index.generated.js +3 -3
  6. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  7. package/src/lib/app-utils/media-embedded-fields.d.ts +471 -0
  8. package/src/lib/app-utils/media-embedded-fields.js +619 -0
  9. package/src/lib/app-utils/media-embedded-fields.js.map +1 -0
  10. package/src/lib/app-utils/media-embedded-metadata/exif.d.ts +160 -0
  11. package/src/lib/app-utils/media-embedded-metadata/exif.js +1267 -0
  12. package/src/lib/app-utils/media-embedded-metadata/exif.js.map +1 -0
  13. package/src/lib/app-utils/media-embedded-metadata/gif.d.ts +44 -0
  14. package/src/lib/app-utils/media-embedded-metadata/gif.js +134 -0
  15. package/src/lib/app-utils/media-embedded-metadata/gif.js.map +1 -0
  16. package/src/lib/app-utils/media-embedded-metadata/heif.d.ts +23 -0
  17. package/src/lib/app-utils/media-embedded-metadata/heif.js +354 -0
  18. package/src/lib/app-utils/media-embedded-metadata/heif.js.map +1 -0
  19. package/src/lib/app-utils/media-embedded-metadata/icc.d.ts +22 -0
  20. package/src/lib/app-utils/media-embedded-metadata/icc.js +85 -0
  21. package/src/lib/app-utils/media-embedded-metadata/icc.js.map +1 -0
  22. package/src/lib/app-utils/media-embedded-metadata/image-blocks.d.ts +145 -0
  23. package/src/lib/app-utils/media-embedded-metadata/image-blocks.js +200 -0
  24. package/src/lib/app-utils/media-embedded-metadata/image-blocks.js.map +1 -0
  25. package/src/lib/app-utils/media-embedded-metadata/index.d.ts +93 -0
  26. package/src/lib/app-utils/media-embedded-metadata/index.js +744 -0
  27. package/src/lib/app-utils/media-embedded-metadata/index.js.map +1 -0
  28. package/src/lib/app-utils/media-embedded-metadata/iptc.d.ts +59 -0
  29. package/src/lib/app-utils/media-embedded-metadata/iptc.js +529 -0
  30. package/src/lib/app-utils/media-embedded-metadata/iptc.js.map +1 -0
  31. package/src/lib/app-utils/media-embedded-metadata/jpeg.d.ts +43 -0
  32. package/src/lib/app-utils/media-embedded-metadata/jpeg.js +456 -0
  33. package/src/lib/app-utils/media-embedded-metadata/jpeg.js.map +1 -0
  34. package/src/lib/app-utils/media-embedded-metadata/mp4.d.ts +37 -0
  35. package/src/lib/app-utils/media-embedded-metadata/mp4.js +621 -0
  36. package/src/lib/app-utils/media-embedded-metadata/mp4.js.map +1 -0
  37. package/src/lib/app-utils/media-embedded-metadata/ooxml.d.ts +131 -0
  38. package/src/lib/app-utils/media-embedded-metadata/ooxml.js +1058 -0
  39. package/src/lib/app-utils/media-embedded-metadata/ooxml.js.map +1 -0
  40. package/src/lib/app-utils/media-embedded-metadata/pdf-objects.d.ts +223 -0
  41. package/src/lib/app-utils/media-embedded-metadata/pdf-objects.js +936 -0
  42. package/src/lib/app-utils/media-embedded-metadata/pdf-objects.js.map +1 -0
  43. package/src/lib/app-utils/media-embedded-metadata/pdf.d.ts +82 -0
  44. package/src/lib/app-utils/media-embedded-metadata/pdf.js +1167 -0
  45. package/src/lib/app-utils/media-embedded-metadata/pdf.js.map +1 -0
  46. package/src/lib/app-utils/media-embedded-metadata/png.d.ts +41 -0
  47. package/src/lib/app-utils/media-embedded-metadata/png.js +512 -0
  48. package/src/lib/app-utils/media-embedded-metadata/png.js.map +1 -0
  49. package/src/lib/app-utils/media-embedded-metadata/svg.d.ts +30 -0
  50. package/src/lib/app-utils/media-embedded-metadata/svg.js +154 -0
  51. package/src/lib/app-utils/media-embedded-metadata/svg.js.map +1 -0
  52. package/src/lib/app-utils/media-embedded-metadata/tiff.d.ts +40 -0
  53. package/src/lib/app-utils/media-embedded-metadata/tiff.js +158 -0
  54. package/src/lib/app-utils/media-embedded-metadata/tiff.js.map +1 -0
  55. package/src/lib/app-utils/media-embedded-metadata/types.d.ts +60 -0
  56. package/src/lib/app-utils/media-embedded-metadata/types.js +32 -0
  57. package/src/lib/app-utils/media-embedded-metadata/types.js.map +1 -0
  58. package/src/lib/app-utils/media-embedded-metadata/webm.d.ts +26 -0
  59. package/src/lib/app-utils/media-embedded-metadata/webm.js +417 -0
  60. package/src/lib/app-utils/media-embedded-metadata/webm.js.map +1 -0
  61. package/src/lib/app-utils/media-embedded-metadata/webp.d.ts +37 -0
  62. package/src/lib/app-utils/media-embedded-metadata/webp.js +272 -0
  63. package/src/lib/app-utils/media-embedded-metadata/webp.js.map +1 -0
  64. package/src/lib/app-utils/media-embedded-metadata/xml.d.ts +231 -0
  65. package/src/lib/app-utils/media-embedded-metadata/xml.js +598 -0
  66. package/src/lib/app-utils/media-embedded-metadata/xml.js.map +1 -0
  67. package/src/lib/app-utils/media-embedded-metadata/xmp.d.ts +100 -0
  68. package/src/lib/app-utils/media-embedded-metadata/xmp.js +1475 -0
  69. package/src/lib/app-utils/media-embedded-metadata/xmp.js.map +1 -0
  70. package/src/lib/app-utils/media-embedded-metadata/zip.d.ts +93 -0
  71. package/src/lib/app-utils/media-embedded-metadata/zip.js +531 -0
  72. package/src/lib/app-utils/media-embedded-metadata/zip.js.map +1 -0
  73. package/src/lib/app-utils/name-search.d.ts +12 -0
  74. package/src/lib/app-utils/name-search.js +11 -0
  75. package/src/lib/app-utils/name-search.js.map +1 -1
  76. package/src/lib/foundation/definitions/platform.types.d.ts +11 -0
  77. package/src/lib/foundation/definitions/platform.types.js.map +1 -1
  78. package/src/server.d.ts +1 -0
  79. package/src/server.js +7 -0
  80. package/src/server.js.map +1 -1
@@ -0,0 +1,1475 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ /**
18
+ * Reading and surgically editing an XMP packet (AGL-3331).
19
+ *
20
+ * XMP is RDF/XML (ISO 16684-1, "XMP Part 1"; Adobe's XMP Specification
21
+ * Part 1 is the same text). The container writers — JPEG APP1, PNG iTXt,
22
+ * WebP `XMP `, TIFF tag 700, a PDF `/Metadata` stream — hand this module
23
+ * the packet as a string and take a string back; none of them parses it.
24
+ *
25
+ * ## Reading
26
+ *
27
+ * {@link readXmp} finds every `rdf:RDF` by namespace URI (prefixes are the
28
+ * file's choice), reads each `rdf:Description` — properties written as
29
+ * child elements AND as shorthand attributes — and folds the well-known
30
+ * ones into canonical keys. Every other simple property, or array of
31
+ * simple values, surfaces under an `xmp|<uri>|<local>` other key unless its
32
+ * namespace is on the deny list of technical noise. Structured values
33
+ * (structs, arrays of structs) are skipped: nothing here could edit them,
34
+ * and a drawer row reading "[object]" helps nobody.
35
+ *
36
+ * ## Writing
37
+ *
38
+ * {@link writeXmp} edits the parsed DOM in place and serializes it with the
39
+ * faithful serializer in `xml.ts`, so everything it did not touch — a
40
+ * Lightroom develop history, face regions, the packet's padding — comes
41
+ * out byte for byte. A property is changed where it lives (attribute or
42
+ * element, whichever Description holds it); a new one is appended to the
43
+ * first `rdf:Description` as an element, indented like its neighbors.
44
+ */ import { embeddedFieldLabel, otherEmbeddedKey, parseOtherEmbeddedKey } from "../media-embedded-fields.js";
45
+ import { EmbeddedWriteError } from "./types.js";
46
+ import { attributeNamespace, createElement, createText, createWhitespace, elementChildren, hasNonXmlCharacters, insertChildren, isNamespaceDeclaration, isWhitespaceText, parseXml, prefixForNamespace, removeAttribute, replaceChildren, resolvePrefix, serializeXml, setAttribute, setTextContent, splitQName, textContent, walkElements, XML_NAMESPACE } from "./xml.js";
47
+ /** Namespace URIs, by the prefix their specifications use. */ export const XMP_NAMESPACES = {
48
+ rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
49
+ x: 'adobe:ns:meta/',
50
+ dc: 'http://purl.org/dc/elements/1.1/',
51
+ xmp: 'http://ns.adobe.com/xap/1.0/',
52
+ xmpRights: 'http://ns.adobe.com/xap/1.0/rights/',
53
+ xmpMM: 'http://ns.adobe.com/xap/1.0/mm/',
54
+ xmpidq: 'http://ns.adobe.com/xmp/Identifier/qual/1.0/',
55
+ xmpNote: 'http://ns.adobe.com/xmp/note/',
56
+ xmpG: 'http://ns.adobe.com/xap/1.0/g/',
57
+ xmpGImg: 'http://ns.adobe.com/xap/1.0/g/img/',
58
+ xmpTPg: 'http://ns.adobe.com/xap/1.0/t/pg/',
59
+ stEvt: 'http://ns.adobe.com/xap/1.0/sType/ResourceEvent#',
60
+ stRef: 'http://ns.adobe.com/xap/1.0/sType/ResourceRef#',
61
+ stDim: 'http://ns.adobe.com/xap/1.0/sType/Dimensions#',
62
+ stFnt: 'http://ns.adobe.com/xap/1.0/sType/Font#',
63
+ stArea: 'http://ns.adobe.com/xmp/sType/Area#',
64
+ photoshop: 'http://ns.adobe.com/photoshop/1.0/',
65
+ illustrator: 'http://ns.adobe.com/illustrator/1.0/',
66
+ crs: 'http://ns.adobe.com/camera-raw-settings/1.0/',
67
+ crss: 'http://ns.adobe.com/camera-raw-saved-settings/1.0/',
68
+ tiff: 'http://ns.adobe.com/tiff/1.0/',
69
+ exif: 'http://ns.adobe.com/exif/1.0/',
70
+ exifEX: 'http://cipa.jp/exif/1.0/',
71
+ aux: 'http://ns.adobe.com/exif/1.0/aux/',
72
+ pdf: 'http://ns.adobe.com/pdf/1.3/',
73
+ pdfx: 'http://ns.adobe.com/pdfx/1.3/',
74
+ Iptc4xmpCore: 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/',
75
+ 'mwg-rs': 'http://www.metadataworkinggroup.com/schemas/regions/',
76
+ cc: 'http://creativecommons.org/ns#'
77
+ };
78
+ const NS = XMP_NAMESPACES;
79
+ const RDF = NS.rdf;
80
+ /**
81
+ * The prefix a NEW declaration uses for a namespace this module writes.
82
+ * An existing binding of the URI, under any prefix, always wins.
83
+ */ const CONVENTIONAL_PREFIX = {
84
+ [NS.dc]: 'dc',
85
+ [NS.photoshop]: 'photoshop',
86
+ [NS.xmp]: 'xmp',
87
+ [NS.xmpRights]: 'xmpRights',
88
+ [NS.tiff]: 'tiff',
89
+ [NS.exif]: 'exif',
90
+ [NS.exifEX]: 'exifEX',
91
+ [NS.aux]: 'aux',
92
+ [NS.pdf]: 'pdf',
93
+ [NS.Iptc4xmpCore]: 'Iptc4xmpCore'
94
+ };
95
+ /**
96
+ * Read-side only: the URI an UNDECLARED prefix is taken to mean. An SVG's
97
+ * `<metadata>` fragment is often cut out from under the `<svg>` element
98
+ * that declared `rdf:` and `dc:`; without this, that fragment reads as
99
+ * nothing at all. A declared prefix always resolves through its
100
+ * declaration.
101
+ */ const FALLBACK_NAMESPACE = Object.fromEntries(Object.entries(NS));
102
+ /** Namespaces too noisy or technical to show as "other" fields. */ const DENIED_NAMESPACES = new Set([
103
+ NS.xmpMM,
104
+ NS.stEvt,
105
+ NS.stRef,
106
+ NS.crs,
107
+ NS.crss,
108
+ NS.xmpNote,
109
+ NS.xmpG,
110
+ NS.xmpGImg,
111
+ NS.xmpTPg,
112
+ NS.stDim,
113
+ NS.stFnt,
114
+ NS.illustrator,
115
+ NS['mwg-rs'],
116
+ NS.stArea,
117
+ NS.exif,
118
+ NS.exifEX,
119
+ NS.tiff,
120
+ NS.aux,
121
+ NS.xmpidq,
122
+ NS.x
123
+ ]);
124
+ /**
125
+ * Single properties that are noise in a namespace that is otherwise kept.
126
+ * `xmp:MetadataDate` is here because {@link writeXmp} stamps it on every
127
+ * write, so an edit of it could never stick; the two digests are hashes
128
+ * Photoshop uses to detect out-of-band edits; `pdf:PDFVersion` restates the
129
+ * header version the PDF reader already reports as `pdfVersion`.
130
+ */ const DENIED_PROPERTIES = new Set([
131
+ propertyId(NS.photoshop, 'DocumentAncestors'),
132
+ propertyId(NS.photoshop, 'ColorMode'),
133
+ propertyId(NS.photoshop, 'ICCProfile'),
134
+ propertyId(NS.photoshop, 'History'),
135
+ propertyId(NS.photoshop, 'LegacyIPTCDigest'),
136
+ propertyId(NS.photoshop, 'EmbeddedXMPDigest'),
137
+ propertyId(NS.xmp, 'MetadataDate'),
138
+ propertyId(NS.pdf, 'PDFVersion')
139
+ ]);
140
+ function propertyId(uri, local) {
141
+ return `${uri}\u0000${local}`;
142
+ }
143
+ /** A namespace URI for `prefix` at `element`, falling back as above. */ function resolveNs(element, prefix) {
144
+ var _FALLBACK_NAMESPACE_prefix;
145
+ const bound = resolvePrefix(element, prefix);
146
+ if (bound !== null) return bound;
147
+ return prefix && Object.prototype.hasOwnProperty.call(FALLBACK_NAMESPACE, prefix) ? (_FALLBACK_NAMESPACE_prefix = FALLBACK_NAMESPACE[prefix]) != null ? _FALLBACK_NAMESPACE_prefix : null : null;
148
+ }
149
+ function elementNs(element) {
150
+ return resolveNs(element, splitQName(element.name).prefix);
151
+ }
152
+ function attrNs(element, attr) {
153
+ if (isNamespaceDeclaration(attr)) return attributeNamespace(element, attr);
154
+ const { prefix } = splitQName(attr.name);
155
+ return prefix ? resolveNs(element, prefix) : null;
156
+ }
157
+ function isRdf(element, local) {
158
+ return splitQName(element.name).local === local && elementNs(element) === RDF;
159
+ }
160
+ /** An `rdf:`-namespaced attribute by local name, whatever its prefix. */ function rdfAttribute(element, local) {
161
+ return element.attributes.find((attr)=>splitQName(attr.name).local === local && attrNs(element, attr) === RDF);
162
+ }
163
+ function langOf(element) {
164
+ const attr = element.attributes.find((candidate)=>splitQName(candidate.name).local === 'lang' && attrNs(element, candidate) === XML_NAMESPACE);
165
+ return attr ? attr.value : null;
166
+ }
167
+ /**
168
+ * Attributes on a property element that make it a struct in RDF's
169
+ * shorthand (XMP Part 1 §7.9.2.5, "property attributes of an empty
170
+ * property element"): anything other than a namespace declaration, an
171
+ * `xml:` attribute or an `rdf:` one.
172
+ */ function hasFieldAttributes(element) {
173
+ return element.attributes.some((attr)=>{
174
+ if (isNamespaceDeclaration(attr)) return false;
175
+ const uri = attrNs(element, attr);
176
+ return uri !== XML_NAMESPACE && uri !== RDF;
177
+ });
178
+ }
179
+ /** Every `rdf:RDF`, outermost only, in document order. */ function findRdfRoots(doc) {
180
+ const roots = [];
181
+ walkElements(doc, (element)=>{
182
+ if (isRdf(element, 'RDF')) {
183
+ roots.push(element);
184
+ return false;
185
+ }
186
+ return true;
187
+ });
188
+ return roots;
189
+ }
190
+ /**
191
+ * The node elements that describe THIS resource: every `rdf:Description`,
192
+ * plus a typed node (`<cc:Work rdf:about="">`, what Inkscape writes into
193
+ * an SVG) whose subject is the empty URI. A typed node about some other
194
+ * resource — a `cc:License` — describes that resource, not the file.
195
+ */ function descriptionsOf(rdf) {
196
+ return elementChildren(rdf).filter((node)=>{
197
+ var _rdfAttribute;
198
+ if (isRdf(node, 'Description')) return true;
199
+ if (elementNs(node) === RDF) return false;
200
+ return ((_rdfAttribute = rdfAttribute(node, 'about')) == null ? void 0 : _rdfAttribute.value) === '';
201
+ });
202
+ }
203
+ /** A Description's properties, attributes first, in source order. */ function propertiesOf(description) {
204
+ const out = [];
205
+ for (const attr of description.attributes){
206
+ if (isNamespaceDeclaration(attr)) continue;
207
+ const { prefix, local } = splitQName(attr.name);
208
+ if (!prefix) continue;
209
+ const uri = resolveNs(description, prefix);
210
+ if (!uri || uri === RDF || uri === XML_NAMESPACE) continue;
211
+ out.push({
212
+ description,
213
+ uri,
214
+ local,
215
+ prefix,
216
+ attribute: attr
217
+ });
218
+ }
219
+ for (const element of elementChildren(description)){
220
+ const uri = elementNs(element);
221
+ if (!uri || uri === RDF || uri === XML_NAMESPACE) continue;
222
+ const { prefix, local } = splitQName(element.name);
223
+ out.push({
224
+ description,
225
+ uri,
226
+ local,
227
+ prefix,
228
+ element
229
+ });
230
+ }
231
+ return out;
232
+ }
233
+ /** The one `rdf:Alt`/`Bag`/`Seq` an array property holds, if that is all. */ function arrayContainer(element) {
234
+ const children = elementChildren(element);
235
+ const only = children[0];
236
+ if (children.length !== 1 || !only) return null;
237
+ if (isRdf(only, 'Alt')) return {
238
+ node: only,
239
+ kind: 'alt'
240
+ };
241
+ if (isRdf(only, 'Bag')) return {
242
+ node: only,
243
+ kind: 'bag'
244
+ };
245
+ if (isRdf(only, 'Seq')) return {
246
+ node: only,
247
+ kind: 'seq'
248
+ };
249
+ return null;
250
+ }
251
+ function arrayItems(container) {
252
+ return elementChildren(container).filter((node)=>isRdf(node, 'li'));
253
+ }
254
+ /**
255
+ * Classify a property's value (XMP Part 1 §7.9.2): a simple value (text,
256
+ * or a URI in `rdf:resource`), an array of simple values, or anything
257
+ * structured — `rdf:parseType="Resource"`, a nested Description, field
258
+ * attributes, an array whose items are structs.
259
+ */ function readValue(property) {
260
+ if (property.attribute) {
261
+ return {
262
+ kind: 'simple',
263
+ value: property.attribute.value
264
+ };
265
+ }
266
+ const element = property.element;
267
+ if (!element || rdfAttribute(element, 'parseType')) return {
268
+ kind: 'struct'
269
+ };
270
+ const children = elementChildren(element);
271
+ if (!children.length) {
272
+ if (hasFieldAttributes(element)) return {
273
+ kind: 'struct'
274
+ };
275
+ const resource = rdfAttribute(element, 'resource');
276
+ return {
277
+ kind: 'simple',
278
+ value: resource ? resource.value : textContent(element)
279
+ };
280
+ }
281
+ const container = arrayContainer(element);
282
+ if (!container) return {
283
+ kind: 'struct'
284
+ };
285
+ const items = [];
286
+ for (const li of arrayItems(container.node)){
287
+ if (rdfAttribute(li, 'parseType') || elementChildren(li).length || hasFieldAttributes(li)) {
288
+ return {
289
+ kind: 'struct'
290
+ };
291
+ }
292
+ const resource = rdfAttribute(li, 'resource');
293
+ items.push({
294
+ value: resource ? resource.value : textContent(li),
295
+ lang: langOf(li)
296
+ });
297
+ }
298
+ return {
299
+ kind: 'array',
300
+ container: container.kind,
301
+ items
302
+ };
303
+ }
304
+ /**
305
+ * The one value of a language alternative a reader shows: `x-default`,
306
+ * else the first item (XMP Part 1 §8.2.2.4, "Language alternatives").
307
+ */ function pickAlternative(items) {
308
+ var _ref;
309
+ var _this;
310
+ const preferred = items.find((item)=>{
311
+ var _item_lang;
312
+ return ((_item_lang = item.lang) == null ? void 0 : _item_lang.toLowerCase()) === 'x-default';
313
+ });
314
+ return (_ref = (_this = preferred != null ? preferred : items[0]) == null ? void 0 : _this.value) != null ? _ref : '';
315
+ }
316
+ function asText(value) {
317
+ switch(value.kind){
318
+ case 'simple':
319
+ return value.value.trim() || null;
320
+ case 'array':
321
+ {
322
+ const text = value.container === 'alt' ? pickAlternative(value.items).trim() : value.items.map((item)=>item.value.trim()).filter(Boolean).join(', ');
323
+ return text || null;
324
+ }
325
+ default:
326
+ return null;
327
+ }
328
+ }
329
+ function asList(value) {
330
+ let items;
331
+ if (value.kind === 'simple') items = [
332
+ value.value
333
+ ];
334
+ else if (value.kind === 'array') {
335
+ items = value.container === 'alt' ? [
336
+ pickAlternative(value.items)
337
+ ] : value.items.map((item)=>item.value);
338
+ } else return null;
339
+ const out = items.map((item)=>item.trim()).filter(Boolean);
340
+ return out.length ? out : null;
341
+ }
342
+ // ---------------------------------------------------------------------------
343
+ // Value formats (see the doc comment on MEDIA_EMBEDDED_CATALOG).
344
+ // ---------------------------------------------------------------------------
345
+ /**
346
+ * An XMP date (XMP Part 1 §8.2.1.1: ISO 8601 as profiled by the W3C
347
+ * datetime note) in the catalog's format. Fractional seconds are cut to
348
+ * milliseconds, a `+0200` zone gains its colon, and the EXIF spelling
349
+ * `2021:05:03 10:11:12` some writers put in XMP is accepted. A zone is
350
+ * kept only when the file wrote one. Reduced precision (`2021`,
351
+ * `2021-05`) is returned as written — a month cannot be invented.
352
+ */ export function normalizeXmpDate(raw) {
353
+ const text = raw.trim();
354
+ if (/^\d{4}(?:-(?:0[1-9]|1[0-2]))?$/.test(text)) return text;
355
+ const match = /^(\d{4})[-:](\d{2})[-:](\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/i.exec(text);
356
+ if (!match) return null;
357
+ const [, year, month, day, hour, minute, second, fraction, zone] = match;
358
+ if (!year || !month || !day) return null;
359
+ const m = Number(month);
360
+ const d = Number(day);
361
+ if (m < 1 || m > 12 || d < 1 || d > 31) return null;
362
+ let out = `${year}-${month}-${day}`;
363
+ if (hour === undefined || minute === undefined) return out;
364
+ if (Number(hour) > 23 || Number(minute) > 59) return null;
365
+ out += `T${hour}:${minute}`;
366
+ if (second !== undefined) {
367
+ if (Number(second) > 60) return null;
368
+ out += `:${second}`;
369
+ if (fraction) out += `.${fraction.slice(0, 3)}`;
370
+ }
371
+ if (zone) {
372
+ if (zone.toUpperCase() === 'Z') out += 'Z';
373
+ else {
374
+ const digits = zone.replace(':', '');
375
+ const hours = Number(digits.slice(1, 3));
376
+ const minutes = Number(digits.slice(3, 5));
377
+ if (hours > 23 || minutes > 59) return null;
378
+ out += `${digits.slice(0, 3)}:${digits.slice(3, 5)}`;
379
+ }
380
+ }
381
+ return out;
382
+ }
383
+ /** `"0"`–`"5"`, or `"-1"` for rejected (XMP Part 2, `xmp:Rating`). */ function normalizeRating(raw) {
384
+ const text = raw.trim();
385
+ if (!/^-?\d+(?:\.\d+)?$/.test(text)) return null;
386
+ const rating = Math.round(Number(text));
387
+ return rating >= -1 && rating <= 5 ? String(rating) : null;
388
+ }
389
+ /** An EXIF rational as XMP writes it (`"28/10"`) or a plain decimal. */ function parseRational(raw) {
390
+ const match = /^\s*(\d+(?:\.\d+)?)(?:\s*\/\s*(\d+(?:\.\d+)?))?\s*$/.exec(raw);
391
+ if (!match || match[1] === undefined) return null;
392
+ const numerator = Number(match[1]);
393
+ const denominator = match[2] === undefined ? 1 : Number(match[2]);
394
+ if (!denominator) return null;
395
+ const value = numerator / denominator;
396
+ return Number.isFinite(value) ? value : null;
397
+ }
398
+ function trimNumber(value, places) {
399
+ return String(Number(value.toFixed(places)));
400
+ }
401
+ /**
402
+ * `1/250 s`. Below a quarter second as a reciprocal, above it as decimal
403
+ * seconds — the convention photographers read, and exiftool's.
404
+ */ function formatExposure(raw) {
405
+ const seconds = parseRational(raw);
406
+ if (seconds === null || seconds <= 0) return null;
407
+ if (seconds < 0.25001) return `1/${Math.round(1 / seconds)} s`;
408
+ return `${trimNumber(seconds, 1)} s`;
409
+ }
410
+ function formatAperture(raw) {
411
+ const value = parseRational(raw);
412
+ return value && value > 0 ? `f/${trimNumber(value, 1)}` : null;
413
+ }
414
+ function formatFocalLength(raw) {
415
+ const value = parseRational(raw);
416
+ return value && value > 0 ? `${trimNumber(value, 1)} mm` : null;
417
+ }
418
+ /** TIFF 6.0 tag 274 / EXIF 2.32 §4.6.4 A "Orientation", as exiftool words it. */ const ORIENTATIONS = {
419
+ '1': 'Horizontal (normal)',
420
+ '2': 'Mirror horizontal',
421
+ '3': 'Rotate 180',
422
+ '4': 'Mirror vertical',
423
+ '5': 'Mirror horizontal and rotate 270 CW',
424
+ '6': 'Rotate 90 CW',
425
+ '7': 'Mirror horizontal and rotate 90 CW',
426
+ '8': 'Rotate 270 CW'
427
+ };
428
+ /**
429
+ * One GPS coordinate in the XMP form (XMP Part 2 / EXIF-in-XMP,
430
+ * "GPSCoordinate"): `DDD,MM.mmmmk` or `DDD,MM,SSk` with k one of NSEW.
431
+ * A bare signed decimal is tolerated. Null when out of range or on the
432
+ * wrong axis (an `E` latitude).
433
+ */ function parseGpsCoordinate(raw, axis) {
434
+ const text = raw.trim();
435
+ let value;
436
+ const match = /^(\d{1,3}(?:\.\d+)?)(?:,(\d{1,2}(?:\.\d+)?)(?:,(\d{1,2}(?:\.\d+)?))?)?\s*([NSEWnsew])$/.exec(text);
437
+ if (match && match[1] !== undefined && match[4] !== undefined) {
438
+ const degrees = Number(match[1]);
439
+ const minutes = match[2] === undefined ? 0 : Number(match[2]);
440
+ const seconds = match[3] === undefined ? 0 : Number(match[3]);
441
+ if (minutes >= 60 || seconds >= 60) return null;
442
+ const ref = match[4].toUpperCase();
443
+ if (axis === 'lat' ? !'NS'.includes(ref) : !'EW'.includes(ref)) return null;
444
+ value = degrees + minutes / 60 + seconds / 3600;
445
+ if (ref === 'S' || ref === 'W') value = -value;
446
+ } else if (/^-?\d{1,3}(?:\.\d+)?$/.test(text)) {
447
+ value = Number(text);
448
+ } else return null;
449
+ const limit = axis === 'lat' ? 90 : 180;
450
+ if (!Number.isFinite(value) || Math.abs(value) > limit) return null;
451
+ return value;
452
+ }
453
+ function formatDegrees(value) {
454
+ // Never print "-0.000000".
455
+ return (Math.abs(value) < 5e-7 ? 0 : value).toFixed(6);
456
+ }
457
+ const readText = asText;
458
+ const readList = asList;
459
+ const readDate = (value)=>{
460
+ const text = asText(value);
461
+ return text ? normalizeXmpDate(text) : null;
462
+ };
463
+ const readRating = (value)=>{
464
+ const text = asText(value);
465
+ return text ? normalizeRating(text) : null;
466
+ };
467
+ /** `pdf:Keywords` is one string (PDF 2.0 §14.3.3, Keywords). */ const readKeywordString = (value)=>{
468
+ const text = asText(value);
469
+ if (!text) return null;
470
+ const items = [
471
+ ...new Set(text.split(/[,;]/).map((item)=>item.trim()).filter(Boolean))
472
+ ];
473
+ return items.length ? items : null;
474
+ };
475
+ const formatted = (format)=>(value)=>{
476
+ const text = asText(value);
477
+ return text ? format(text) : null;
478
+ };
479
+ /** A single number that may arrive as a one-item `rdf:Seq`. */ const readIso = (value)=>{
480
+ var _value_items_;
481
+ const first = value.kind === 'simple' ? value.value : value.kind === 'array' ? (_value_items_ = value.items[0]) == null ? void 0 : _value_items_.value : undefined;
482
+ const text = first == null ? void 0 : first.trim();
483
+ return text && /^\d+$/.test(text) ? String(Number(text)) : null;
484
+ };
485
+ const src = (uri, local, read)=>({
486
+ uri,
487
+ local,
488
+ read
489
+ });
490
+ /** Canonical key → where XMP keeps it, strongest source first. */ function canonicalReads(profile) {
491
+ return [
492
+ {
493
+ key: 'title',
494
+ sources: [
495
+ src(NS.dc, 'title', readText)
496
+ ]
497
+ },
498
+ {
499
+ key: 'headline',
500
+ sources: [
501
+ src(NS.photoshop, 'Headline', readText)
502
+ ]
503
+ },
504
+ {
505
+ key: 'description',
506
+ sources: [
507
+ src(NS.dc, 'description', readText)
508
+ ]
509
+ },
510
+ {
511
+ key: 'keywords',
512
+ sources: [
513
+ src(NS.dc, 'subject', readList),
514
+ src(NS.pdf, 'Keywords', readKeywordString)
515
+ ]
516
+ },
517
+ {
518
+ key: 'instructions',
519
+ sources: [
520
+ src(NS.photoshop, 'Instructions', readText)
521
+ ]
522
+ },
523
+ {
524
+ key: 'label',
525
+ sources: [
526
+ src(NS.xmp, 'Label', readText)
527
+ ]
528
+ },
529
+ {
530
+ key: 'rating',
531
+ sources: [
532
+ src(NS.xmp, 'Rating', readRating)
533
+ ]
534
+ },
535
+ {
536
+ key: 'creator',
537
+ sources: [
538
+ src(NS.dc, 'creator', readList)
539
+ ]
540
+ },
541
+ {
542
+ key: 'copyright',
543
+ sources: [
544
+ src(NS.dc, 'rights', readText)
545
+ ]
546
+ },
547
+ {
548
+ key: 'credit',
549
+ sources: [
550
+ src(NS.photoshop, 'Credit', readText)
551
+ ]
552
+ },
553
+ {
554
+ key: 'source',
555
+ sources: [
556
+ src(NS.photoshop, 'Source', readText)
557
+ ]
558
+ },
559
+ {
560
+ key: 'usageTerms',
561
+ sources: [
562
+ src(NS.xmpRights, 'UsageTerms', readText)
563
+ ]
564
+ },
565
+ {
566
+ key: 'webStatement',
567
+ sources: [
568
+ src(NS.xmpRights, 'WebStatement', readText)
569
+ ]
570
+ },
571
+ {
572
+ key: 'city',
573
+ sources: [
574
+ src(NS.photoshop, 'City', readText)
575
+ ]
576
+ },
577
+ {
578
+ key: 'state',
579
+ sources: [
580
+ src(NS.photoshop, 'State', readText)
581
+ ]
582
+ },
583
+ {
584
+ key: 'country',
585
+ sources: [
586
+ src(NS.photoshop, 'Country', readText)
587
+ ]
588
+ },
589
+ {
590
+ key: 'createdAt',
591
+ sources: profile === 'pdf' ? [
592
+ src(NS.xmp, 'CreateDate', readDate)
593
+ ] : [
594
+ src(NS.photoshop, 'DateCreated', readDate),
595
+ src(NS.exif, 'DateTimeOriginal', readDate),
596
+ src(NS.xmp, 'CreateDate', readDate)
597
+ ]
598
+ },
599
+ {
600
+ key: 'make',
601
+ sources: [
602
+ src(NS.tiff, 'Make', readText)
603
+ ]
604
+ },
605
+ {
606
+ key: 'model',
607
+ sources: [
608
+ src(NS.tiff, 'Model', readText)
609
+ ]
610
+ },
611
+ {
612
+ key: 'lens',
613
+ sources: [
614
+ src(NS.exifEX, 'LensModel', readText),
615
+ src(NS.aux, 'Lens', readText)
616
+ ]
617
+ },
618
+ {
619
+ key: 'exposure',
620
+ sources: [
621
+ src(NS.exif, 'ExposureTime', formatted(formatExposure))
622
+ ]
623
+ },
624
+ {
625
+ key: 'aperture',
626
+ sources: [
627
+ src(NS.exif, 'FNumber', formatted(formatAperture))
628
+ ]
629
+ },
630
+ {
631
+ key: 'iso',
632
+ sources: [
633
+ src(NS.exifEX, 'PhotographicSensitivity', readIso),
634
+ src(NS.exif, 'ISOSpeedRatings', readIso)
635
+ ]
636
+ },
637
+ {
638
+ key: 'focalLength',
639
+ sources: [
640
+ src(NS.exif, 'FocalLength', formatted(formatFocalLength))
641
+ ]
642
+ },
643
+ {
644
+ key: 'modifiedAt',
645
+ sources: [
646
+ src(NS.xmp, 'ModifyDate', readDate)
647
+ ]
648
+ },
649
+ {
650
+ key: 'software',
651
+ sources: [
652
+ src(NS.xmp, 'CreatorTool', readText)
653
+ ]
654
+ },
655
+ {
656
+ key: 'producer',
657
+ sources: [
658
+ src(NS.pdf, 'Producer', readText)
659
+ ]
660
+ },
661
+ {
662
+ key: 'orientation',
663
+ sources: [
664
+ src(NS.tiff, 'Orientation', formatted((raw)=>{
665
+ var _ORIENTATIONS_raw_trim;
666
+ return (_ORIENTATIONS_raw_trim = ORIENTATIONS[raw.trim()]) != null ? _ORIENTATIONS_raw_trim : null;
667
+ }))
668
+ ]
669
+ }
670
+ ];
671
+ }
672
+ /**
673
+ * Every property the canonical mapping owns, in either profile. None of
674
+ * them is ever offered as an "other" field — a shadowed fallback (an
675
+ * `xmp:CreateDate` beside a `photoshop:DateCreated`) is the same idea as
676
+ * the field that won, not a second field.
677
+ */ const CLAIMED_PROPERTIES = new Set([
678
+ ...canonicalReads('image'),
679
+ ...canonicalReads('pdf')
680
+ ].flatMap((entry)=>entry.sources.map((source)=>propertyId(source.uri, source.local))));
681
+ /** Whether `(uri, local)` may be read or written as an "other" field. */ function isOtherProperty(uri, local) {
682
+ if (DENIED_NAMESPACES.has(uri)) return false;
683
+ const id = propertyId(uri, local);
684
+ if (DENIED_PROPERTIES.has(id) || CLAIMED_PROPERTIES.has(id)) return false;
685
+ // The other key is `|`-delimited; a URI containing one could not
686
+ // round-trip through parseOtherEmbeddedKey.
687
+ return !uri.includes('|') && !local.includes('|');
688
+ }
689
+ function openPacket(packet) {
690
+ const doc = parseXml(packet);
691
+ if (!doc) return null;
692
+ const roots = findRdfRoots(doc);
693
+ return {
694
+ doc,
695
+ roots,
696
+ descriptions: roots.flatMap(descriptionsOf)
697
+ };
698
+ }
699
+ function findProperties(descriptions, uri, local) {
700
+ return descriptions.flatMap((description)=>propertiesOf(description).filter((property)=>property.uri === uri && property.local === local));
701
+ }
702
+ /**
703
+ * Read an XMP packet into candidates. Accepts a whole packet (`<?xpacket`
704
+ * wrapper and `x:xmpmeta`), a bare `x:xmpmeta`, or a bare `rdf:RDF`
705
+ * fragment. Never throws; unreadable input yields `[]`.
706
+ */ export function readXmp(packet, options = {}) {
707
+ try {
708
+ var _options_profile;
709
+ return read(packet, (_options_profile = options.profile) != null ? _options_profile : 'image');
710
+ } catch (unused) {
711
+ // Defensive: the parser and walkers do not throw, and a reader of
712
+ // hostile bytes must degrade to "nothing found" if one ever does.
713
+ return [];
714
+ }
715
+ }
716
+ function read(packet, profile) {
717
+ if (typeof packet !== 'string') return [];
718
+ const parsed = openPacket(packet);
719
+ if (!parsed) return [];
720
+ const properties = parsed.descriptions.flatMap(propertiesOf);
721
+ const byId = new Map();
722
+ for (const property of properties){
723
+ const id = propertyId(property.uri, property.local);
724
+ const list = byId.get(id);
725
+ if (list) list.push(property);
726
+ else byId.set(id, [
727
+ property
728
+ ]);
729
+ }
730
+ const out = [];
731
+ for (const { key, sources } of canonicalReads(profile)){
732
+ const value = firstValue(byId, sources);
733
+ if (value !== null) out.push({
734
+ key,
735
+ value,
736
+ source: 'xmp'
737
+ });
738
+ }
739
+ const gps = readGps(byId);
740
+ if (gps) out.push({
741
+ key: 'gps',
742
+ value: gps,
743
+ source: 'xmp'
744
+ });
745
+ const seen = new Set();
746
+ for (const property of properties){
747
+ if (!isOtherProperty(property.uri, property.local)) continue;
748
+ const key = otherEmbeddedKey('xmp', property.uri, property.local);
749
+ if (seen.has(key)) continue;
750
+ const value = readValue(property);
751
+ let shown = null;
752
+ if (value.kind === 'simple') shown = value.value.trim() || null;
753
+ else if (value.kind === 'array') {
754
+ var _asList;
755
+ shown = value.container === 'alt' ? asText(value) : (_asList = asList(value)) != null ? _asList : null;
756
+ }
757
+ if (shown === null) continue;
758
+ seen.add(key);
759
+ out.push({
760
+ key,
761
+ value: shown,
762
+ source: 'xmp',
763
+ label: `${property.prefix || CONVENTIONAL_PREFIX[property.uri] || 'xmp'}:${property.local}`
764
+ });
765
+ }
766
+ return out;
767
+ }
768
+ function firstValue(byId, sources) {
769
+ for (const source of sources){
770
+ var _byId_get;
771
+ for (const property of (_byId_get = byId.get(propertyId(source.uri, source.local))) != null ? _byId_get : []){
772
+ const value = source.read(readValue(property));
773
+ if (value !== null) return value;
774
+ }
775
+ }
776
+ return null;
777
+ }
778
+ function readGps(byId) {
779
+ const coordinate = (local, axis)=>{
780
+ var _byId_get;
781
+ for (const property of (_byId_get = byId.get(propertyId(NS.exif, local))) != null ? _byId_get : []){
782
+ const text = asText(readValue(property));
783
+ const value = text ? parseGpsCoordinate(text, axis) : null;
784
+ if (value !== null) return value;
785
+ }
786
+ return null;
787
+ };
788
+ const lat = coordinate('GPSLatitude', 'lat');
789
+ const lon = coordinate('GPSLongitude', 'lon');
790
+ return lat === null || lon === null ? null : `${formatDegrees(lat)},${formatDegrees(lon)}`;
791
+ }
792
+ const target = (uri, local, shape, create = true)=>({
793
+ uri,
794
+ local,
795
+ shape,
796
+ create
797
+ });
798
+ /** Canonical key → the properties an edit of it writes, per profile. */ function canonicalWrites(profile) {
799
+ const pdf = profile === 'pdf';
800
+ return {
801
+ title: [
802
+ target(NS.dc, 'title', 'alt')
803
+ ],
804
+ description: [
805
+ target(NS.dc, 'description', 'alt')
806
+ ],
807
+ creator: [
808
+ target(NS.dc, 'creator', 'seq')
809
+ ],
810
+ copyright: [
811
+ target(NS.dc, 'rights', 'alt')
812
+ ],
813
+ keywords: pdf ? [
814
+ target(NS.pdf, 'Keywords', 'simple'),
815
+ target(NS.dc, 'subject', 'bag', false)
816
+ ] : [
817
+ target(NS.dc, 'subject', 'bag'),
818
+ target(NS.pdf, 'Keywords', 'simple', false)
819
+ ],
820
+ headline: [
821
+ target(NS.photoshop, 'Headline', 'simple')
822
+ ],
823
+ credit: [
824
+ target(NS.photoshop, 'Credit', 'simple')
825
+ ],
826
+ source: [
827
+ target(NS.photoshop, 'Source', 'simple')
828
+ ],
829
+ instructions: [
830
+ target(NS.photoshop, 'Instructions', 'simple')
831
+ ],
832
+ city: [
833
+ target(NS.photoshop, 'City', 'simple')
834
+ ],
835
+ state: [
836
+ target(NS.photoshop, 'State', 'simple')
837
+ ],
838
+ country: [
839
+ target(NS.photoshop, 'Country', 'simple')
840
+ ],
841
+ createdAt: pdf ? [
842
+ target(NS.xmp, 'CreateDate', 'simple')
843
+ ] : [
844
+ target(NS.photoshop, 'DateCreated', 'simple'),
845
+ target(NS.exif, 'DateTimeOriginal', 'simple', false),
846
+ target(NS.xmp, 'CreateDate', 'simple', false)
847
+ ],
848
+ modifiedAt: [
849
+ target(NS.xmp, 'ModifyDate', 'simple')
850
+ ],
851
+ software: [
852
+ target(NS.xmp, 'CreatorTool', 'simple')
853
+ ],
854
+ producer: [
855
+ target(NS.pdf, 'Producer', 'simple')
856
+ ],
857
+ label: [
858
+ target(NS.xmp, 'Label', 'simple')
859
+ ],
860
+ rating: [
861
+ target(NS.xmp, 'Rating', 'simple')
862
+ ],
863
+ usageTerms: [
864
+ target(NS.xmpRights, 'UsageTerms', 'alt')
865
+ ],
866
+ webStatement: [
867
+ target(NS.xmpRights, 'WebStatement', 'simple')
868
+ ],
869
+ make: [
870
+ target(NS.tiff, 'Make', 'simple')
871
+ ],
872
+ model: [
873
+ target(NS.tiff, 'Model', 'simple')
874
+ ],
875
+ lens: [
876
+ target(NS.exifEX, 'LensModel', 'simple'),
877
+ target(NS.aux, 'Lens', 'simple', false)
878
+ ]
879
+ };
880
+ }
881
+ /**
882
+ * Canonical keys XMP holds but nobody edits: they describe how the bytes
883
+ * were captured. Asking to write one is a caller bug, and says so.
884
+ */ const READ_ONLY_KEYS = new Set([
885
+ 'exposure',
886
+ 'aperture',
887
+ 'iso',
888
+ 'focalLength',
889
+ 'orientation'
890
+ ]);
891
+ /** An XMP date (XMP Part 1 §8.2.1.1), the only thing a date field takes. */ const XMP_DATE = /^\d{4}(?:-\d{2}(?:-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:\d{2})?)?)?)?$/;
892
+ /** The packet {@link writeXmp} starts from when a file has none. */ const PACKET_HEADER = '<?xpacket begin="\uFEFF" id="W5M0MpCehiHzreSzNTczkc9d"?>\n' + '<x:xmpmeta xmlns:x="adobe:ns:meta/">\n' + ' <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">\n' + ' <rdf:Description rdf:about="">\n' + ' </rdf:Description>\n' + ' </rdf:RDF>\n' + '</x:xmpmeta>\n';
893
+ /**
894
+ * Padding lets a later editor grow the packet in place without moving the
895
+ * bytes after it (XMP Part 3 §1.1.1 recommends about 2 KB). Twenty lines
896
+ * of 100 spaces is what Adobe's toolkit writes.
897
+ */ const PACKET_PADDING = (' '.repeat(100) + '\n').repeat(20);
898
+ const PACKET_TRAILER = '<?xpacket end="w"?>';
899
+ /**
900
+ * Apply `patch` to `packet` and return the new packet.
901
+ *
902
+ * - `null` removes a key; a string or string[] sets it. An empty string or
903
+ * list is a removal.
904
+ * - Canonical keys fan out as {@link canonicalWrites} lists them. Keys XMP
905
+ * has no place for (`subject`, a `png|…` key) are ignored — the patch is
906
+ * shared with the file's other writers — and the camera's measurements
907
+ * (`exposure`, …) throw.
908
+ * - `gps: null` removes every `exif:GPS*` property. Setting it throws.
909
+ * - An `xmp|<uri>|<local>` key edits or removes a property that already
910
+ * exists; one that does not, or that the reader would never offer,
911
+ * throws.
912
+ * - `xmp:MetadataDate` is set to `now` on every write; in the `pdf`
913
+ * profile `xmp:ModifyDate` is too, unless the patch sets `modifiedAt`.
914
+ * - With `packet === null`, a fresh packet is created if the patch sets
915
+ * anything XMP holds; otherwise the result is `null`.
916
+ *
917
+ * Throws {@link EmbeddedWriteError} when the packet cannot be parsed, has
918
+ * no `rdf:RDF`, or the edit is not one XMP can take. Never returns a
919
+ * packet it could not re-read.
920
+ */ export function writeXmp(packet, patch, options) {
921
+ const { profile, now } = options;
922
+ if (!(now instanceof Date) || Number.isNaN(now.getTime())) {
923
+ throw new EmbeddedWriteError('The time of the edit is not a valid date.');
924
+ }
925
+ const ops = planWrite(patch, profile);
926
+ const sets = ops.some((op)=>op.type !== 'gps' && op.value !== null);
927
+ if (packet === null) {
928
+ const other = ops.find((op)=>op.type === 'other');
929
+ if ((other == null ? void 0 : other.type) === 'other') {
930
+ throw new EmbeddedWriteError(`${other.label} is not in this file's XMP metadata.`);
931
+ }
932
+ if (!sets) return null;
933
+ }
934
+ const source = packet === null ? PACKET_HEADER + PACKET_PADDING + PACKET_TRAILER : packet;
935
+ const parsed = openPacket(source);
936
+ if (!parsed) {
937
+ throw new EmbeddedWriteError("This file's XMP metadata could not be read, so it cannot be edited safely.");
938
+ }
939
+ const firstRoot = parsed.roots[0];
940
+ if (!firstRoot) {
941
+ throw new EmbeddedWriteError("This file's XMP metadata has no RDF block to edit.");
942
+ }
943
+ const editor = new PacketEditor(parsed.descriptions, firstRoot);
944
+ for (const op of ops){
945
+ if (op.type === 'gps') {
946
+ for (const property of editor.all()){
947
+ if (property.uri === NS.exif && property.local.startsWith('GPS')) {
948
+ editor.remove(property);
949
+ }
950
+ }
951
+ } else if (op.type === 'canonical') {
952
+ if (op.value === null) {
953
+ for (const each of op.targets){
954
+ for (const property of editor.find(each.uri, each.local)){
955
+ editor.remove(property);
956
+ }
957
+ }
958
+ } else {
959
+ for (const each of op.targets)editor.set(each, op.value);
960
+ }
961
+ } else {
962
+ applyOther(editor, op);
963
+ }
964
+ }
965
+ const stamp = formatXmpNow(now);
966
+ editor.set(target(NS.xmp, 'MetadataDate', 'simple'), stamp);
967
+ if (profile === 'pdf') {
968
+ const modified = patch['modifiedAt'];
969
+ editor.set(target(NS.xmp, 'ModifyDate', 'simple'), typeof modified === 'string' && modified ? modified : stamp);
970
+ }
971
+ const out = serializeXml(parsed.doc);
972
+ // The promise is "never produce a corrupt file": prove the result parses.
973
+ if (!parseXml(out)) {
974
+ throw new EmbeddedWriteError('The edited XMP metadata did not re-read cleanly, so it was not written.');
975
+ }
976
+ return out;
977
+ }
978
+ /** `2026-09-24T18:04:05Z` — whole seconds, UTC, zone always present. */ function formatXmpNow(now) {
979
+ return now.toISOString().replace(/\.\d{3}Z$/, 'Z');
980
+ }
981
+ function planWrite(patch, profile) {
982
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) {
983
+ throw new EmbeddedWriteError('Nothing to change.');
984
+ }
985
+ const writes = canonicalWrites(profile);
986
+ const format = profile === 'pdf' ? 'pdf' : 'jpeg';
987
+ const ops = [];
988
+ for (const [key, raw] of Object.entries(patch)){
989
+ const other = key.startsWith('xmp|') ? parseOtherEmbeddedKey(key) : null;
990
+ if (other) {
991
+ const [uri, local, ...rest] = other.parts;
992
+ if (!uri || !local || rest.length || !isOtherProperty(uri, local)) {
993
+ throw new EmbeddedWriteError(`${local != null ? local : key} cannot be edited as an XMP field.`);
994
+ }
995
+ const conventional = CONVENTIONAL_PREFIX[uri];
996
+ const label = conventional ? `${conventional}:${local}` : local;
997
+ ops.push({
998
+ type: 'other',
999
+ label,
1000
+ uri,
1001
+ local,
1002
+ value: patchValue(label, raw)
1003
+ });
1004
+ continue;
1005
+ }
1006
+ const label = embeddedFieldLabel(key, format);
1007
+ if (key === 'gps') {
1008
+ if (patchValue(label, raw) !== null) {
1009
+ throw new EmbeddedWriteError(`${label} can only be removed.`);
1010
+ }
1011
+ ops.push({
1012
+ type: 'gps'
1013
+ });
1014
+ continue;
1015
+ }
1016
+ if (READ_ONLY_KEYS.has(key)) {
1017
+ throw new EmbeddedWriteError(`${label} cannot be edited.`);
1018
+ }
1019
+ const targets = Object.prototype.hasOwnProperty.call(writes, key) ? writes[key] : undefined;
1020
+ // Not an XMP key: another writer of this file owns it.
1021
+ if (!targets) continue;
1022
+ const value = patchValue(label, raw);
1023
+ if (value !== null) validateCanonical(key, label, value);
1024
+ ops.push({
1025
+ type: 'canonical',
1026
+ label,
1027
+ targets,
1028
+ value
1029
+ });
1030
+ }
1031
+ return ops;
1032
+ }
1033
+ /** Normalize one patch value; '' and [] mean removal. */ function patchValue(label, raw) {
1034
+ if (raw === null || raw === undefined) return null;
1035
+ if (typeof raw === 'string') {
1036
+ if (hasNonXmlCharacters(raw)) {
1037
+ throw new EmbeddedWriteError(`${label} contains characters that cannot be stored in XMP.`);
1038
+ }
1039
+ return raw.trim() ? raw : null;
1040
+ }
1041
+ if (Array.isArray(raw)) {
1042
+ const items = [];
1043
+ for (const item of raw){
1044
+ if (typeof item !== 'string') {
1045
+ throw new EmbeddedWriteError(`${label} must be text.`);
1046
+ }
1047
+ if (hasNonXmlCharacters(item)) {
1048
+ throw new EmbeddedWriteError(`${label} contains characters that cannot be stored in XMP.`);
1049
+ }
1050
+ if (item.trim()) items.push(item);
1051
+ }
1052
+ return items.length ? items : null;
1053
+ }
1054
+ throw new EmbeddedWriteError(`${label} must be text.`);
1055
+ }
1056
+ function validateCanonical(key, label, value) {
1057
+ const text = Array.isArray(value) ? value.join(', ') : value;
1058
+ if ((key === 'createdAt' || key === 'modifiedAt') && !XMP_DATE.test(text)) {
1059
+ throw new EmbeddedWriteError(`${label} must be a date.`);
1060
+ }
1061
+ if (key === 'rating' && !/^(?:-1|[0-5])$/.test(text)) {
1062
+ throw new EmbeddedWriteError(`${label} must be between 0 and 5.`);
1063
+ }
1064
+ }
1065
+ function applyOther(editor, op) {
1066
+ const found = editor.find(op.uri, op.local);
1067
+ if (!found.length) {
1068
+ throw new EmbeddedWriteError(`${op.label} is not in this file's XMP metadata.`);
1069
+ }
1070
+ if (op.value === null) {
1071
+ for (const property of found)editor.remove(property);
1072
+ return;
1073
+ }
1074
+ for (const property of found){
1075
+ const current = readValue(property);
1076
+ if (current.kind === 'struct') {
1077
+ throw new EmbeddedWriteError(`${op.label} is structured and cannot be edited here.`);
1078
+ }
1079
+ editor.write(property, current.kind === 'array' ? current.container : 'simple', op.value);
1080
+ }
1081
+ }
1082
+ /** The layout of `node` from the whitespace text just before it. */ function layoutOf(node) {
1083
+ const parent = node.parent;
1084
+ if (!parent) return null;
1085
+ const index = parent.children.indexOf(node);
1086
+ const before = parent.children[index - 1];
1087
+ if (!isWhitespaceText(before) || !before.raw) return null;
1088
+ const raw = before.raw;
1089
+ const newline = raw.lastIndexOf('\n');
1090
+ if (newline === -1) return {
1091
+ lineBreak: '',
1092
+ indent: raw
1093
+ };
1094
+ return {
1095
+ lineBreak: raw[newline - 1] === '\r' ? '\r\n' : '\n',
1096
+ indent: raw.slice(newline + 1)
1097
+ };
1098
+ }
1099
+ function deeper(layout, unit) {
1100
+ if (!layout || !layout.lineBreak) return layout;
1101
+ return _extends({}, layout, {
1102
+ indent: layout.indent + unit
1103
+ });
1104
+ }
1105
+ function whitespace(layout) {
1106
+ return layout ? [
1107
+ createWhitespace(layout.lineBreak + layout.indent)
1108
+ ] : [];
1109
+ }
1110
+ function qualify(prefix, local) {
1111
+ return prefix ? `${prefix}:${local}` : local;
1112
+ }
1113
+ /**
1114
+ * The DOM surgery behind {@link writeXmp}. Holds the Descriptions and the
1115
+ * packet's indentation habits, and re-scans on every lookup so an edit is
1116
+ * always made against the tree as it is now.
1117
+ */ let PacketEditor = class PacketEditor {
1118
+ all() {
1119
+ return this.descriptions.flatMap(propertiesOf);
1120
+ }
1121
+ find(uri, local) {
1122
+ return findProperties(this.descriptions, uri, local);
1123
+ }
1124
+ /** Set a property everywhere it lives, or add it if `create` allows. */ set(where, value) {
1125
+ const found = this.find(where.uri, where.local);
1126
+ if (!found.length) {
1127
+ if (where.create) this.add(where, value);
1128
+ return;
1129
+ }
1130
+ for (const property of found)this.write(property, where.shape, value);
1131
+ }
1132
+ /** Remove one occurrence, and the indentation that led up to it. */ remove(property) {
1133
+ if (property.attribute) {
1134
+ removeAttribute(property.description, property.attribute.name);
1135
+ return;
1136
+ }
1137
+ const element = property.element;
1138
+ const parent = element == null ? void 0 : element.parent;
1139
+ if (!element || !parent) return;
1140
+ const index = parent.children.indexOf(element);
1141
+ if (index === -1) return;
1142
+ const before = parent.children[index - 1];
1143
+ if (isWhitespaceText(before)) parent.children.splice(index - 1, 2);
1144
+ else parent.children.splice(index, 1);
1145
+ element.parent = null;
1146
+ }
1147
+ /** Write `value` into an existing occurrence, keeping its form. */ write(property, shape, value) {
1148
+ const items = Array.isArray(value) ? value : [
1149
+ value
1150
+ ];
1151
+ const text = Array.isArray(value) ? value.join(', ') : value;
1152
+ if (property.attribute) {
1153
+ if (shape === 'simple') {
1154
+ setAttribute(property.description, property.attribute.name, text);
1155
+ return;
1156
+ }
1157
+ // An attribute cannot hold an array: move it to an element in the
1158
+ // same Description, under the prefix it was written with.
1159
+ removeAttribute(property.description, property.attribute.name);
1160
+ this.appendProperty(property.description, qualify(property.prefix, property.local), shape, items);
1161
+ return;
1162
+ }
1163
+ const element = property.element;
1164
+ if (!element) return;
1165
+ if (shape === 'simple') {
1166
+ const resource = rdfAttribute(element, 'resource');
1167
+ if (resource && !elementChildren(element).length) {
1168
+ setAttribute(element, resource.name, text);
1169
+ return;
1170
+ }
1171
+ if (!elementChildren(element).length && !hasFieldAttributes(element) && !rdfAttribute(element, 'parseType')) {
1172
+ setTextContent(element, text);
1173
+ return;
1174
+ }
1175
+ this.clearValueAttributes(element);
1176
+ setTextContent(element, text);
1177
+ return;
1178
+ }
1179
+ // Only an array of plain items is edited in place; one whose items are
1180
+ // structs (or that is no array at all) is rebuilt below.
1181
+ const current = readValue(property);
1182
+ const container = current.kind === 'array' ? arrayContainer(element) : null;
1183
+ if (container) {
1184
+ if (shape === 'alt' && container.kind === 'alt') {
1185
+ this.setDefaultAlternative(container.node, text);
1186
+ return;
1187
+ }
1188
+ // A Bag where a Seq belongs (or the reverse) keeps its container:
1189
+ // the order question is the file's, the values are the edit's.
1190
+ if (shape !== 'alt' && container.kind !== 'alt') {
1191
+ this.setItems(container.node, items, null);
1192
+ return;
1193
+ }
1194
+ }
1195
+ // The property holds the wrong kind of value; rebuild its content.
1196
+ this.clearValueAttributes(element);
1197
+ const layout = layoutOf(element);
1198
+ const inner = deeper(layout, this.unit);
1199
+ const built = this.buildContainer(element, shape, shape === 'alt' ? [
1200
+ text
1201
+ ] : items, inner);
1202
+ replaceChildren(element, [
1203
+ ...whitespace(inner),
1204
+ built,
1205
+ ...whitespace(layout)
1206
+ ]);
1207
+ element.selfClosing = false;
1208
+ }
1209
+ /** Add a property that does not exist yet to the first Description. */ add(where, value) {
1210
+ const prefix = this.ensurePrefix(this.home, where.uri);
1211
+ const items = Array.isArray(value) ? value : [
1212
+ value
1213
+ ];
1214
+ this.appendProperty(this.home, qualify(prefix, where.local), where.shape, where.shape === 'alt' ? [
1215
+ items.join(', ')
1216
+ ] : items);
1217
+ }
1218
+ appendProperty(description, name, shape, items) {
1219
+ const { layout, insert } = this.planAppend(description);
1220
+ let element;
1221
+ if (shape === 'simple') {
1222
+ element = createElement(name, [], [
1223
+ createText(items.join(', '))
1224
+ ]);
1225
+ } else {
1226
+ element = createElement(name);
1227
+ const inner = deeper(layout, this.unit);
1228
+ const container = this.buildContainer(description, shape, items, inner);
1229
+ insertChildren(element, 0, [
1230
+ ...whitespace(inner),
1231
+ container,
1232
+ ...whitespace(layout)
1233
+ ]);
1234
+ }
1235
+ insert(element);
1236
+ }
1237
+ /**
1238
+ * Where the next child element of `parent` goes and how it is indented:
1239
+ * after the last element child, at that child's indent; or, in an empty
1240
+ * parent, one level deeper than the parent, before its closing tag.
1241
+ */ planAppend(parent) {
1242
+ const kids = elementChildren(parent);
1243
+ const last = kids[kids.length - 1];
1244
+ if (last) {
1245
+ var _reverse_map_find;
1246
+ const layout = (_reverse_map_find = [
1247
+ ...kids
1248
+ ].reverse().map(layoutOf).find((found)=>found !== null)) != null ? _reverse_map_find : null;
1249
+ return {
1250
+ layout,
1251
+ insert: (node)=>insertChildren(parent, parent.children.indexOf(last) + 1, [
1252
+ ...whitespace(layout),
1253
+ node
1254
+ ])
1255
+ };
1256
+ }
1257
+ const own = layoutOf(parent);
1258
+ const layout = deeper(own, this.unit);
1259
+ return {
1260
+ layout,
1261
+ insert: (node)=>{
1262
+ const tail = parent.children[parent.children.length - 1];
1263
+ if (layout && isWhitespaceText(tail) && tail.raw) {
1264
+ insertChildren(parent, parent.children.length - 1, [
1265
+ ...whitespace(layout),
1266
+ node
1267
+ ]);
1268
+ } else {
1269
+ insertChildren(parent, parent.children.length, [
1270
+ ...whitespace(layout),
1271
+ node,
1272
+ ...whitespace(own)
1273
+ ]);
1274
+ }
1275
+ parent.selfClosing = false;
1276
+ }
1277
+ };
1278
+ }
1279
+ /** The prefix `rdf:` elements take at `scope`. */ rdfPrefix(scope) {
1280
+ const bound = prefixForNamespace(scope, RDF);
1281
+ if (bound !== null) return bound;
1282
+ if (resolvePrefix(scope, '') === RDF) return '';
1283
+ return 'rdf';
1284
+ }
1285
+ buildContainer(scope, shape, items, layout) {
1286
+ const prefix = this.rdfPrefix(scope);
1287
+ const name = qualify(prefix, shape === 'alt' ? 'Alt' : shape === 'bag' ? 'Bag' : 'Seq');
1288
+ const container = createElement(name);
1289
+ const itemLayout = deeper(layout, this.unit);
1290
+ const lang = shape === 'alt' ? 'x-default' : null;
1291
+ insertChildren(container, 0, [
1292
+ ...items.flatMap((item)=>[
1293
+ ...whitespace(itemLayout),
1294
+ this.buildItem(prefix, item, lang)
1295
+ ]),
1296
+ ...whitespace(layout)
1297
+ ]);
1298
+ return container;
1299
+ }
1300
+ buildItem(rdfPrefix, value, lang) {
1301
+ return createElement(qualify(rdfPrefix, 'li'), lang ? [
1302
+ [
1303
+ 'xml:lang',
1304
+ lang
1305
+ ]
1306
+ ] : [], value ? [
1307
+ createText(value)
1308
+ ] : [], this.quote);
1309
+ }
1310
+ /**
1311
+ * Set the `x-default` item of a language alternative. Following the
1312
+ * XMP Toolkit's `SetLocalizedText`, an item in a specific language that
1313
+ * held the same text as the old default is its twin and changes with
1314
+ * it; an item that had been translated is left alone. With no
1315
+ * `x-default` the first item — the one a reader shows — is set.
1316
+ */ setDefaultAlternative(container, value) {
1317
+ const items = arrayItems(container);
1318
+ const fallback = items.find((item)=>{
1319
+ var _langOf;
1320
+ return ((_langOf = langOf(item)) == null ? void 0 : _langOf.toLowerCase()) === 'x-default';
1321
+ });
1322
+ if (fallback) {
1323
+ const previous = textContent(fallback);
1324
+ for (const item of items){
1325
+ if (item !== fallback && !elementChildren(item).length && textContent(item) === previous) {
1326
+ setTextContent(item, value);
1327
+ }
1328
+ }
1329
+ setTextContent(fallback, value);
1330
+ return;
1331
+ }
1332
+ const first = items[0];
1333
+ if (first) {
1334
+ setTextContent(first, value);
1335
+ return;
1336
+ }
1337
+ this.setItems(container, [
1338
+ value
1339
+ ], 'x-default');
1340
+ }
1341
+ /**
1342
+ * Replace an array's items, reusing the whitespace the old items were
1343
+ * laid out with so the rewritten block looks like the rest of the file.
1344
+ */ setItems(container, values, lang) {
1345
+ const kids = container.children;
1346
+ const firstItem = kids.findIndex((node)=>node.type === 'element');
1347
+ let itemSpace = null;
1348
+ let closeSpace = null;
1349
+ if (firstItem !== -1) {
1350
+ const before = kids[firstItem - 1];
1351
+ if (isWhitespaceText(before)) itemSpace = before.raw;
1352
+ const tail = kids[kids.length - 1];
1353
+ if (isWhitespaceText(tail) && kids.length - 1 > firstItem) {
1354
+ closeSpace = tail.raw;
1355
+ }
1356
+ } else {
1357
+ const layout = layoutOf(container);
1358
+ const inner = deeper(layout, this.unit);
1359
+ if (inner) itemSpace = inner.lineBreak + inner.indent;
1360
+ if (layout) closeSpace = layout.lineBreak + layout.indent;
1361
+ }
1362
+ const prefix = splitQName(container.name).prefix;
1363
+ const nodes = [];
1364
+ for (const value of values){
1365
+ if (itemSpace !== null) nodes.push(createWhitespace(itemSpace));
1366
+ nodes.push(this.buildItem(prefix, value, lang));
1367
+ }
1368
+ if (closeSpace !== null) nodes.push(createWhitespace(closeSpace));
1369
+ replaceChildren(container, nodes);
1370
+ container.selfClosing = false;
1371
+ }
1372
+ /**
1373
+ * Drop what made a property element structured — `rdf:parseType`,
1374
+ * `rdf:resource`, field attributes, `xml:lang` — keeping namespace
1375
+ * declarations, which descendants elsewhere may rely on.
1376
+ */ clearValueAttributes(element) {
1377
+ element.attributes = element.attributes.filter(isNamespaceDeclaration);
1378
+ }
1379
+ /**
1380
+ * A prefix bound to `uri` at `scope`, declaring one on `scope` when none
1381
+ * is: the conventional prefix, or `<conventional>1`, `2`, … if a file
1382
+ * has already used that prefix for something else.
1383
+ */ ensurePrefix(scope, uri) {
1384
+ var _CONVENTIONAL_PREFIX_uri, _declarations_, _ref, _ref1;
1385
+ const existing = prefixForNamespace(scope, uri);
1386
+ if (existing !== null) return existing;
1387
+ const base = (_CONVENTIONAL_PREFIX_uri = CONVENTIONAL_PREFIX[uri]) != null ? _CONVENTIONAL_PREFIX_uri : 'ns';
1388
+ let prefix = base;
1389
+ for(let n = 1; resolvePrefix(scope, prefix) !== null; n++){
1390
+ prefix = `${base}${n}`;
1391
+ }
1392
+ const declarations = scope.attributes.filter(isNamespaceDeclaration);
1393
+ const anchor = (_declarations_ = declarations[declarations.length - 1]) != null ? _declarations_ : scope.attributes[scope.attributes.length - 1];
1394
+ setAttribute(scope, `xmlns:${prefix}`, uri, {
1395
+ leading: (_ref = anchor == null ? void 0 : anchor.leading) != null ? _ref : ' ',
1396
+ quote: (_ref1 = anchor == null ? void 0 : anchor.quote) != null ? _ref1 : this.quote,
1397
+ index: anchor ? scope.attributes.indexOf(anchor) + 1 : 0
1398
+ });
1399
+ return prefix;
1400
+ }
1401
+ /** An `rdf:Description` for an `rdf:RDF` that holds none. */ createDescription(root) {
1402
+ var _ref;
1403
+ var _root_attributes_;
1404
+ const prefix = prefixForNamespace(root, RDF);
1405
+ const quote = (_ref = (_root_attributes_ = root.attributes[0]) == null ? void 0 : _root_attributes_.quote) != null ? _ref : '"';
1406
+ const description = prefix ? createElement(`${prefix}:Description`, [
1407
+ [
1408
+ `${prefix}:about`,
1409
+ ''
1410
+ ]
1411
+ ], [], quote) : createElement('rdf:Description', [
1412
+ [
1413
+ 'xmlns:rdf',
1414
+ RDF
1415
+ ],
1416
+ [
1417
+ 'rdf:about',
1418
+ ''
1419
+ ]
1420
+ ], [], quote);
1421
+ const own = layoutOf(root);
1422
+ const layout = deeper(own, ' ');
1423
+ const tail = root.children[root.children.length - 1];
1424
+ if (layout && isWhitespaceText(tail) && tail.raw) {
1425
+ insertChildren(root, root.children.length - 1, [
1426
+ ...whitespace(layout),
1427
+ description
1428
+ ]);
1429
+ } else {
1430
+ insertChildren(root, root.children.length, [
1431
+ ...whitespace(layout),
1432
+ description,
1433
+ ...whitespace(own)
1434
+ ]);
1435
+ }
1436
+ root.selfClosing = false;
1437
+ // An empty Description reads the same either way; open it so its
1438
+ // first property lands between the tags with its own line.
1439
+ description.children = layout ? whitespace(layout) : [];
1440
+ return description;
1441
+ }
1442
+ constructor(descriptions, root){
1443
+ var _descriptions_find, _ref, _ref1;
1444
+ var _home_attributes_find, _home_attributes_;
1445
+ const home = (_descriptions_find = descriptions.find((node)=>isRdf(node, 'Description'))) != null ? _descriptions_find : this.createDescription(root);
1446
+ this.descriptions = descriptions.includes(home) ? descriptions : [
1447
+ ...descriptions,
1448
+ home
1449
+ ];
1450
+ this.home = home;
1451
+ this.unit = inferIndentUnit(home);
1452
+ this.quote = (_ref = (_ref1 = (_home_attributes_find = home.attributes.find((attr)=>!isNamespaceDeclaration(attr))) == null ? void 0 : _home_attributes_find.quote) != null ? _ref1 : (_home_attributes_ = home.attributes[0]) == null ? void 0 : _home_attributes_.quote) != null ? _ref : '"';
1453
+ }
1454
+ };
1455
+ /**
1456
+ * One level of indentation as this packet writes it: Adobe's XMP Core
1457
+ * indents by one space, Photoshop's serializer by three, exiftool by one.
1458
+ * Measured from the Description's first property, else from the
1459
+ * Description against its `rdf:RDF`; one space when neither says.
1460
+ */ function inferIndentUnit(description) {
1461
+ var _description_parent;
1462
+ const own = layoutOf(description);
1463
+ const firstChild = elementChildren(description)[0];
1464
+ const child = firstChild ? layoutOf(firstChild) : null;
1465
+ if (own && child && child.indent.length > own.indent.length && child.indent.startsWith(own.indent)) {
1466
+ return child.indent.slice(own.indent.length);
1467
+ }
1468
+ const parent = ((_description_parent = description.parent) == null ? void 0 : _description_parent.type) === 'element' ? layoutOf(description.parent) : null;
1469
+ if (own && parent && own.indent.length > parent.indent.length && own.indent.startsWith(parent.indent)) {
1470
+ return own.indent.slice(parent.indent.length);
1471
+ }
1472
+ return ' ';
1473
+ }
1474
+
1475
+ //# sourceMappingURL=xmp.js.map