@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.
- package/package.json +11 -11
- package/src/lib/app-utils/docs-help.generated.d.ts +1 -1
- package/src/lib/app-utils/docs-help.generated.js +1 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +3 -3
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/media-embedded-fields.d.ts +471 -0
- package/src/lib/app-utils/media-embedded-fields.js +619 -0
- package/src/lib/app-utils/media-embedded-fields.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/exif.d.ts +160 -0
- package/src/lib/app-utils/media-embedded-metadata/exif.js +1267 -0
- package/src/lib/app-utils/media-embedded-metadata/exif.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/gif.d.ts +44 -0
- package/src/lib/app-utils/media-embedded-metadata/gif.js +134 -0
- package/src/lib/app-utils/media-embedded-metadata/gif.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/heif.d.ts +23 -0
- package/src/lib/app-utils/media-embedded-metadata/heif.js +354 -0
- package/src/lib/app-utils/media-embedded-metadata/heif.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/icc.d.ts +22 -0
- package/src/lib/app-utils/media-embedded-metadata/icc.js +85 -0
- package/src/lib/app-utils/media-embedded-metadata/icc.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/image-blocks.d.ts +145 -0
- package/src/lib/app-utils/media-embedded-metadata/image-blocks.js +200 -0
- package/src/lib/app-utils/media-embedded-metadata/image-blocks.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/index.d.ts +93 -0
- package/src/lib/app-utils/media-embedded-metadata/index.js +744 -0
- package/src/lib/app-utils/media-embedded-metadata/index.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/iptc.d.ts +59 -0
- package/src/lib/app-utils/media-embedded-metadata/iptc.js +529 -0
- package/src/lib/app-utils/media-embedded-metadata/iptc.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/jpeg.d.ts +43 -0
- package/src/lib/app-utils/media-embedded-metadata/jpeg.js +456 -0
- package/src/lib/app-utils/media-embedded-metadata/jpeg.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/mp4.d.ts +37 -0
- package/src/lib/app-utils/media-embedded-metadata/mp4.js +621 -0
- package/src/lib/app-utils/media-embedded-metadata/mp4.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/ooxml.d.ts +131 -0
- package/src/lib/app-utils/media-embedded-metadata/ooxml.js +1058 -0
- package/src/lib/app-utils/media-embedded-metadata/ooxml.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/pdf-objects.d.ts +223 -0
- package/src/lib/app-utils/media-embedded-metadata/pdf-objects.js +936 -0
- package/src/lib/app-utils/media-embedded-metadata/pdf-objects.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/pdf.d.ts +82 -0
- package/src/lib/app-utils/media-embedded-metadata/pdf.js +1167 -0
- package/src/lib/app-utils/media-embedded-metadata/pdf.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/png.d.ts +41 -0
- package/src/lib/app-utils/media-embedded-metadata/png.js +512 -0
- package/src/lib/app-utils/media-embedded-metadata/png.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/svg.d.ts +30 -0
- package/src/lib/app-utils/media-embedded-metadata/svg.js +154 -0
- package/src/lib/app-utils/media-embedded-metadata/svg.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/tiff.d.ts +40 -0
- package/src/lib/app-utils/media-embedded-metadata/tiff.js +158 -0
- package/src/lib/app-utils/media-embedded-metadata/tiff.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/types.d.ts +60 -0
- package/src/lib/app-utils/media-embedded-metadata/types.js +32 -0
- package/src/lib/app-utils/media-embedded-metadata/types.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/webm.d.ts +26 -0
- package/src/lib/app-utils/media-embedded-metadata/webm.js +417 -0
- package/src/lib/app-utils/media-embedded-metadata/webm.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/webp.d.ts +37 -0
- package/src/lib/app-utils/media-embedded-metadata/webp.js +272 -0
- package/src/lib/app-utils/media-embedded-metadata/webp.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/xml.d.ts +231 -0
- package/src/lib/app-utils/media-embedded-metadata/xml.js +598 -0
- package/src/lib/app-utils/media-embedded-metadata/xml.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/xmp.d.ts +100 -0
- package/src/lib/app-utils/media-embedded-metadata/xmp.js +1475 -0
- package/src/lib/app-utils/media-embedded-metadata/xmp.js.map +1 -0
- package/src/lib/app-utils/media-embedded-metadata/zip.d.ts +93 -0
- package/src/lib/app-utils/media-embedded-metadata/zip.js +531 -0
- package/src/lib/app-utils/media-embedded-metadata/zip.js.map +1 -0
- package/src/lib/app-utils/name-search.d.ts +12 -0
- package/src/lib/app-utils/name-search.js +11 -0
- package/src/lib/app-utils/name-search.js.map +1 -1
- package/src/lib/foundation/definitions/platform.types.d.ts +11 -0
- package/src/lib/foundation/definitions/platform.types.js.map +1 -1
- package/src/server.d.ts +1 -0
- package/src/server.js +7 -0
- 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
|