@openpresentation/opf-pptx 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,998 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
+ // Namespace import: cores before FF-34 do not export resolveSocialProfile.
4
+ import * as opfCore from '@openpresentation/opf';
5
+
6
+ // FF-32: document and slide references survive a PPTX round trip.
7
+ //
8
+ // PPTX has no native field for an OPF catalog reference (design.colorScheme,
9
+ // fontScheme, theme, dimensions and background, a slide layout id) or for
10
+ // authoring metadata (narrative, tone, audience, purpose, language,
11
+ // organization, speaker ...). They are stored as standard PresentationML
12
+ // customer-data tags, the mechanism the other OPF provenance records already
13
+ // use: OPF_DOCUMENT_V1 on p:presentation/p:custDataLst and OPF_SLIDE_V1 on each
14
+ // slide's p:cSld/p:custDataLst. PowerPoint keeps tags through edits and saves,
15
+ // copies slide tags with a slide, and never shows them. Values are UTF-8 JSON as
16
+ // uppercase hex, because PowerPoint's Tags API is case-insensitive.
17
+ //
18
+ // Each design reference records the native evidence it produced (theme
19
+ // colors, theme fonts, slide size, slide background, slide object geometry).
20
+ // Import restores a reference only while that evidence is unchanged. After an
21
+ // edit the observed native values stay and a specific diagnostic names the
22
+ // reference that was not restored. Tags are untrusted input: they are parsed,
23
+ // size-limited, validated with the whole imported document, and never executed.
24
+ //
25
+ // Layout intent (FF-29) is part of each OPF_SLIDE_V1 record: the slide's
26
+ // layout id, type, composition and composition hints, plus `layoutRecord`,
27
+ // the document's inline catalogs.layouts record for that id. The record is
28
+ // kept on the slide as well as in the document's catalogs, so a slide keeps
29
+ // its layout when OPF_DOCUMENT_V1 is missing or unreadable (for example a
30
+ // slide pasted into another deck), and a pasted slide brings its own record.
31
+
32
+ export const DOCUMENT_TAG = 'OPF_DOCUMENT_V1';
33
+ export const SLIDE_TAG = 'OPF_SLIDE_V1';
34
+ const REL_TAGS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
35
+ const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
36
+ const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
37
+ const MAX_OMITTED = 256;
38
+ const MAX_FIELD_BYTES = 256 * 1024, MAX_CATALOG_BYTES = 1024 * 1024, MAX_TAG_CHARS = 16 * 1024 * 1024;
39
+
40
+ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
41
+ const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', textNodeName: '#text', parseAttributeValue: false, parseTagValue: false, trimValues: false});
42
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
43
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ const canonical = value => JSON.stringify(value, (_key, item) => object(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
45
+ const same = (a, b) => canonical(a) === canonical(b);
46
+ const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
47
+ const own = (value, key) => object(value) && Object.hasOwn(value, key) && value[key] !== undefined;
48
+ // Furniture socials re-import as the displayed profile URL (FF-34). While a line
49
+ // still shows exactly what the stored authored value formats to, keep the
50
+ // authored form (a handle stays a handle); an edited line keeps its new URL.
51
+ function authoredSocials(stored, observed, records) {
52
+ if (!object(stored) || !object(observed) || typeof opfCore.resolveSocialProfile !== 'function') return observed;
53
+ return Object.fromEntries(Object.entries(observed).map(([platform, value]) => {
54
+ const authored = stored[platform];
55
+ if (typeof authored !== 'string') return [platform, value];
56
+ const profile = opfCore.resolveSocialProfile(platform, authored, records, 'organization');
57
+ const shown = profile.href && !/^[a-z][a-z0-9+.-]*:/i.test(profile.text) ? `https://${profile.text}` : profile.text;
58
+ return [platform, shown === value ? authored : value];
59
+ }));
60
+ }
61
+
62
+ // Deck-level fields. References are gated by native evidence; metadata has no
63
+ // native PowerPoint counterpart and round-trips from the stored value.
64
+ export const DESIGN_REFERENCES = Object.freeze(['theme', 'colorScheme', 'fontScheme', 'dimensions', 'background']);
65
+ export const COMPOSITION_HINTS = Object.freeze(['titleAlignment', 'contentAlignment', 'contentBox', 'contentDirection', 'chartPrimary', 'imageFill', 'listBullet']);
66
+ export const METADATA = Object.freeze(['narrative', 'tone', 'audience', 'purpose', 'language', 'organization', 'speaker', 'takeaway', 'duration', 'tags', 'variables']);
67
+ const STYLE_REFERENCES = ['theme', 'colorScheme', 'fontScheme'];
68
+ const SLIDE_STRUCTURE = ['layout', 'type', 'composition'];
69
+
70
+ // 53-bit non-cryptographic hash (cyrb53). Change detection only; tags are
71
+ // writable by anyone who can edit the file, so no authenticity is claimed.
72
+ const hash = text => hashUnits(text.length, index => text.charCodeAt(index));
73
+ const hashBytes = bytes => hashUnits(bytes.byteLength, index => bytes[index]);
74
+ function hashUnits(length, unit) {
75
+ let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
76
+ for (let index = 0; index < length; index += 1) {
77
+ const code = unit(index);
78
+ h1 = Math.imul(h1 ^ code, 2654435761);
79
+ h2 = Math.imul(h2 ^ code, 1597334677);
80
+ }
81
+ h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
82
+ h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
83
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16).padStart(14, '0');
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Package helpers shared by export (final normalized parts) and import.
88
+
89
+ function relsPath(part) {
90
+ const slash = part.lastIndexOf('/');
91
+ return `${part.slice(0, slash + 1)}_rels/${part.slice(slash + 1)}.rels`;
92
+ }
93
+
94
+ function resolveTarget(source, target) {
95
+ if (!target || /^[a-z][a-z0-9+.-]*:/i.test(target)) return null;
96
+ const raw = target.startsWith('/') ? target.slice(1) : source.slice(0, source.lastIndexOf('/') + 1) + target;
97
+ const parts = [];
98
+ for (const part of raw.split('/')) {
99
+ if (!part || part === '.') continue;
100
+ if (part === '..') parts.pop(); else parts.push(part);
101
+ }
102
+ return parts.join('/');
103
+ }
104
+
105
+ function relationships(entries, part) {
106
+ const map = new Map();
107
+ const bytes = entries[relsPath(part)];
108
+ if (!bytes) return map;
109
+ for (const rel of array(parser.parse(dec.decode(bytes))?.Relationships?.Relationship)) {
110
+ if (!rel?.Id) continue;
111
+ map.set(rel.Id, {type: rel.Type ?? '', external: rel.TargetMode === 'External', path: rel.TargetMode === 'External' ? null : resolveTarget(part, rel.Target ?? '')});
112
+ }
113
+ return map;
114
+ }
115
+
116
+ function slidePaths(entries, presentationRoot, rels) {
117
+ return array(presentationRoot?.['p:sldIdLst']?.['p:sldId']).map(node => rels.get(node?.['r:id']))
118
+ .filter(rel => rel?.type.endsWith('/slide') && rel.path && entries[rel.path]).map(rel => rel.path);
119
+ }
120
+
121
+ function themePath(entries, presentationRoot, rels) {
122
+ const masters = array(presentationRoot?.['p:sldMasterIdLst']?.['p:sldMasterId']).map(node => rels.get(node?.['r:id']))
123
+ .filter(rel => rel?.type.endsWith('/slideMaster') && rel.path && entries[rel.path]);
124
+ for (const master of masters) {
125
+ const theme = [...relationships(entries, master.path).values()].find(rel => rel.type.endsWith('/theme') && rel.path && entries[rel.path]);
126
+ if (theme) return theme.path;
127
+ }
128
+ return [...rels.values()].find(rel => rel.type.endsWith('/theme') && rel.path && entries[rel.path])?.path ?? null;
129
+ }
130
+
131
+ const THEME_SLOTS = ['dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6', 'hlink', 'folHlink'];
132
+
133
+ function colorValue(node) {
134
+ if (!object(node)) return null;
135
+ if (object(node['a:srgbClr']) || typeof node['a:srgbClr'] === 'string') {
136
+ const color = node['a:srgbClr'], {val, ...rest} = object(color) ? color : {};
137
+ return `srgb:${String(val ?? '').toUpperCase()}${Object.keys(rest).length ? canonical(rest) : ''}`;
138
+ }
139
+ // PowerPoint rewrites lastClr with the current system color; the reference is what persists.
140
+ if (object(node['a:sysClr'])) return `sys:${node['a:sysClr'].val ?? ''}`;
141
+ return canonical(withoutExtensions(node));
142
+ }
143
+
144
+ // Theme colors, theme fonts and slide size are the native counterparts of
145
+ // design.colorScheme, design.fontScheme (both via design.theme) and design.dimensions.
146
+ function nativeDocument(entries, presentationRoot, rels) {
147
+ const path = themePath(entries, presentationRoot, rels);
148
+ let elements;
149
+ try { elements = path ? parser.parse(dec.decode(entries[path]))?.['a:theme']?.['a:themeElements'] : undefined; } catch { elements = undefined; }
150
+ const scheme = elements?.['a:clrScheme'], fontScheme = elements?.['a:fontScheme'];
151
+ const colors = object(scheme) ? Object.fromEntries(THEME_SLOTS.map(slot => [slot, colorValue(scheme[`a:${slot}`])])) : null;
152
+ const face = (font, script) => { const node = fontScheme?.[font]?.[`a:${script}`]; return object(node) ? String(node.typeface ?? '') : null; };
153
+ const fonts = object(fontScheme) ? Object.fromEntries(['major', 'minor'].map(kind => [kind, Object.fromEntries(['latin', 'ea', 'cs'].map(script => [script, face(`a:${kind}Font`, script)]))])) : null;
154
+ const size = presentationRoot?.['p:sldSz'];
155
+ return {colors, fonts, size: object(size) ? {cx: String(size.cx ?? ''), cy: String(size.cy ?? '')} : null};
156
+ }
157
+
158
+ function withoutExtensions(node) {
159
+ if (Array.isArray(node)) return node.map(withoutExtensions);
160
+ if (!object(node)) return node;
161
+ return Object.fromEntries(Object.entries(node).filter(([key]) => key !== 'a:extLst' && key !== 'p:extLst').map(([key, value]) => [key, withoutExtensions(value)]));
162
+ }
163
+
164
+ // Relationship ids are renumbered by editors; the target bytes identify an image.
165
+ function withResolvedRelationships(node, rels, entries) {
166
+ if (Array.isArray(node)) return node.map(item => withResolvedRelationships(item, rels, entries));
167
+ if (!object(node)) return node;
168
+ return Object.fromEntries(Object.entries(node).map(([key, value]) => {
169
+ if (/^r:(embed|link|id)$/.test(key) && typeof value === 'string') {
170
+ const rel = rels.get(value);
171
+ const bytes = rel?.path ? entries[rel.path] : null;
172
+ return [key, bytes ? `bytes:${bytes.byteLength}:${hashBytes(bytes)}` : `rel:${rel?.type ?? ''}`];
173
+ }
174
+ return [key, withResolvedRelationships(value, rels, entries)];
175
+ }));
176
+ }
177
+
178
+ function box(xfrm, frame) {
179
+ if (!object(xfrm)) return 'inherit';
180
+ const off = xfrm['a:off'], ext = xfrm['a:ext'];
181
+ // Table rows grow when a viewer reflows cell text, so a table frame's height
182
+ // is not layout structure. Position and width are.
183
+ return [off?.x, off?.y, ext?.cx, frame ? '' : ext?.cy, xfrm.rot ?? '0', xfrm.flipH ?? '0', xfrm.flipV ?? '0'].map(value => String(value ?? '')).join(',');
184
+ }
185
+
186
+ function structure(tree) {
187
+ const kinds = {};
188
+ const add = (kind, value) => (kinds[kind] ??= []).push(value);
189
+ for (const node of array(tree?.['p:sp'])) add('sp', box(node?.['p:spPr']?.['a:xfrm']));
190
+ for (const node of array(tree?.['p:pic'])) add('pic', box(node?.['p:spPr']?.['a:xfrm']));
191
+ for (const node of array(tree?.['p:cxnSp'])) add('cxnSp', box(node?.['p:spPr']?.['a:xfrm']));
192
+ for (const node of array(tree?.['p:graphicFrame'])) add('graphicFrame', box(node?.['p:xfrm'], true));
193
+ for (const node of array(tree?.['p:grpSp'])) add('grpSp', `${box(node?.['p:grpSpPr']?.['a:xfrm'])}[${structure(node)}]`);
194
+ for (const kind of ['p:contentPart', 'mc:AlternateContent']) if (tree?.[kind] !== undefined) add(kind, String(array(tree[kind]).length));
195
+ // Stacking order is not structure; object kinds, positions and sizes are.
196
+ return canonical(Object.fromEntries(Object.entries(kinds).map(([kind, values]) => [kind, values.sort()])));
197
+ }
198
+
199
+ function styleSignature(tree) {
200
+ const faces = new Set(), colors = new Set();
201
+ const visit = node => {
202
+ if (Array.isArray(node)) { node.forEach(visit); return; }
203
+ if (!object(node)) return;
204
+ for (const [key, value] of Object.entries(node)) {
205
+ if (/^a:(latin|ea|cs|sym)$/.test(key)) for (const font of array(value)) if (object(font) && font.typeface !== undefined) faces.add(String(font.typeface));
206
+ if (/^a:(srgbClr|schemeClr|sysClr|prstClr)$/.test(key)) for (const color of array(value)) colors.add(`${key}:${object(color) ? String(color.val ?? '').toUpperCase() : ''}`);
207
+ visit(value);
208
+ }
209
+ };
210
+ visit(tree);
211
+ return canonical({faces: [...faces].sort(), colors: [...colors].sort()});
212
+ }
213
+
214
+ function nativeSlide(entries, path) {
215
+ const root = parser.parse(dec.decode(entries[path]))?.['p:sld'];
216
+ const cSld = root?.['p:cSld'], tree = cSld?.['p:spTree'];
217
+ const rels = relationships(entries, path);
218
+ return {
219
+ structure: hash(structure(tree)),
220
+ background: hash(canonical(withResolvedRelationships(withoutExtensions(cSld?.['p:bg'] ?? null), rels, entries))),
221
+ style: hash(styleSignature(withoutExtensions(tree ?? null)))
222
+ };
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Export
227
+
228
+ export const PROVENANCE_MODES = Object.freeze(['full', 'references-only']);
229
+ const sizeOf = value => enc.encode(JSON.stringify(value)).byteLength;
230
+ // The tag value is the UTF-8 JSON as uppercase hex: two characters per byte.
231
+ const tagChars = value => sizeOf(value) * 2;
232
+ const SOURCE_KEYS = new Set(['src', 'image', 'logo', 'photo']);
233
+ const SOURCE_PREFIX = /^(asset:|data:|https?:|file:)/i;
234
+ const METADATA_CATALOGS = Object.freeze({narrative: 'narratives', tone: 'tones', purpose: 'purposes', language: 'languages', audience: 'audiences'});
235
+
236
+ function collectStrings(value, into = new Set()) {
237
+ if (typeof value === 'string') into.add(value);
238
+ else if (Array.isArray(value)) value.forEach(item => collectStrings(item, into));
239
+ else if (object(value)) for (const [key, item] of Object.entries(value)) {
240
+ // Socials keys are socialPlatforms catalog references (FF-34).
241
+ if (key === 'socials' && object(item)) Object.keys(item).forEach(id => into.add(id));
242
+ collectStrings(item, into);
243
+ }
244
+ return into;
245
+ }
246
+
247
+ // True when a value names an image, logo, file or URL rather than only catalog ids and settings.
248
+ function carriesSource(value) {
249
+ if (typeof value === 'string') return SOURCE_PREFIX.test(value);
250
+ if (Array.isArray(value)) return value.some(carriesSource);
251
+ if (object(value)) return Object.entries(value).some(([key, item]) => SOURCE_KEYS.has(key) || carriesSource(item));
252
+ return false;
253
+ }
254
+
255
+ // Inline catalog records are kept only when referenced, so restored ids
256
+ // resolve without copying unrelated catalog content.
257
+ function pruneCatalogs(catalogs, referenced) {
258
+ if (!object(catalogs)) return undefined;
259
+ const result = {};
260
+ for (const [kind, entry] of Object.entries(catalogs)) {
261
+ if (Array.isArray(entry)) {
262
+ const records = entry.filter(record => typeof record?.id === 'string' && referenced.has(record.id));
263
+ if (records.length) result[kind] = records;
264
+ } else if (object(entry)) {
265
+ const records = array(entry.records).filter(record => typeof record?.id === 'string' && referenced.has(record.id));
266
+ const kept = {...(entry.source !== undefined ? {source: entry.source} : {}), ...(records.length ? {records} : {})};
267
+ if (Object.keys(kept).length) result[kind] = kept;
268
+ }
269
+ }
270
+ return Object.keys(result).length ? result : undefined;
271
+ }
272
+
273
+ // A referenced record can itself refer to another inline record (a theme to its color scheme).
274
+ function referencedCatalogs(catalogs, referenced) {
275
+ let pruned = pruneCatalogs(catalogs, referenced);
276
+ for (let pass = 0; pass < 3 && pruned; pass += 1) {
277
+ const ids = new Set(referenced);
278
+ for (const entry of Object.values(pruned)) for (const record of array(Array.isArray(entry) ? entry : entry?.records)) collectStrings(record, ids);
279
+ const next = pruneCatalogs(catalogs, ids);
280
+ if (same(next, pruned)) break;
281
+ pruned = next;
282
+ }
283
+ return pruned;
284
+ }
285
+
286
+ const layoutRecords = catalogs => array(Array.isArray(catalogs?.layouts) ? catalogs.layouts : catalogs?.layouts?.records);
287
+
288
+ // Presentation validation deliberately accepts arbitrary inline catalog objects.
289
+ // Recovered layouts must also pass their companion schema before composition
290
+ // can use them (for example, a null placeholder otherwise crashes rendering).
291
+ function validLayoutRecord(value) {
292
+ // Inline catalogs already identify the kind. Supply only an omitted
293
+ // standalone identifier for validation; never change the authored record.
294
+ try { return object(value) && opfCore.validateCatalogRecord('layouts', {$schema: 'https://openpresentation.org/schema/opf-layout/v1', ...value}).valid; }
295
+ catch { return false; }
296
+ }
297
+
298
+ function validatedLayoutCatalog(catalogs, entries, report, rejectedIds) {
299
+ if (!object(catalogs) || catalogs.layouts === undefined) return catalogs;
300
+ const current = catalogs.layouts;
301
+ const records = layoutRecords(catalogs).flatMap((record, index) => {
302
+ const {value, unresolved} = resolveMedia(record, entries);
303
+ if (!unresolved && validLayoutRecord(value)) return [value];
304
+ if (typeof record?.id === 'string') rejectedIds.add(record.id);
305
+ const path = `catalogs.layouts.${Array.isArray(current) ? '' : 'records.'}${index}`;
306
+ report({code: unresolved ? 'unresolved-asset-reference' : 'invalid-document-provenance', path,
307
+ message: `The stored layout record at ${path} ${unresolved ? 'refers to unavailable media' : 'does not validate against the layouts catalog schema'}, so it was not restored.`});
308
+ return [];
309
+ });
310
+ const result = {...catalogs};
311
+ if (records.length) result.layouts = Array.isArray(current) ? records : {...current, records};
312
+ else if (object(current) && current.source !== undefined) { result.layouts = {...current}; delete result.layouts.records; }
313
+ else delete result.layouts;
314
+ return Object.keys(result).length ? result : undefined;
315
+ }
316
+
317
+ const assetReferences = value => [...collectStrings(value)].filter(item => item.startsWith('asset:')).map(item => item.slice(6));
318
+
319
+ /**
320
+ * The references and metadata to persist, before package-dependent checks.
321
+ * Only values the document states are stored; engine defaults are not.
322
+ *
323
+ * mode 'full' stores deck design references and composition defaults, the
324
+ * METADATA fields, slide id/beat/layout/type/composition and slide design
325
+ * references, the assets those values reference, and referenced inline
326
+ * catalog records. mode 'references-only' stores catalog references only:
327
+ * design references and hints that name no image, file or URL, slide
328
+ * layout/type/composition/beat and slide design references of that kind,
329
+ * narrative/tone/purpose/language/audience only as catalog ids, and the inline
330
+ * catalog records those ids need. It stores no organization, speaker,
331
+ * free-text metadata, slide ids or assets.
332
+ */
333
+ export function documentProvenance(presentation, {mode = 'full', isCatalogId = () => false, report = () => {}} = {}) {
334
+ const referencesOnly = mode === 'references-only';
335
+ const design = {}, metadata = {};
336
+ for (const key of [...DESIGN_REFERENCES, ...COMPOSITION_HINTS]) {
337
+ if (!own(presentation.design, key)) continue;
338
+ if (referencesOnly && carriesSource(presentation.design[key])) continue;
339
+ design[key] = clone(presentation.design[key]);
340
+ }
341
+ for (const key of METADATA) {
342
+ if (!own(presentation, key)) continue;
343
+ const value = presentation[key];
344
+ if (referencesOnly) {
345
+ const kind = METADATA_CATALOGS[key];
346
+ const ids = typeof value === 'string' ? [value] : key === 'audience' && Array.isArray(value) && value.every(item => typeof item === 'string') ? value : null;
347
+ if (!kind || !ids || !ids.every(id => isCatalogId(kind, id))) continue;
348
+ }
349
+ metadata[key] = clone(value);
350
+ }
351
+ const slides = presentation.slides.map((slide, index) => {
352
+ const record = {v: 1, slide: index};
353
+ for (const key of [...(referencesOnly ? [] : ['id']), 'beat', ...SLIDE_STRUCTURE]) if (own(slide, key)) record[key] = clone(slide[key]);
354
+ const layoutRecord = typeof slide.layout === 'string' ? layoutRecords(presentation.catalogs).find(item => item?.id === slide.layout) : undefined;
355
+ // The layout record is a catalog record. 'references-only' stores it only
356
+ // when it names no image, file or URL; neither mode stores the assets it
357
+ // references (as before FF-29, catalog records never pulled in assets).
358
+ // A record's own $schema URL identifies its format and is not a source.
359
+ if (layoutRecord && !(referencesOnly && carriesSource({...layoutRecord, $schema: undefined}))) record.layoutRecord = clone(layoutRecord);
360
+ const slideDesign = {};
361
+ for (const key of [...STYLE_REFERENCES, 'background', ...COMPOSITION_HINTS]) {
362
+ if (!own(slide.design, key) || (referencesOnly && carriesSource(slide.design[key]))) continue;
363
+ slideDesign[key] = clone(slide.design[key]);
364
+ }
365
+ if (Object.keys(slideDesign).length) record.design = slideDesign;
366
+ return record;
367
+ });
368
+ const stated = Object.keys(design).length || Object.keys(metadata).length || slides.some(record => Object.keys(record).length > 2);
369
+ if (!stated) return null;
370
+ const document = {v: 1, slides: slides.length};
371
+ if (Object.keys(design).length) document.design = design;
372
+ if (Object.keys(metadata).length) document.metadata = metadata;
373
+ const stored = [design, metadata, slides];
374
+ const assetIds = [...new Set(assetReferences([design, metadata, slides.map(({layoutRecord: _record, ...rest}) => rest)]))].filter(id => own(presentation.assets, id));
375
+ if (assetIds.length) document.assets = Object.fromEntries(assetIds.map(id => [id, clone(presentation.assets[id])]));
376
+ const {catalogs, ...rest} = presentation;
377
+ const catalogRecords = referencedCatalogs(catalogs, collectStrings(referencesOnly ? stored : rest));
378
+ if (catalogRecords) document.catalogs = catalogRecords;
379
+ return {mode, document, slides, report};
380
+ }
381
+
382
+ function base64ToBytes(value) {
383
+ const binary = atob(value.replace(/\s+/g, ''));
384
+ const bytes = new Uint8Array(binary.length);
385
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
386
+ return bytes;
387
+ }
388
+
389
+ function bytesToBase64(bytes) {
390
+ let binary = '';
391
+ for (let index = 0; index < bytes.length; index += 0x8000) binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
392
+ return btoa(binary);
393
+ }
394
+
395
+ const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((value, index) => value === b[index]);
396
+
397
+ // Embedded data: sources are never copied into a tag. A data URI whose bytes
398
+ // are an exported media part becomes a reference to that part; any other data
399
+ // URI makes the value unstorable.
400
+ function mediaExternalizer(entries) {
401
+ const media = new Map();
402
+ for (const [path, bytes] of Object.entries(entries)) {
403
+ if (!/^ppt\/media\/[^/]+$/.test(path)) continue;
404
+ const key = `${bytes.byteLength}:${hashBytes(bytes)}`;
405
+ media.set(key, [...(media.get(key) ?? []), path]);
406
+ }
407
+ const find = bytes => (media.get(`${bytes.byteLength}:${hashBytes(bytes)}`) ?? []).find(path => sameBytes(entries[path], bytes));
408
+ return function externalize(value) {
409
+ let missing = false;
410
+ const visit = item => {
411
+ if (typeof item === 'string' && /^data:/i.test(item)) {
412
+ const match = item.match(/^data:([\w.+-]+\/[\w.+-]+)?((?:;[^;,]*)*?)(;base64)?,([\s\S]*)$/i);
413
+ let bytes = null;
414
+ try { if (match) bytes = match[3] ? base64ToBytes(match[4]) : enc.encode(decodeURIComponent(match[4])); } catch { bytes = null; }
415
+ const path = bytes && find(bytes);
416
+ if (!path) { missing = true; return item; }
417
+ return {$opfMedia: path, prefix: `data:${match[1] || 'application/octet-stream'};base64,`};
418
+ }
419
+ if (Array.isArray(item)) return item.map(visit);
420
+ if (object(item)) return Object.fromEntries(Object.entries(item).map(([key, value]) => [key, visit(value)]));
421
+ return item;
422
+ };
423
+ const result = visit(value);
424
+ return {value: result, missing};
425
+ };
426
+ }
427
+
428
+ function tagPart(name, value) {
429
+ return enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${name}" val="${encodeTextTag(value)}"/></p:tagLst>`);
430
+ }
431
+
432
+ function freeId(relsXml, base) {
433
+ const ids = new Set([...relsXml.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
434
+ let id = base;
435
+ while (ids.has(id)) id += '_';
436
+ return id;
437
+ }
438
+
439
+ /**
440
+ * Make the collected provenance storable against the final package: data:
441
+ * sources become media-part references, per-field size limits apply, fields
442
+ * that name an unstorable asset are dropped, and the whole tag must stay below
443
+ * the import limit. Every omission is reported with its OPF path.
444
+ */
445
+ function storable(entries, provenance) {
446
+ const report = provenance.report ?? (() => {});
447
+ // Omitted paths are recorded so import knows the source stated them and keeps observed values.
448
+ const omitted = [];
449
+ const omit = (path, reason) => { omitted.push(path); report({code: 'document-provenance-omitted', path, message: `${path} is not stored in the PPTX because ${reason}; reimport keeps the values observed in the PPTX instead.`}); };
450
+ const externalize = mediaExternalizer(entries);
451
+ const prepare = (path, value, limit = MAX_FIELD_BYTES) => {
452
+ const {value: result, missing} = externalize(value);
453
+ if (missing) { omit(path, 'it embeds a data: source that is not one of the exported media parts'); return undefined; }
454
+ if (sizeOf(result) > limit) { omit(path, `it is larger than ${limit / 1024} KiB`); return undefined; }
455
+ return result;
456
+ };
457
+ const source = provenance.document;
458
+ const document = {v: 1, slides: source.slides};
459
+ const assets = {};
460
+ for (const [id, asset] of Object.entries(source.assets ?? {})) {
461
+ const value = prepare(`assets.${id}`, asset);
462
+ if (value !== undefined) assets[id] = value;
463
+ }
464
+ const unstoredAsset = value => assetReferences(value).some(id => !Object.hasOwn(assets, id) && own(source.assets, id));
465
+ for (const section of ['design', 'metadata']) {
466
+ const fields = {};
467
+ for (const [key, value] of Object.entries(source[section] ?? {})) {
468
+ const path = section === 'design' ? `design.${key}` : key;
469
+ // A design reference is only restorable together with its asset.
470
+ if (section === 'design' && unstoredAsset(value)) { omit(path, 'the asset it references could not be stored'); continue; }
471
+ const prepared = prepare(path, value);
472
+ if (prepared !== undefined) fields[key] = prepared;
473
+ }
474
+ if (Object.keys(fields).length) document[section] = fields;
475
+ }
476
+ if (Object.keys(assets).length) document.assets = assets;
477
+ if (source.catalogs !== undefined) {
478
+ const catalogs = prepare('catalogs', source.catalogs, MAX_CATALOG_BYTES);
479
+ if (catalogs !== undefined) document.catalogs = catalogs;
480
+ }
481
+ const slides = provenance.slides.map((record, index) => {
482
+ const result = {v: 1, slide: index};
483
+ for (const key of ['id', 'beat', ...SLIDE_STRUCTURE]) {
484
+ if (record[key] === undefined) continue;
485
+ const value = prepare(`slides.${index}.${key}`, record[key]);
486
+ if (value !== undefined) result[key] = value;
487
+ }
488
+ // The layout record is only meaningful with its layout id and assets.
489
+ if (record.layoutRecord !== undefined && result.layout !== undefined) {
490
+ if (unstoredAsset(record.layoutRecord)) omit(`slides.${index}.layoutRecord`, 'the asset it references could not be stored');
491
+ else {
492
+ const value = prepare(`slides.${index}.layoutRecord`, record.layoutRecord);
493
+ if (value !== undefined) result.layoutRecord = value;
494
+ }
495
+ }
496
+ const slideDesign = {};
497
+ for (const [key, value] of Object.entries(record.design ?? {})) {
498
+ const path = `slides.${index}.design.${key}`;
499
+ if (unstoredAsset(value)) { omit(path, 'the asset it references could not be stored'); continue; }
500
+ const prepared = prepare(path, value);
501
+ if (prepared !== undefined) slideDesign[key] = prepared;
502
+ }
503
+ if (Object.keys(slideDesign).length) result.design = slideDesign;
504
+ return result;
505
+ });
506
+ // Keep the whole tag importable: shed the largest optional content first.
507
+ const budget = MAX_TAG_CHARS - 64 * 1024;
508
+ const shed = (holder, path) => { omit(path, `the tag would exceed the ${MAX_TAG_CHARS / (1024 * 1024)} MiB import limit`); };
509
+ while (tagChars(document) > budget) {
510
+ if (document.catalogs) { delete document.catalogs; shed(document, 'catalogs'); continue; }
511
+ if (document.assets) { delete document.assets; shed(document, 'assets'); continue; }
512
+ const candidates = ['metadata', 'design'].flatMap(section => Object.entries(document[section] ?? {}).map(([key, value]) => ({section, key, size: sizeOf(value)})));
513
+ if (!candidates.length) break;
514
+ const largest = candidates.sort((a, b) => b.size - a.size)[0];
515
+ delete document[largest.section][largest.key];
516
+ shed(document, largest.section === 'design' ? `design.${largest.key}` : largest.key);
517
+ }
518
+ for (const [index, record] of slides.entries()) {
519
+ while (tagChars(record) > budget) {
520
+ const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
521
+ ...Object.keys(record.design ?? {}).map(key => ({key, design: true, size: sizeOf(record.design[key])}))];
522
+ if (!candidates.length) break;
523
+ const largest = candidates.sort((a, b) => b.size - a.size)[0];
524
+ if (largest.design) delete record.design[largest.key]; else delete record[largest.key];
525
+ if (largest.key === 'layout' && record.layoutRecord !== undefined) { delete record.layoutRecord; shed(record, `slides.${index}.layoutRecord`); }
526
+ shed(record, `slides.${index}.${largest.design ? 'design.' : ''}${largest.key}`);
527
+ }
528
+ }
529
+ for (const path of omitted.slice(0, MAX_OMITTED)) {
530
+ const slide = path.match(/^slides\.(\d+)\.(.+)$/);
531
+ const holder = slide ? slides[Number(slide[1])] : document;
532
+ (holder.omitted ??= []).push(slide ? slide[2] : path);
533
+ }
534
+ return {document, slides};
535
+ }
536
+
537
+ /**
538
+ * Record native evidence from the final normalized parts and write the tags.
539
+ * `entries` maps part paths to bytes and is updated in place.
540
+ */
541
+ export function attachDocumentProvenance(entries, provenance) {
542
+ if (!provenance) return;
543
+ const presentationXml = dec.decode(entries['ppt/presentation.xml']);
544
+ const presentationRoot = parser.parse(presentationXml)['p:presentation'];
545
+ const rels = relationships(entries, 'ppt/presentation.xml');
546
+ const paths = slidePaths(entries, presentationRoot, rels);
547
+ if (paths.length !== provenance.slides.length) throw Error('Generated slide count differs from the document.');
548
+ const prepared = storable(entries, provenance);
549
+ const types = [];
550
+ const document = {...prepared.document, native: nativeDocument(entries, presentationRoot, rels)};
551
+
552
+ // CT_Presentation: custDataLst follows photoAlbum and precedes kinsoku/defaultTextStyle/modifyVerifier/extLst.
553
+ const documentPart = 'ppt/tags/opfDocument.xml';
554
+ if (entries[documentPart] || /<p:custDataLst\b/.test(presentationXml)) throw Error('Colliding presentation customer data.');
555
+ let presentationRels = dec.decode(entries['ppt/_rels/presentation.xml.rels']);
556
+ const documentId = freeId(presentationRels, 'rIdOpfDocument');
557
+ const anchor = presentationXml.search(/<p:(kinsoku|defaultTextStyle|modifyVerifier|extLst)\b|<\/p:presentation>/);
558
+ if (anchor < 0) throw Error('Generated presentation part has no closing element.');
559
+ entries['ppt/presentation.xml'] = enc.encode(`${presentationXml.slice(0, anchor)}<p:custDataLst><p:tags r:id="${documentId}"/></p:custDataLst>${presentationXml.slice(anchor)}`);
560
+ entries['ppt/_rels/presentation.xml.rels'] = enc.encode(presentationRels.replace('</Relationships>', `<Relationship Id="${documentId}" Type="${REL_TAGS}" Target="tags/opfDocument.xml"/></Relationships>`));
561
+ entries[documentPart] = tagPart(DOCUMENT_TAG, document);
562
+ types.push(documentPart);
563
+
564
+ for (const [index, path] of paths.entries()) {
565
+ const record = {...prepared.slides[index], native: nativeSlide(entries, path)};
566
+ const tag = `<p:tag name="${SLIDE_TAG}" val="${encodeTextTag(record)}"/>`;
567
+ let xml = dec.decode(entries[path]);
568
+ const slideRels = relsPath(path);
569
+ let relsXml = dec.decode(entries[slideRels]);
570
+ // CT_CustomerDataList allows one p:tags: join the slide's existing tag list (furniture) when present.
571
+ const existing = xml.match(/<\/p:spTree>\s*<p:custDataLst>\s*<p:tags r:id="([^"]+)"\s*\/>/);
572
+ if (existing) {
573
+ const target = relationships(entries, path).get(existing[1]);
574
+ if (target?.type !== REL_TAGS || !target.path || !entries[target.path]) throw Error('Generated slide tag relationship is missing.');
575
+ entries[target.path] = enc.encode(dec.decode(entries[target.path]).replace('</p:tagLst>', `${tag}</p:tagLst>`));
576
+ continue;
577
+ }
578
+ if (!xml.includes('</p:spTree>') || /<\/p:spTree>\s*<p:custDataLst\b/.test(xml)) throw Error('Generated slide has no shape tree or unexpected customer data.');
579
+ const part = `ppt/tags/opfSlide${index + 1}.xml`;
580
+ if (entries[part]) throw Error('Colliding slide tag part.');
581
+ const id = freeId(relsXml, 'rIdOpfSlide');
582
+ entries[path] = enc.encode(xml.replace('</p:spTree>', `</p:spTree><p:custDataLst><p:tags r:id="${id}"/></p:custDataLst>`));
583
+ entries[slideRels] = enc.encode(relsXml.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL_TAGS}" Target="../tags/opfSlide${index + 1}.xml"/></Relationships>`));
584
+ entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}">${tag}</p:tagLst>`);
585
+ types.push(part);
586
+ }
587
+ const contentTypes = dec.decode(entries['[Content_Types].xml']);
588
+ entries['[Content_Types].xml'] = enc.encode(contentTypes.replace('</Types>', types.map(part => `<Override PartName="/${part}" ContentType="${TAGS_TYPE}"/>`).join('') + '</Types>'));
589
+ }
590
+
591
+ // ---------------------------------------------------------------------------
592
+ // Import
593
+
594
+ function readTag(entries, container, rels, name) {
595
+ const found = [];
596
+ let unreadable = false;
597
+ for (const link of array(container?.['p:tags'])) {
598
+ const rel = rels.get(link?.['r:id']);
599
+ if (rel?.type !== REL_TAGS || rel.external || rel.targetMode === 'External' || !rel.path || !entries[rel.path]) { unreadable = true; continue; }
600
+ try { found.push(...array(parser.parse(dec.decode(entries[rel.path]))?.['p:tagLst']?.['p:tag']).filter(tag => String(tag?.name ?? '').toUpperCase() === name)); }
601
+ catch { unreadable = true; }
602
+ }
603
+ if (!found.length) return {missing: true, unreadable};
604
+ if (found.length > 1) throw Error(`Multiple ${name} tags.`);
605
+ const value = found[0].val;
606
+ if (typeof value !== 'string' || value.length > MAX_TAG_CHARS) throw Error(`Oversized or empty ${name} tag.`);
607
+ return {value: decodeTextTag(value)};
608
+ }
609
+
610
+ function validateOmitted(value) {
611
+ if (value !== undefined && (!Array.isArray(value) || value.length > MAX_OMITTED || !value.every(path => typeof path === 'string' && /^[A-Za-z0-9_.:+-]{1,256}$/.test(path)))) throw Error('Invalid omitted field list.');
612
+ }
613
+
614
+ function validateDocument(value) {
615
+ if (!object(value) || value.v !== 1 || !Number.isSafeInteger(value.slides) || value.slides < 1) throw Error('Unsupported document provenance version.');
616
+ for (const key of ['design', 'metadata', 'catalogs', 'assets']) if (value[key] !== undefined && !object(value[key])) throw Error(`Invalid ${key} record.`);
617
+ if (!object(value.native)) throw Error('Missing native evidence.');
618
+ for (const key of Object.keys(value.design ?? {})) if (![...DESIGN_REFERENCES, ...COMPOSITION_HINTS].includes(key)) throw Error(`Unknown design field ${key}.`);
619
+ for (const key of Object.keys(value.metadata ?? {})) if (!METADATA.includes(key)) throw Error(`Unknown metadata field ${key}.`);
620
+ validateOmitted(value.omitted);
621
+ return value;
622
+ }
623
+
624
+ function validateSlide(value) {
625
+ if (!object(value) || value.v !== 1 || !Number.isSafeInteger(value.slide) || value.slide < 0) throw Error('Unsupported slide provenance version.');
626
+ if (!object(value.native) || typeof value.native.structure !== 'string' || typeof value.native.background !== 'string' || typeof value.native.style !== 'string') throw Error('Missing slide native evidence.');
627
+ if (value.design !== undefined && !object(value.design)) throw Error('Invalid slide design record.');
628
+ for (const key of Object.keys(value.design ?? {})) if (![...STYLE_REFERENCES, 'background', ...COMPOSITION_HINTS].includes(key)) throw Error(`Unknown slide design field ${key}.`);
629
+ if (value.layoutRecord !== undefined && (!object(value.layoutRecord) || typeof value.layoutRecord.id !== 'string')) throw Error('Invalid slide layout record.');
630
+ validateOmitted(value.omitted);
631
+ return value;
632
+ }
633
+
634
+ const label = value => typeof value === 'string' ? `'${value}'` : object(value) && typeof value.id === 'string' ? `'${value.id}' (with inline overrides)` : 'inline value';
635
+
636
+ function changedSlots(stored, observed) {
637
+ if (!object(stored) || !object(observed)) return 'theme missing';
638
+ const keys = [...new Set([...Object.keys(stored), ...Object.keys(observed)])].filter(key => !same(stored[key], observed[key]));
639
+ return keys.join(', ') || 'unreadable';
640
+ }
641
+
642
+ // Media references back to data: URIs from the current package bytes.
643
+ function resolveMedia(value, entries) {
644
+ let unresolved = false;
645
+ const visit = item => {
646
+ if (Array.isArray(item)) return item.map(visit);
647
+ if (!object(item)) return item;
648
+ if (Object.hasOwn(item, '$opfMedia')) {
649
+ const {$opfMedia: path, prefix} = item;
650
+ const bytes = typeof path === 'string' && /^ppt\/media\/[^/]+$/.test(path) ? entries[path] : undefined;
651
+ if (!bytes || Object.keys(item).length !== 2 || typeof prefix !== 'string' || !/^data:[\w.+-]+\/[\w.+-]+;base64,$/.test(prefix)) { unresolved = true; return null; }
652
+ return prefix + bytesToBase64(bytes);
653
+ }
654
+ return Object.fromEntries(Object.entries(item).map(([key, value]) => [key, visit(value)]));
655
+ };
656
+ const result = visit(value);
657
+ return {value: result, unresolved};
658
+ }
659
+
660
+ // Remove properties and list items whose asset: reference does not resolve.
661
+ function pruneDangling(value, dangling, path, removed) {
662
+ const isDangling = item => (typeof item === 'string' && item.startsWith('asset:') && dangling(item.slice(6)))
663
+ || (object(item) && typeof item.src === 'string' && item.src.startsWith('asset:') && dangling(item.src.slice(6)));
664
+ if (Array.isArray(value)) return value.flatMap((item, index) => isDangling(item) ? (removed.push(`${path}.${index}`), []) : [pruneDangling(item, dangling, `${path}.${index}`, removed)]);
665
+ if (!object(value)) return value;
666
+ return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => isDangling(item) ? (removed.push(`${path}.${key}`), []) : [[key, pruneDangling(item, dangling, `${path}.${key}`, removed)]]));
667
+ }
668
+
669
+ /**
670
+ * Read OPF_DOCUMENT_V1 / OPF_SLIDE_V1 and decide what still matches the
671
+ * package. Nothing is modified here: the result lists restore groups (one per
672
+ * OPF field; background deduplication belongs to design.background) for
673
+ * applyDocumentProvenance, and per-slide layout evidence.
674
+ *
675
+ * slides[i] = {layout, structure: 'match' | 'changed' | 'untagged', record, catalogRecord}
676
+ * is the contract for layout-structure recovery (FF-29): `record` is the
677
+ * validated OPF_SLIDE_V1 value (layout, type, composition, design hints, ...),
678
+ * `catalogRecord` the stored inline layouts record for `layout`, if any.
679
+ */
680
+ export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, socialPlatformRecords}, report) {
681
+ const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
682
+ let document;
683
+ try {
684
+ const found = readTag(entries, presentationRoot?.['p:custDataLst'], presentationRels, DOCUMENT_TAG);
685
+ if (found.missing) { if (found.unreadable) invalid('Presentation customer data could not be read.'); }
686
+ else document = validateDocument(found.value);
687
+ } catch (error) { invalid(`${error.message}`); document = undefined; }
688
+
689
+ const rejectedLayoutIds = new Set();
690
+ if (document) document = {...document, catalogs: validatedLayoutCatalog(document.catalogs, entries, report, rejectedLayoutIds)};
691
+
692
+ const slideRecords = slides.map(({root, relationships: rels, path}, index) => {
693
+ try {
694
+ const found = readTag(entries, root?.['p:cSld']?.['p:custDataLst'], rels, SLIDE_TAG);
695
+ if (found.missing) return null;
696
+ return {record: validateSlide(found.value), native: nativeSlide(entries, path)};
697
+ } catch (error) {
698
+ report({code: 'invalid-document-provenance', path: `slides.${index}`, message: `${error.message} This slide keeps the values observed in the PPTX.`});
699
+ return null;
700
+ }
701
+ });
702
+ const groups = [];
703
+ const group = (field, ops) => groups.push({field, ops});
704
+ const intent = layoutIntent(document ? layoutRecords(document.catalogs) : [], Boolean(document), slideRecords, entries, group, report, rejectedLayoutIds);
705
+
706
+ // Without document provenance (a slide pasted into another deck, or a
707
+ // missing or unreadable OPF_DOCUMENT_V1), each OPF_SLIDE_V1 still restores
708
+ // its layout intent; deck references, metadata and slide ids need the
709
+ // document record and are not restored.
710
+ if (!document) {
711
+ const layouts = imported.slides.map((_, index) => slideRecords[index] ? intent.slide(index, slideRecords[index]).entry : {structure: 'untagged'});
712
+ return {groups, slides: layouts, finalize: doc => intent.finalize(doc)};
713
+ }
714
+
715
+ // Stored assets resolve against current media parts; a missing part leaves the asset unavailable.
716
+ const storedAssets = {};
717
+ for (const [id, asset] of Object.entries(object(document.assets) ? document.assets : {})) {
718
+ const {value, unresolved} = resolveMedia(asset, entries);
719
+ if (!unresolved) storedAssets[id] = value;
720
+ }
721
+ const available = id => own(imported.assets, id) || Object.hasOwn(storedAssets, id);
722
+ const changed = (path, message) => report({code: 'design-reference-changed', path, message});
723
+ // A value is restorable only when its media and asset references resolve.
724
+ const restorable = (field, value, {prune = false} = {}) => {
725
+ const {value: resolved, unresolved} = resolveMedia(value, entries);
726
+ if (unresolved) { report({code: 'unresolved-asset-reference', path: field, message: `${field} refers to a picture that is no longer in the PPTX, so it was not restored; the imported document keeps the values observed in the PPTX.`}); return undefined; }
727
+ const missing = [...new Set(assetReferences(resolved).filter(id => !available(id)))];
728
+ if (!missing.length) return resolved;
729
+ if (!prune) { report({code: 'unresolved-asset-reference', path: field, message: `${field} refers to asset '${missing[0]}', which the PPTX no longer provides, so it was not restored; the imported document keeps the values observed in the PPTX.`}); return undefined; }
730
+ const removed = [];
731
+ const pruned = pruneDangling(resolved, id => !available(id), field, removed);
732
+ for (const path of removed) report({code: 'unresolved-asset-reference', path, message: `${path} refers to an asset the PPTX no longer provides and was left out of the restored ${field}.`});
733
+ return pruned;
734
+ };
735
+ const set = (path, value) => ({path, value});
736
+ const remove = path => ({path, remove: true});
737
+
738
+ const notStored = path => report({code: 'document-provenance-omitted', path, message: `${path} was stated in the source document but not stored at export, so the imported document keeps the values observed in the PPTX.`});
739
+ for (const path of array(document.omitted)) notStored(path);
740
+ for (const [index, entry] of slideRecords.entries()) for (const path of array(entry?.record.omitted)) notStored(`slides.${index}.${path}`);
741
+
742
+ const native = nativeDocument(entries, presentationRoot, presentationRels);
743
+ const stored = document.native;
744
+ const match = {colors: same(stored.colors, native.colors) && native.colors !== null, fonts: same(stored.fonts, native.fonts) && native.fonts !== null, size: same(stored.size, native.size)};
745
+ const storedDesign = document.design ?? {};
746
+ const gates = {
747
+ theme: [match.colors && match.fonts, () => `The PPTX theme colors or fonts changed since export (${[match.colors ? '' : changedSlots(stored.colors, native.colors), match.fonts ? '' : `${changedSlots(stored.fonts, native.fonts)} font`].filter(Boolean).join('; ')}).`],
748
+ colorScheme: [match.colors, () => `The PPTX theme colors changed since export (${changedSlots(stored.colors, native.colors)}).`],
749
+ fontScheme: [match.fonts, () => `The PPTX theme fonts changed since export (${changedSlots(stored.fonts, native.fonts)} font).`],
750
+ dimensions: [match.size, () => `The PPTX slide size changed since export.`]
751
+ };
752
+ const restoredStyle = {};
753
+ for (const key of ['theme', 'colorScheme', 'fontScheme', 'dimensions']) {
754
+ const [ok, why] = gates[key];
755
+ // Keys FF-24 recovers that the source never stated (a theme-only deck
756
+ // gains its colorScheme) are left as observed; see docs/document-roundtrip.md.
757
+ if (storedDesign[key] === undefined) continue;
758
+ if (!ok) { restoredStyle[key] = false; changed(`design.${key}`, `${why()} design.${key} ${label(storedDesign[key])} was not restored; the imported document keeps the values observed in the PPTX.`); continue; }
759
+ const value = restorable(`design.${key}`, storedDesign[key]);
760
+ if (value === undefined) { restoredStyle[key] = false; continue; }
761
+ restoredStyle[key] = true;
762
+ group(`design.${key}`, [set(['design', key], value)]);
763
+ }
764
+ const styleIntact = STYLE_REFERENCES.every(key => restoredStyle[key] !== false);
765
+
766
+ // Slide-level references.
767
+ const layouts = [];
768
+ const seenIds = new Set();
769
+ let allStructure = slideRecords.length === document.slides && slideRecords.every(Boolean);
770
+ const backgroundInheritors = [];
771
+ for (const [index, slide] of imported.slides.entries()) {
772
+ const entry = slideRecords[index];
773
+ if (!entry) { layouts.push({structure: 'untagged'}); allStructure = false; continue; }
774
+ const {record, native: observed} = entry;
775
+ const {entry: layoutEntry, structureMatch} = intent.slide(index, entry);
776
+ if (!structureMatch) allStructure = false;
777
+ layouts.push(layoutEntry);
778
+ const at = (...path) => ['slides', index, ...path];
779
+ if (record.id !== undefined) {
780
+ if (seenIds.has(record.id)) report({code: 'duplicate-slide-id', path: `slides.${index}.id`, message: `Slide id '${record.id}' appears on more than one slide (a duplicated slide); the first keeps it.`});
781
+ else { seenIds.add(record.id); group(`slides.${index}.id`, [set(at('id'), clone(record.id))]); }
782
+ }
783
+ if (record.beat !== undefined) group(`slides.${index}.beat`, [set(at('beat'), clone(record.beat))]);
784
+ const recordDesign = record.design ?? {};
785
+ const styleMatch = record.native.style === observed.style;
786
+ for (const key of STYLE_REFERENCES) {
787
+ if (recordDesign[key] === undefined) continue;
788
+ if (!styleMatch) { changed(`slides.${index}.design.${key}`, `The slide's fonts or colors changed since export, so slides.${index}.design.${key} ${label(recordDesign[key])} was not restored; the slide keeps its observed formatting.`); continue; }
789
+ const value = restorable(`slides.${index}.design.${key}`, recordDesign[key]);
790
+ if (value !== undefined) group(`slides.${index}.design.${key}`, [set(at('design', key), value)]);
791
+ }
792
+ const backgroundMatch = record.native.background === observed.background;
793
+ if (recordDesign.background !== undefined) {
794
+ if (!backgroundMatch) { changed(`slides.${index}.design.background`, `The slide background changed since export, so slides.${index}.design.background ${label(recordDesign.background)} was not restored; the slide keeps its observed background.`); continue; }
795
+ const value = restorable(`slides.${index}.design.background`, recordDesign.background);
796
+ if (value !== undefined) group(`slides.${index}.design.background`, [set(at('design', 'background'), value)]);
797
+ } else if (!array(record.omitted).includes('design.background')) backgroundInheritors.push({index, backgroundMatch});
798
+ }
799
+
800
+ // A deck background is evidenced by the slides that inherit it. It is kept
801
+ // while at least one of them still shows it (edited slides keep a local
802
+ // override) or when every slide overrides it. Unchanged inheriting slides
803
+ // then drop the background import observed on them.
804
+ const backgroundOps = [];
805
+ let deckBackground = storedDesign.background === undefined && !array(document.omitted).includes('design.background');
806
+ if (storedDesign.background !== undefined) {
807
+ if (!backgroundInheritors.length || backgroundInheritors.some(item => item.backgroundMatch)) {
808
+ const value = restorable('design.background', storedDesign.background);
809
+ if (value !== undefined) { backgroundOps.push(set(['design', 'background'], value)); deckBackground = true; }
810
+ } else changed('design.background', `Every slide that inherited the deck background now shows a different background, so design.background ${label(storedDesign.background)} was not restored; slides keep their observed backgrounds.`);
811
+ }
812
+ if (deckBackground && styleIntact) {
813
+ for (const {index, backgroundMatch} of backgroundInheritors) {
814
+ if (backgroundMatch) { if (imported.slides[index]?.design?.background !== undefined) backgroundOps.push(remove(['slides', index, 'design', 'background'])); }
815
+ else if (storedDesign.background !== undefined) changed(`slides.${index}.design.background`, `This slide's background changed since export; it is kept as a slide background override instead of inheriting design.background.`);
816
+ }
817
+ }
818
+ if (backgroundOps.length) group('design.background', backgroundOps);
819
+
820
+ for (const key of COMPOSITION_HINTS) {
821
+ if (storedDesign[key] === undefined) continue;
822
+ if (allStructure) group(`design.${key}`, [set(['design', key], clone(storedDesign[key]))]);
823
+ else changed(`design.${key}`, `Slides were added, removed or rearranged since export, so the deck default design.${key} was not restored.`);
824
+ }
825
+
826
+ // Authoring metadata has no native counterpart. Fields read from native
827
+ // content (the furniture organization name, linked socials) win over their
828
+ // stored values; stored-only fields (logo, role ...) return.
829
+ const metadata = document.metadata ?? {};
830
+ for (const key of METADATA) {
831
+ if (metadata[key] === undefined) continue;
832
+ if (key === 'organization' && organizationConflict) {
833
+ report({code: 'metadata-reference-changed', path: 'organization', message: 'Slides now show different organization names in their furniture, so the stored organization was not restored; each slide keeps its visible text.'});
834
+ continue;
835
+ }
836
+ let value = restorable(key, metadata[key], {prune: true});
837
+ if (value === undefined) continue;
838
+ if (key === 'organization' && object(imported.organization)) {
839
+ const observed = imported.organization;
840
+ // Same order as export: inline records, then the document source, host catalogs and bundled records.
841
+ const hostRecords = typeof socialPlatformRecords === 'function' ? socialPlatformRecords(document.catalogs) : (opfCore.catalogs?.socialPlatforms ?? []);
842
+ const records = [...array(document.catalogs?.socialPlatforms?.records ?? document.catalogs?.socialPlatforms), ...hostRecords].filter(object);
843
+ const merge = (stored, current) => object(current.socials) && object(stored?.socials) ? {...current, socials: authoredSocials(stored.socials, current.socials, records)} : current;
844
+ const list = array(value);
845
+ const index = list.findIndex(item => object(item) && item.id === observed.id);
846
+ if (index < 0) value = clone(observed);
847
+ else if (Array.isArray(value)) value[index] = {...list[index], ...merge(list[index], clone(observed))};
848
+ else value = {...value, ...merge(value, clone(observed))};
849
+ }
850
+ group(key, [set([key], value)]);
851
+ }
852
+
853
+ // Assets and inline catalog records follow what the restored document references.
854
+ const finalize = doc => {
855
+ const needed = [...new Set(assetReferences(doc))].filter(id => !own(doc.assets, id) && Object.hasOwn(storedAssets, id));
856
+ if (needed.length) doc.assets = {...(object(doc.assets) ? doc.assets : {}), ...Object.fromEntries(needed.map(id => [id, clone(storedAssets[id])]))};
857
+ if (object(document.catalogs)) {
858
+ const {catalogs: _ignored, ...rest} = doc;
859
+ const {value, unresolved} = resolveMedia(referencedCatalogs(document.catalogs, collectStrings(rest)), entries);
860
+ if (value && !unresolved) doc.catalogs = value;
861
+ }
862
+ return intent.finalize(doc);
863
+ };
864
+ return {groups, slides: layouts, finalize};
865
+ }
866
+
867
+ /**
868
+ * Layout intent of the tagged slides (FF-29): layout id, type, composition and
869
+ * composition hints are restored while a slide's arrangement is unchanged.
870
+ * Each layout id resolves to exactly one record, in this order:
871
+ * - the document's inline record (OPF_DOCUMENT_V1);
872
+ * - a bundled layout. A slide's override of a bundled id is used only when
873
+ * there is no document record, or its record for that id was rejected, and
874
+ * every restored slide with that id carries the same override, so it never
875
+ * changes another slide's layout;
876
+ * - the first restored slide's `layoutRecord`.
877
+ * A slide whose own record disagrees with the chosen one keeps its content
878
+ * without the layout id (`layout-reference-changed`), and an id that resolves
879
+ * to no record is not restored (`unresolved-layout-reference`), so the
880
+ * imported document always renders. `finalize` adds the slide-level records
881
+ * that the imported document references.
882
+ */
883
+ function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group, report, rejectedDocumentIds) {
884
+ const bundled = id => array(opfCore.catalogs?.layouts).find(item => item?.id === id);
885
+ const owns = slideRecords.map(entry => {
886
+ if (!entry) return null;
887
+ const {record, native: observed} = entry;
888
+ const structureMatch = record.native.structure === observed.structure;
889
+ const layout = typeof record.layout === 'string' ? record.layout : undefined;
890
+ let own = record.layoutRecord, problem;
891
+ // A record for another id is ignored and reported.
892
+ if (own !== undefined && own.id !== layout) { problem = layout === undefined ? undefined : 'mismatch'; own = undefined; }
893
+ else if (own !== undefined) {
894
+ const {value, unresolved} = resolveMedia(own, entries);
895
+ if (unresolved) { own = undefined; problem = 'media'; }
896
+ else if (!validLayoutRecord(value)) { own = undefined; problem = 'schema'; }
897
+ else own = value;
898
+ }
899
+ return {structureMatch, layout, own, problem, stated: record.layoutRecord};
900
+ });
901
+ const resolved = new Map();
902
+ for (const id of new Set(owns.filter(item => item?.structureMatch && item.layout !== undefined).map(item => item.layout))) {
903
+ const members = owns.filter(item => item?.structureMatch && item.layout === id);
904
+ const fromDocument = documentRecords.find(item => item?.id === id), builtIn = bundled(id);
905
+ if (fromDocument) resolved.set(id, {record: fromDocument, source: 'document'});
906
+ else if (builtIn) {
907
+ const overrides = members.filter(item => item.own && !same(item.own, builtIn));
908
+ if ((!hasDocument || rejectedDocumentIds.has(id)) && overrides.length && overrides.length === members.length && overrides.every(item => same(item.own, overrides[0].own))) resolved.set(id, {record: overrides[0].own, source: 'slides', add: true});
909
+ else resolved.set(id, {record: builtIn, source: 'bundled'});
910
+ } else {
911
+ const first = members.find(item => item.own);
912
+ if (first) resolved.set(id, {record: first.own, source: 'slides', add: true});
913
+ }
914
+ }
915
+ const chosen = new Map([...resolved].filter(([, item]) => item.add).map(([id, item]) => [id, item.record]));
916
+ const against = {document: 'the document', bundled: 'the built-in layout', slides: 'another slide'};
917
+ const slide = (index, {record}) => {
918
+ const {structureMatch, layout, own, problem, stated} = owns[index];
919
+ const {native: _native, ...recordValue} = record;
920
+ if (structureMatch && problem === 'mismatch') report({code: 'invalid-document-provenance', path: `slides.${index}.layoutRecord`, message: `The stored layout record '${stated?.id}' does not match slides.${index}.layout '${layout}', so it was not restored.`});
921
+ if (structureMatch && problem === 'media') report({code: 'unresolved-asset-reference', path: `slides.${index}.layoutRecord`, message: `slides.${index}.layoutRecord refers to a picture that is no longer in the PPTX, so the slide's stored layout record was not restored.`});
922
+ if (structureMatch && problem === 'schema') report({code: 'invalid-document-provenance', path: `slides.${index}.layoutRecord`, message: `slides.${index}.layoutRecord does not validate against the layouts catalog schema, so the slide's stored layout record was not restored.`});
923
+ const target = layout === undefined ? undefined : resolved.get(layout);
924
+ let restoreLayout = structureMatch;
925
+ if (structureMatch && layout !== undefined) {
926
+ if (!target) {
927
+ restoreLayout = false;
928
+ report({code: 'unresolved-layout-reference', path: `slides.${index}.layout`, message: `Layout '${layout}' is not a bundled layout and the PPTX carries no inline record for it, so slides.${index}.layout was not restored; the imported slide keeps its observed arrangement.`});
929
+ } else if (own && !same(own, target.record)) {
930
+ restoreLayout = false;
931
+ report({code: 'layout-reference-changed', path: `slides.${index}.layout`, message: `This slide stores a different record for layout '${layout}' than ${against[target.source]}, so slides.${index}.layout was not restored; the imported slide keeps its observed arrangement.`});
932
+ }
933
+ }
934
+ const catalogRecord = target && target.source !== 'bundled' ? target.record : undefined;
935
+ const at = (...path) => ['slides', index, ...path];
936
+ for (const key of SLIDE_STRUCTURE) {
937
+ if (record[key] === undefined) continue;
938
+ if (!structureMatch) report({code: key === 'layout' ? 'layout-reference-changed' : 'slide-reference-changed', path: `slides.${index}.${key}`,
939
+ message: `The slide's objects were moved, resized, added or removed since export, so slides.${index}.${key} ${label(record[key])} was not restored; the imported slide keeps its observed arrangement.`});
940
+ else if (key !== 'layout' || restoreLayout) group(`slides.${index}.${key}`, [{path: at(key), value: clone(record[key])}]);
941
+ }
942
+ for (const key of COMPOSITION_HINTS) {
943
+ const value = record.design?.[key];
944
+ if (value === undefined) continue;
945
+ if (structureMatch) group(`slides.${index}.design.${key}`, [{path: at('design', key), value: clone(value)}]);
946
+ else report({code: 'design-reference-changed', path: `slides.${index}.design.${key}`, message: `The slide's objects changed since export, so slides.${index}.design.${key} was not restored.`});
947
+ }
948
+ return {structureMatch, entry: {layout, structure: structureMatch ? 'match' : 'changed', record: clone(recordValue), ...(catalogRecord !== undefined ? {catalogRecord: clone(catalogRecord)} : {})}};
949
+ };
950
+ const finalize = doc => {
951
+ const needed = [...chosen].filter(([id]) => array(doc.slides).some(item => item?.layout === id) && !layoutRecords(doc.catalogs).some(item => item?.id === id));
952
+ if (!needed.length) return doc;
953
+ const catalogs = object(doc.catalogs) ? doc.catalogs : {}, current = catalogs.layouts, records = needed.map(([, record]) => clone(record));
954
+ doc.catalogs = {...catalogs, layouts: Array.isArray(current) ? [...current, ...records] : {...(object(current) ? current : {}), records: [...array(current?.records), ...records]}};
955
+ return doc;
956
+ };
957
+ return {slide, finalize};
958
+ }
959
+
960
+ function applyOp(doc, {path, value, remove: removing}) {
961
+ let holder = doc;
962
+ for (const key of path.slice(0, -1)) {
963
+ if (!object(holder[key]) && !Array.isArray(holder[key])) { if (removing) return; holder[key] = {}; }
964
+ holder = holder[key];
965
+ }
966
+ const last = path.at(-1);
967
+ if (removing) delete holder[last]; else holder[last] = clone(value);
968
+ }
969
+
970
+ function tidy(doc) {
971
+ for (const slide of doc.slides ?? []) if (object(slide.design) && !Object.keys(slide.design).length) delete slide.design;
972
+ if (object(doc.design) && !Object.keys(doc.design).length) delete doc.design;
973
+ return doc;
974
+ }
975
+
976
+ /**
977
+ * Apply restore groups to a copy of `imported`. When the combined result does
978
+ * not validate, groups are applied one at a time and only the groups that make
979
+ * the document invalid are left out, each with a diagnostic at its path.
980
+ */
981
+ export function applyDocumentProvenance(imported, {groups, finalize}, validate, report) {
982
+ const build = accepted => {
983
+ const doc = structuredClone(imported);
984
+ for (const item of accepted) for (const op of item.ops) applyOp(doc, op);
985
+ return tidy(doc);
986
+ };
987
+ const all = finalize(build(groups));
988
+ if (validate(all).valid) return all;
989
+ const accepted = [];
990
+ for (const item of groups) {
991
+ if (validate(build([...accepted, item])).valid) accepted.push(item);
992
+ else report({code: 'invalid-document-provenance', path: item.field, message: `The stored ${item.field} does not form a valid document with the imported content, so it was not restored; the imported document keeps the values observed in the PPTX.`});
993
+ }
994
+ const result = finalize(build(accepted));
995
+ if (validate(result).valid) return result;
996
+ report({code: 'invalid-document-provenance', path: 'catalogs', message: 'Stored inline catalog records or assets do not validate with the imported document and were not restored.'});
997
+ return build(accepted);
998
+ }