@openpresentation/opf-pptx 0.14.0 → 0.15.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.
@@ -2,8 +2,8 @@ import {XMLParser} from 'fast-xml-parser';
2
2
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
3
  import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks} from './content-topology.js';
4
4
  import {runColorRecord, validateRunColors} from './run-colors.js';
5
- import {catalogs as bundledCatalogs, validateCatalogRecord} from '@openpresentation/opf';
6
- import {resolveSocialProfile} from '@openpresentation/opf/composition';
5
+ import {validateCatalogRecord} from '@openpresentation/opf';
6
+ import {parseReference, resolveReference, resolveSocialProfile} from '@openpresentation/opf/composition';
7
7
 
8
8
  // FF-32: document and slide references survive a PPTX round trip.
9
9
  //
@@ -25,11 +25,24 @@ import {resolveSocialProfile} from '@openpresentation/opf/composition';
25
25
  // size-limited, validated with the whole imported document, and never executed.
26
26
  //
27
27
  // Layout intent (FF-29) is part of each OPF_SLIDE_V1 record: the slide's
28
- // layout id, type, composition and composition hints, plus `layoutRecord`,
29
- // the document's inline catalogs.layouts record for that id. The record is
30
- // kept on the slide as well as in the document's catalogs, so a slide keeps
31
- // its layout when OPF_DOCUMENT_V1 is missing or unreadable (for example a
32
- // slide pasted into another deck), and a pasted slide brings its own record.
28
+ // layout reference, type, composition and composition hints, plus
29
+ // `layoutRecord`, the record the document embeds for that reference:
30
+ // `{group, id, source?, record}` (OPF 0.15, FA-23), where `group` is the
31
+ // catalogs group the reference resolved in (`custom`, `default` or a named
32
+ // group), `source` that group's source and `record` the embedded record. The
33
+ // record is kept on the slide as well as in the document's catalogs, so a
34
+ // slide keeps its layout when OPF_DOCUMENT_V1 is missing or unreadable (for
35
+ // example a slide pasted into another deck), and a pasted slide brings its own
36
+ // record and the group it belongs to. A layout that resolves only in a host's
37
+ // registered catalog is not embedded by the export and stores no record.
38
+ //
39
+ // OPF_DOCUMENT_V1.catalogs (FA-23) is the document's grouped catalogs
40
+ // (`catalogs.<group>.<kind>.<id>`), pruned to the records the stored values
41
+ // reference (a reference inside a theme resolves in the theme's group first);
42
+ // every group declaration (`source`, `default: false`) is kept, since a
43
+ // `name:id` reference is only valid while its group is declared. The 0.14
44
+ // shapes (`catalogs.<kind>.records`, a layoutRecord carrying its own `id`) are
45
+ // not read: a tag written by an older exporter restores without them.
33
46
  //
34
47
  // Spec-gap closure P1 adds, in `full` mode: the document `filename` and
35
48
  // `extensions`, the whole `assets` registry, `design.logo` (BRAND_ASSETS, also
@@ -60,12 +73,12 @@ const own = (value, key) => object(value) && Object.hasOwn(value, key) && value[
60
73
  // Furniture socials re-import as the displayed profile URL (FF-34). While a line
61
74
  // still shows exactly what the stored authored value formats to, keep the
62
75
  // authored form (a handle stays a handle); an edited line keeps its new URL.
63
- function authoredSocials(stored, observed, records) {
76
+ function authoredSocials(stored, observed) {
64
77
  if (!object(stored) || !object(observed)) return observed;
65
78
  return Object.fromEntries(Object.entries(observed).map(([platform, value]) => {
66
79
  const authored = stored[platform];
67
80
  if (typeof authored !== 'string') return [platform, value];
68
- const profile = resolveSocialProfile(platform, authored, records, 'organization');
81
+ const profile = resolveSocialProfile(platform, authored, 'organization');
69
82
  const shown = profile.href && !/^[a-z][a-z0-9+.-]*:/i.test(profile.text) ? `https://${profile.text}` : profile.text;
70
83
  return [platform, shown === value ? authored : value];
71
84
  }));
@@ -74,7 +87,7 @@ function authoredSocials(stored, observed, records) {
74
87
  // Deck-level fields. References are gated by native evidence; metadata has no
75
88
  // native PowerPoint counterpart and round-trips from the stored value.
76
89
  export const DESIGN_REFERENCES = Object.freeze(['theme', 'colorScheme', 'fontScheme', 'dimensions', 'background']);
77
- export const COMPOSITION_HINTS = Object.freeze(['titleAlignment', 'contentAlignment', 'contentBox', 'contentDirection', 'chartPrimary', 'imageFill', 'listBullet']);
90
+ export const COMPOSITION_HINTS = Object.freeze(['titleAlignment', 'contentAlignment', 'contentBox', 'contentDirection', 'chartPrimary', 'imageFit', 'listBullet']);
78
91
  // `references` (RR-34): the deck's cited sources. Like `author`, stored as a top-level key of OPF_DOCUMENT_V1 (importers up to
79
92
  // 0.11.9 drop a tag with an unknown `supplement` field but ignore an unknown top-level key) and read back into metadata.
80
93
  export const METADATA = Object.freeze(['narrative', 'tone', 'audience', 'purpose', 'language', 'organization', 'speaker', 'takeaway', 'duration', 'tags', 'variables', 'filename', 'extensions', 'author', 'references']);
@@ -294,9 +307,15 @@ function nativeSlide(entries, path) {
294
307
  const root = parser.parse(dec.decode(entries[path]))?.['p:sld'];
295
308
  const cSld = root?.['p:cSld'], tree = cSld?.['p:spTree'];
296
309
  const rels = relationships(entries, path);
310
+ // FA-23: an image background with alt text is a back picture and its overlay is a shape (src/image-provenance.js); both
311
+ // are evidence of design.background with the native slide background, so moving or recolouring them is a background edit.
312
+ const named = (nodes, key) => array(nodes).filter(node => /^OPF background /.test(String(node?.[key]?.['p:cNvPr']?.name ?? '')));
313
+ const backgroundObjects = [...named(tree?.['p:pic'], 'p:nvPicPr').map(node => ({spPr: node['p:spPr'], blipFill: node['p:blipFill']})),
314
+ ...named(tree?.['p:sp'], 'p:nvSpPr').map(node => ({spPr: node['p:spPr']}))];
315
+ const background = backgroundObjects.length ? {bg: cSld?.['p:bg'] ?? null, objects: backgroundObjects} : cSld?.['p:bg'] ?? null;
297
316
  return {
298
317
  structure: hash(structure(tree)),
299
- background: hash(canonical(withResolvedRelationships(withoutExtensions(cSld?.['p:bg'] ?? null), rels, entries))),
318
+ background: hash(canonical(withResolvedRelationships(withoutExtensions(background), rels, entries))),
300
319
  style: hash(styleSignature(withoutExtensions(tree ?? null)))
301
320
  };
302
321
  }
@@ -309,17 +328,18 @@ const sizeOf = value => enc.encode(JSON.stringify(value)).byteLength;
309
328
  // The tag value is the UTF-8 JSON as uppercase hex: two characters per byte.
310
329
  const tagChars = value => sizeOf(value) * 2;
311
330
  const SOURCE_KEYS = new Set(['src', 'image', 'logo', 'photo']);
312
- const SOURCE_PREFIX = /^(asset:|data:|https?:|file:)/i;
313
- const METADATA_CATALOGS = Object.freeze({narrative: 'narratives', tone: 'tones', purpose: 'purposes', language: 'languages', audience: 'audiences'});
331
+ // An image source: an asset id, a URL, a data URI or a path relative to the document (the 0.15 image source forms).
332
+ const SOURCE_PREFIX = /^(asset:|data:|https?:|file:|\.\.?\/)/i;
333
+ // Metadata fields that hold content references (OPF 0.15: `id` or `name:id`; for audience and purpose a string that is not
334
+ // a reference is free text). `language` is an engine vocabulary (a BCP-47 tag), not a catalog reference.
335
+ const METADATA_REFERENCES = Object.freeze(['narrative', 'tone', 'purpose', 'audience']);
336
+ const LAYOUT_SCHEMA = 'https://openpresentation.org/schema/opf-layout/v1';
337
+ const GROUP_NAME = /^[a-z][a-z0-9-]*$/;
314
338
 
315
339
  function collectStrings(value, into = new Set()) {
316
340
  if (typeof value === 'string') into.add(value);
317
341
  else if (Array.isArray(value)) value.forEach(item => collectStrings(item, into));
318
- else if (object(value)) for (const [key, item] of Object.entries(value)) {
319
- // Socials keys are socialPlatforms catalog references (FF-34).
320
- if (key === 'socials' && object(item)) Object.keys(item).forEach(id => into.add(id));
321
- collectStrings(item, into);
322
- }
342
+ else if (object(value)) for (const item of Object.values(value)) collectStrings(item, into);
323
343
  return into;
324
344
  }
325
345
 
@@ -331,66 +351,93 @@ function carriesSource(value) {
331
351
  return false;
332
352
  }
333
353
 
334
- // Inline catalog records are kept only when referenced, so restored ids
335
- // resolve without copying unrelated catalog content.
336
- function pruneCatalogs(catalogs, referenced) {
354
+ // A reference's id: a string reference, or the `id` of an object reference (`{id, ...overrides}`).
355
+ const referenceOf = value => typeof value === 'string' ? value : object(value) && typeof value.id === 'string' ? value.id : undefined;
356
+
357
+ // The content references a document (or the stored subset of one) writes, as core resolves them: the root narrative,
358
+ // audience, purpose and tone, a language object's font schemes, the deck and slide designs and each slide's layout.
359
+ function documentReferences(doc) {
360
+ const references = [];
361
+ const push = (kind, value) => {
362
+ const reference = referenceOf(value);
363
+ if (reference !== undefined && parseReference(reference)) references.push({kind, reference});
364
+ };
365
+ const design = value => {
366
+ if (!object(value)) return;
367
+ push('themes', value.theme);
368
+ push('colorSchemes', value.colorScheme);
369
+ push('fontSchemes', value.fontScheme);
370
+ };
371
+ push('narratives', doc.narrative);
372
+ for (const audience of array(doc.audience)) push('audiences', audience);
373
+ push('purposes', doc.purpose);
374
+ push('tones', doc.tone);
375
+ if (object(doc.language)) { push('fontSchemes', doc.language.fontScheme); push('fontSchemes', doc.language.googleFontScheme); }
376
+ design(doc.design);
377
+ for (const slide of array(doc.slides)) if (object(slide)) { push('layouts', slide.layout); design(slide.design); }
378
+ return references;
379
+ }
380
+
381
+ // The embedded catalogs a set of references needs: each referenced record once, in the group it resolves in, plus the
382
+ // records it references (a theme's colour and font schemes, resolved in the theme's group first), and every group
383
+ // declaration (its `source`, or `default: false`). Only embedded records are kept; nothing resolves in a host catalog here.
384
+ export function referencedCatalogs(catalogs, references) {
337
385
  if (!object(catalogs)) return undefined;
386
+ const document = {catalogs};
338
387
  const result = {};
339
- for (const [kind, entry] of Object.entries(catalogs)) {
340
- if (Array.isArray(entry)) {
341
- const records = entry.filter(record => typeof record?.id === 'string' && referenced.has(record.id));
342
- if (records.length) result[kind] = records;
343
- } else if (object(entry)) {
344
- const records = array(entry.records).filter(record => typeof record?.id === 'string' && referenced.has(record.id));
345
- const kept = {...(entry.source !== undefined ? {source: entry.source} : {}), ...(records.length ? {records} : {})};
346
- if (Object.keys(kept).length) result[kind] = kept;
388
+ for (const [name, group] of Object.entries(catalogs)) {
389
+ if (name === 'default' && group === false) result[name] = false;
390
+ else if (object(group) && typeof group.source === 'string') result[name] = {source: group.source};
391
+ }
392
+ const queue = [...references], seen = new Set();
393
+ while (queue.length) {
394
+ const {kind, reference, group} = queue.shift();
395
+ const key = `${group ?? ''}|${kind}|${reference}`;
396
+ if (seen.has(key)) continue;
397
+ seen.add(key);
398
+ const found = resolveReference(document, kind, reference, {catalogs: [], ...(group !== undefined ? {group} : {})});
399
+ if (!found || found.origin !== 'document') continue;
400
+ const holder = result[found.group] = object(result[found.group]) ? result[found.group] : {};
401
+ holder[kind] = {...(object(holder[kind]) ? holder[kind] : {}), [found.id]: clone(found.record)};
402
+ if (kind === 'themes') for (const [schemeKind, field] of [['colorSchemes', 'colorScheme'], ['fontSchemes', 'fontScheme']]) {
403
+ const inner = referenceOf(found.record[field]);
404
+ if (inner !== undefined && parseReference(inner)) queue.push({kind: schemeKind, reference: inner, group: found.group});
347
405
  }
348
406
  }
349
407
  return Object.keys(result).length ? result : undefined;
350
408
  }
351
409
 
352
- // A referenced record can itself refer to another inline record (a theme to its color scheme).
353
- function referencedCatalogs(catalogs, referenced) {
354
- let pruned = pruneCatalogs(catalogs, referenced);
355
- for (let pass = 0; pass < 3 && pruned; pass += 1) {
356
- const ids = new Set(referenced);
357
- for (const entry of Object.values(pruned)) for (const record of array(Array.isArray(entry) ? entry : entry?.records)) collectStrings(record, ids);
358
- const next = pruneCatalogs(catalogs, ids);
359
- if (same(next, pruned)) break;
360
- pruned = next;
361
- }
362
- return pruned;
363
- }
364
-
365
- const layoutRecords = catalogs => array(Array.isArray(catalogs?.layouts) ? catalogs.layouts : catalogs?.layouts?.records);
366
-
367
410
  // Presentation validation deliberately accepts arbitrary inline catalog objects.
368
411
  // Recovered layouts must also pass their companion schema before composition
369
412
  // can use them (for example, a null placeholder otherwise crashes rendering).
370
- function validLayoutRecord(value) {
371
- // Inline catalogs already identify the kind. Supply only an omitted
372
- // standalone identifier for validation; never change the authored record.
373
- try { return object(value) && validateCatalogRecord('layouts', {$schema: 'https://openpresentation.org/schema/opf-layout/v1', ...value}).valid; }
413
+ // An embedded record carries no `$schema` or `id` (its key is the id): both are
414
+ // supplied for validation only; the authored record is never changed.
415
+ function validLayoutRecord(value, id) {
416
+ // An authored `$schema` or `id` is validated as it is (a foreign `$schema` fails).
417
+ try { return object(value) && validateCatalogRecord('layouts', {$schema: LAYOUT_SCHEMA, id, ...value}).valid; }
374
418
  catch { return false; }
375
419
  }
376
420
 
377
- function validatedLayoutCatalog(catalogs, entries, report, rejectedIds) {
378
- if (!object(catalogs) || catalogs.layouts === undefined) return catalogs;
379
- const current = catalogs.layouts;
380
- const records = layoutRecords(catalogs).flatMap((record, index) => {
381
- const {value, unresolved} = resolveMedia(record, entries);
382
- if (!unresolved && validLayoutRecord(value)) return [value];
383
- if (typeof record?.id === 'string') rejectedIds.add(record.id);
384
- const path = `catalogs.layouts.${Array.isArray(current) ? '' : 'records.'}${index}`;
385
- report({code: unresolved ? 'unresolved-asset-reference' : 'invalid-document-provenance', path,
386
- 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.`});
387
- return [];
388
- });
389
- const result = {...catalogs};
390
- if (records.length) result.layouts = Array.isArray(current) ? records : {...current, records};
391
- else if (object(current) && current.source !== undefined) { result.layouts = {...current}; delete result.layouts.records; }
392
- else delete result.layouts;
393
- return Object.keys(result).length ? result : undefined;
421
+ // Every embedded layout record of the stored catalogs, with packaged media resolved; a record that does not validate is
422
+ // left out (its group stays) and reported at catalogs.<group>.layouts.<id>.
423
+ function validatedLayoutCatalog(catalogs, entries, report) {
424
+ if (!object(catalogs)) return catalogs;
425
+ const result = {};
426
+ for (const [name, group] of Object.entries(catalogs)) {
427
+ if (!object(group) || !object(group.layouts)) { result[name] = group; continue; }
428
+ const layouts = {};
429
+ for (const [id, record] of Object.entries(group.layouts)) {
430
+ const {value, unresolved} = resolveMedia(record, entries);
431
+ if (!unresolved && validLayoutRecord(value, id)) { layouts[id] = value; continue; }
432
+ const path = `catalogs.${name}.layouts.${id}`;
433
+ report({code: unresolved ? 'unresolved-asset-reference' : 'invalid-document-provenance', path,
434
+ 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.`});
435
+ }
436
+ const kept = {...group};
437
+ if (Object.keys(layouts).length) kept.layouts = layouts; else delete kept.layouts;
438
+ result[name] = kept;
439
+ }
440
+ return result;
394
441
  }
395
442
 
396
443
  const assetReferences = value => [...collectStrings(value)].filter(item => item.startsWith('asset:')).map(item => item.slice(6));
@@ -409,7 +456,7 @@ const assetReferences = value => [...collectStrings(value)].filter(item => item.
409
456
  * catalog records those ids need. It stores no organization, speaker,
410
457
  * free-text metadata, slide ids or assets.
411
458
  */
412
- export function documentProvenance(presentation, {mode = 'full', isCatalogId = () => false, report = () => {}} = {}) {
459
+ export function documentProvenance(presentation, {mode = 'full', layoutOf = () => undefined, report = () => {}} = {}) {
413
460
  const referencesOnly = mode === 'references-only';
414
461
  const design = {}, metadata = {};
415
462
  for (const key of DESIGN_FIELDS) {
@@ -422,21 +469,24 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
422
469
  const value = presentation[key];
423
470
  if (key === 'author' && (referencesOnly || !authorNeedsRecord(value))) continue;
424
471
  if (referencesOnly) {
425
- const kind = METADATA_CATALOGS[key];
472
+ // A language tag is stored as it is; a reference field only while every value is a reference (never free text).
426
473
  const ids = typeof value === 'string' ? [value] : key === 'audience' && Array.isArray(value) && value.every(item => typeof item === 'string') ? value : null;
427
- if (!kind || !ids || !ids.every(id => isCatalogId(kind, id))) continue;
474
+ if (!ids || !(key === 'language' || (METADATA_REFERENCES.includes(key) && ids.every(id => parseReference(id))))) continue;
428
475
  }
429
476
  metadata[key] = clone(value);
430
477
  }
431
478
  const slides = presentation.slides.map((slide, index) => {
432
479
  const record = {v: 1, slide: index};
433
480
  for (const key of [...(referencesOnly ? [] : ['id', ...SLIDE_METADATA]), 'beat', ...SLIDE_STRUCTURE]) if (own(slide, key)) record[key] = clone(slide[key]);
434
- const layoutRecord = typeof slide.layout === 'string' ? layoutRecords(presentation.catalogs).find(item => item?.id === slide.layout) : undefined;
481
+ // The record the document embeds for the slide's layout reference, with the group it resolved in: core's provenance of
482
+ // the slide's resolved layout (resolveSlideContext().resolved.provenance.layout), passed in by the exporter.
483
+ const found = typeof slide.layout === 'string' ? layoutOf(index) : undefined;
435
484
  // The layout record is a catalog record. 'references-only' stores it only
436
485
  // when it names no image, file or URL; neither mode stores the assets it
437
486
  // references (as before FF-29, catalog records never pulled in assets).
438
- // A record's own $schema URL identifies its format and is not a source.
439
- if (layoutRecord && !(referencesOnly && carriesSource({...layoutRecord, $schema: undefined}))) record.layoutRecord = clone(layoutRecord);
487
+ if (found?.origin === 'document' && !(referencesOnly && carriesSource(found.record))) {
488
+ record.layoutRecord = {group: found.group, id: found.id, ...(found.source !== undefined ? {source: found.source} : {}), record: clone(found.record)};
489
+ }
440
490
  const slideDesign = {};
441
491
  for (const key of SLIDE_DESIGN_FIELDS) {
442
492
  if (!own(slide.design, key) || (referencesOnly && carriesSource(slide.design[key]))) continue;
@@ -448,12 +498,11 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
448
498
  const document = {v: 1, slides: slides.length};
449
499
  if (Object.keys(design).length) document.design = design;
450
500
  if (Object.keys(metadata).length) document.metadata = metadata;
451
- const stored = [design, metadata, slides];
452
501
  // 'full' stores the whole registry, so unreferenced assets return as authored;
453
502
  // 'references-only' stores no assets at all.
454
503
  if (!referencesOnly && object(presentation.assets) && Object.keys(presentation.assets).length) document.assets = clone(presentation.assets);
455
- const {catalogs, ...rest} = presentation;
456
- const catalogRecords = referencedCatalogs(catalogs, collectStrings(referencesOnly ? stored : rest));
504
+ // The embedded records (and group declarations) the stored values reference.
505
+ const catalogRecords = referencedCatalogs(presentation.catalogs, documentReferences({...metadata, design, slides}));
457
506
  if (catalogRecords) document.catalogs = catalogRecords;
458
507
  // Slide content topology is added per slide by the exporter (recordContentTopology).
459
508
  const stated = Object.keys(design).length || Object.keys(metadata).length || document.assets !== undefined || slides.some(record => Object.keys(record).length > 2);
@@ -794,7 +843,9 @@ function validateSlide(stored) {
794
843
  for (const key of SLIDE_SUPPLEMENT.design) if (object(stored.design) && stored.design[key] !== undefined) throw Error(`Supplement field design.${key} stored in the legacy section.`);
795
844
  const value = fromStoredShape(stored, SLIDE_SUPPLEMENT);
796
845
  for (const key of Object.keys(value.design ?? {})) if (!SLIDE_DESIGN_FIELDS.includes(key)) throw Error(`Unknown slide design field ${key}.`);
797
- if (value.layoutRecord !== undefined && (!object(value.layoutRecord) || typeof value.layoutRecord.id !== 'string')) throw Error('Invalid slide layout record.');
846
+ const layoutRecord = value.layoutRecord;
847
+ if (layoutRecord !== undefined && (!object(layoutRecord) || !GROUP_NAME.test(layoutRecord.group ?? '') || !GROUP_NAME.test(layoutRecord.id ?? '') || !object(layoutRecord.record)
848
+ || (layoutRecord.source !== undefined && typeof layoutRecord.source !== 'string') || Object.keys(layoutRecord).some(key => !['group', 'id', 'source', 'record'].includes(key)))) throw Error('Invalid slide layout record.');
798
849
  if (value.section !== undefined && typeof value.section !== 'string') throw Error('Invalid slide section record.');
799
850
  if (value.extensions !== undefined && !object(value.extensions)) throw Error('Invalid slide extensions record.');
800
851
  if (value.content !== undefined) validateTopology(value.content);
@@ -865,14 +916,19 @@ export function slideListBreaks(entries, path, root, rels) {
865
916
  * slides[i] = {layout, structure: 'match' | 'changed' | 'untagged', record, catalogRecord}
866
917
  * is the contract for layout-structure recovery (FF-29): `record` is the
867
918
  * validated OPF_SLIDE_V1 value (layout, type, composition, design hints, ...),
868
- * `catalogRecord` the stored inline layouts record for `layout`, if any.
919
+ * `catalogRecord` the stored embedded layout record for `layout`, if any
920
+ * (none when the layout resolves only in a registered host catalog).
921
+ *
922
+ * `catalogs` are the host's registered catalogs (core Catalog[]): a stored
923
+ * layout reference that neither the stored document catalogs nor a slide's
924
+ * own record resolves may still resolve there.
869
925
  *
870
926
  * `slides[i].contentBounds` are the native bounds (reference px) of the
871
927
  * imported blocks of slide i, in block order, for content topology matching.
872
928
  * `nativeSections` is the package's native section list as one name (or
873
929
  * undefined) per slide, or null when the package has no list.
874
930
  */
875
- export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, socialPlatformRecords, nativeSections = null}, report) {
931
+ export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, catalogs: hostCatalogs, nativeSections = null}, report) {
876
932
  const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
877
933
  let document;
878
934
  try {
@@ -881,8 +937,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
881
937
  else document = remapMedia(validateDocument(found.value), entries);
882
938
  } catch (error) { invalid(`${error.message}`); document = undefined; }
883
939
 
884
- const rejectedLayoutIds = new Set();
885
- if (document) document = {...document, catalogs: validatedLayoutCatalog(document.catalogs, entries, report, rejectedLayoutIds)};
940
+ if (document && document.catalogs !== undefined) document = {...document, catalogs: validatedLayoutCatalog(document.catalogs, entries, report)};
886
941
 
887
942
  const slideRecords = slides.map(({root, relationships: rels, path}, index) => {
888
943
  try {
@@ -898,7 +953,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
898
953
  const group = (field, ops, contentPaths) => groups.push({field, ops, ...(contentPaths ? {contentPaths} : {})});
899
954
  const set = (path, value) => ({path, value});
900
955
  const remove = path => ({path, remove: true});
901
- const intent = layoutIntent(document ? layoutRecords(document.catalogs) : [], Boolean(document), slideRecords, entries, group, report, rejectedLayoutIds);
956
+ const intent = layoutIntent(document?.catalogs, slideRecords, entries, group, report, hostCatalogs);
902
957
 
903
958
  // Slide and block ids must stay unique across the document; a duplicated
904
959
  // slide carries copies of both, and the first occurrence keeps them.
@@ -1123,10 +1178,8 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1123
1178
  if (value === undefined) continue;
1124
1179
  if (key === 'organization' && object(imported.organization)) {
1125
1180
  const observed = imported.organization;
1126
- // Same order as export: inline records, then the document source, host catalogs and bundled records.
1127
- const hostRecords = typeof socialPlatformRecords === 'function' ? socialPlatformRecords(document.catalogs) : bundledCatalogs.socialPlatforms;
1128
- const records = [...array(document.catalogs?.socialPlatforms?.records ?? document.catalogs?.socialPlatforms), ...hostRecords].filter(object);
1129
- const merge = (stored, current) => object(current.socials) && object(stored?.socials) ? {...current, socials: authoredSocials(stored.socials, current.socials, records)} : current;
1181
+ // Core formats socials from its own SOCIAL_PLATFORMS vocabulary, exactly as export did.
1182
+ const merge = (stored, current) => object(current.socials) && object(stored?.socials) ? {...current, socials: authoredSocials(stored.socials, current.socials)} : current;
1130
1183
  const list = array(value);
1131
1184
  const index = list.findIndex(item => object(item) && item.id === observed.id);
1132
1185
  if (index < 0) value = clone(observed);
@@ -1148,13 +1201,13 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1148
1201
  // (a media asset read from a video placeholder) keeps its observed value.
1149
1202
  for (const [id, asset] of Object.entries(storedAssets)) if (!own(imported.assets, id)) group(`assets.${id}`, [set(['assets', id], clone(asset))]);
1150
1203
 
1151
- // Assets and inline catalog records follow what the restored document references.
1204
+ // Assets and embedded catalog records follow what the restored document references.
1152
1205
  const finalize = doc => {
1153
1206
  const needed = [...new Set(assetReferences(doc))].filter(id => !own(doc.assets, id) && Object.hasOwn(storedAssets, id));
1154
1207
  if (needed.length) doc.assets = {...(object(doc.assets) ? doc.assets : {}), ...Object.fromEntries(needed.map(id => [id, clone(storedAssets[id])]))};
1155
1208
  if (object(document.catalogs)) {
1156
1209
  const {catalogs: _ignored, ...rest} = doc;
1157
- const {value, unresolved} = resolveMedia(referencedCatalogs(document.catalogs, collectStrings(rest)), entries);
1210
+ const {value, unresolved} = resolveMedia(referencedCatalogs(document.catalogs, documentReferences(rest)), entries);
1158
1211
  if (value && !unresolved) doc.catalogs = value;
1159
1212
  }
1160
1213
  return intent.finalize(doc);
@@ -1163,74 +1216,79 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1163
1216
  }
1164
1217
 
1165
1218
  /**
1166
- * Layout intent of the tagged slides (FF-29): layout id, type, composition and
1167
- * composition hints are restored while a slide's arrangement is unchanged.
1168
- * Each layout id resolves to exactly one record, in this order:
1169
- * - the document's inline record (OPF_DOCUMENT_V1);
1170
- * - a bundled layout. A slide's override of a bundled id is used only when
1171
- * there is no document record, or its record for that id was rejected, and
1172
- * every restored slide with that id carries the same override, so it never
1173
- * changes another slide's layout;
1174
- * - the first restored slide's `layoutRecord`.
1219
+ * Layout intent of the tagged slides (FF-29): layout reference, type,
1220
+ * composition and composition hints are restored while a slide's arrangement
1221
+ * is unchanged. Each layout reference resolves to exactly one record, in the
1222
+ * order core resolves references (embedded records before registered ones):
1223
+ * - the record the stored document catalogs embed (OPF_DOCUMENT_V1);
1224
+ * - a slide's own `layoutRecord`, embedded under its group by `finalize` (a
1225
+ * slide whose group the stored document declares with another source cannot
1226
+ * carry its record there). When the reference also resolves in a registered
1227
+ * catalog, a slide's record is used only when every restored slide with that
1228
+ * reference carries the same one, so it never changes another slide's layout;
1229
+ * - a record of the host's registered catalogs (`hostCatalogs`), which the
1230
+ * imported document then references without embedding it.
1175
1231
  * A slide whose own record disagrees with the chosen one keeps its content
1176
- * without the layout id (`layout-reference-changed`), and an id that resolves
1177
- * to no record is not restored (`unresolved-layout-reference`), so the
1178
- * imported document always renders. `finalize` adds the slide-level records
1179
- * that the imported document references.
1232
+ * without the layout reference (`layout-reference-changed`), and a reference
1233
+ * that resolves nowhere is not restored (`unresolved-reference`), so
1234
+ * the imported document always renders.
1180
1235
  */
1181
- function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group, report, rejectedDocumentIds) {
1182
- const bundled = id => array(bundledCatalogs.layouts).find(item => item?.id === id);
1236
+ export function layoutIntent(storedCatalogs, slideRecords, entries, group, report, hostCatalogs) {
1237
+ const stored = {catalogs: object(storedCatalogs) ? storedCatalogs : {}};
1238
+ const declared = name => Object.hasOwn(stored.catalogs, name) ? stored.catalogs[name] : undefined;
1183
1239
  const owns = slideRecords.map(entry => {
1184
1240
  if (!entry) return null;
1185
1241
  const {record, native: observed} = entry;
1186
1242
  const structureMatch = record.native.structure === observed.structure;
1187
1243
  const layout = typeof record.layout === 'string' ? record.layout : undefined;
1244
+ const parsed = layout === undefined ? undefined : parseReference(layout);
1188
1245
  let own = record.layoutRecord, problem;
1189
- // A record for another id is ignored and reported.
1190
- if (own !== undefined && own.id !== layout) { problem = layout === undefined ? undefined : 'mismatch'; own = undefined; }
1246
+ // A record for another reference (another id, or a group the reference does not name) is ignored and reported.
1247
+ const matches = own !== undefined && parsed !== undefined && own.id === parsed.id
1248
+ && (parsed.group !== undefined ? own.group === parsed.group : own.group === 'custom' || own.group === 'default');
1249
+ if (own !== undefined && !matches) { problem = layout === undefined ? undefined : 'mismatch'; own = undefined; }
1191
1250
  else if (own !== undefined) {
1192
- const {value, unresolved} = resolveMedia(own, entries);
1251
+ const {value, unresolved} = resolveMedia(own.record, entries);
1252
+ const target = declared(own.group);
1193
1253
  if (unresolved) { own = undefined; problem = 'media'; }
1194
- else if (!validLayoutRecord(value)) { own = undefined; problem = 'schema'; }
1195
- else own = value;
1254
+ else if (!validLayoutRecord(value, own.id)) { own = undefined; problem = 'schema'; }
1255
+ else if (own.group !== 'custom' && target !== undefined && (target === false || !object(target) || target.source !== own.source)) { own = undefined; problem = 'source'; }
1256
+ else own = {...own, record: value};
1196
1257
  }
1197
1258
  return {structureMatch, layout, own, problem, stated: record.layoutRecord};
1198
1259
  });
1199
1260
  const resolved = new Map();
1200
- for (const id of new Set(owns.filter(item => item?.structureMatch && item.layout !== undefined).map(item => item.layout))) {
1201
- const members = owns.filter(item => item?.structureMatch && item.layout === id);
1202
- const fromDocument = documentRecords.find(item => item?.id === id), builtIn = bundled(id);
1203
- if (fromDocument) resolved.set(id, {record: fromDocument, source: 'document'});
1204
- else if (builtIn) {
1205
- const overrides = members.filter(item => item.own && !same(item.own, builtIn));
1206
- 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});
1207
- else resolved.set(id, {record: builtIn, source: 'bundled'});
1208
- } else {
1209
- const first = members.find(item => item.own);
1210
- if (first) resolved.set(id, {record: first.own, source: 'slides', add: true});
1211
- }
1261
+ for (const layout of new Set(owns.filter(item => item?.structureMatch && item.layout !== undefined).map(item => item.layout))) {
1262
+ const members = owns.filter(item => item?.structureMatch && item.layout === layout);
1263
+ const fromDocument = resolveReference(stored, 'layouts', layout, {catalogs: []});
1264
+ const first = members.find(item => item.own);
1265
+ const fromHost = fromDocument ? undefined : resolveReference(stored, 'layouts', layout, {catalogs: Array.isArray(hostCatalogs) ? hostCatalogs : []});
1266
+ const agreed = first && members.every(item => item.own && same(item.own.record, first.own.record));
1267
+ if (fromDocument) resolved.set(layout, {record: fromDocument.record, source: 'document'});
1268
+ else if (first && (!fromHost || agreed)) resolved.set(layout, {record: first.own.record, source: 'slides', add: first.own});
1269
+ else if (fromHost) resolved.set(layout, {record: fromHost.record, source: 'host'});
1212
1270
  }
1213
- const chosen = new Map([...resolved].filter(([, item]) => item.add).map(([id, item]) => [id, item.record]));
1214
- const against = {document: 'the document', bundled: 'the built-in layout', slides: 'another slide'};
1271
+ const against = {document: 'the document', host: 'the registered catalog', slides: 'another slide'};
1215
1272
  // `typeInContent`: the slide stores a content topology, so its `type` is restored with that group (or not at all).
1216
1273
  const slide = (index, {record}, typeInContent = false) => {
1217
1274
  const {structureMatch, layout, own, problem, stated} = owns[index];
1218
1275
  const {native: _native, ...recordValue} = record;
1219
- 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.`});
1276
+ if (structureMatch && problem === 'mismatch') report({code: 'invalid-document-provenance', path: `slides.${index}.layoutRecord`, message: `The stored layout record '${stated?.group}:${stated?.id}' does not match slides.${index}.layout '${layout}', so it was not restored.`});
1220
1277
  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.`});
1221
1278
  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.`});
1279
+ if (structureMatch && problem === 'source') report({code: 'invalid-document-provenance', path: `slides.${index}.layoutRecord`, message: `slides.${index}.layoutRecord belongs to catalogs.${stated.group}${stated.source ? ` (${stated.source})` : ''}, which the document declares with another source, so the slide's stored layout record was not restored.`});
1222
1280
  const target = layout === undefined ? undefined : resolved.get(layout);
1223
1281
  let restoreLayout = structureMatch;
1224
1282
  if (structureMatch && layout !== undefined) {
1225
1283
  if (!target) {
1226
1284
  restoreLayout = false;
1227
- 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.`});
1228
- } else if (own && !same(own, target.record)) {
1285
+ report({code: 'unresolved-reference', path: `slides.${index}.layout`, message: `Layout '${layout}' resolves neither in the catalogs the PPTX stores nor in a registered catalog, so slides.${index}.layout was not restored; the imported slide keeps its observed arrangement.`});
1286
+ } else if (own && !same(own.record, target.record)) {
1229
1287
  restoreLayout = false;
1230
1288
  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.`});
1231
1289
  }
1232
1290
  }
1233
- const catalogRecord = target && target.source !== 'bundled' ? target.record : undefined;
1291
+ const catalogRecord = target && target.source !== 'host' ? target.record : undefined;
1234
1292
  const at = (...path) => ['slides', index, ...path];
1235
1293
  for (const key of SLIDE_STRUCTURE) {
1236
1294
  if (record[key] === undefined || (key === 'type' && typeInContent)) continue;
@@ -1246,11 +1304,20 @@ function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group
1246
1304
  }
1247
1305
  return {structureMatch, entry: {layout, structure: structureMatch ? 'match' : 'changed', record: clone(recordValue), ...(catalogRecord !== undefined ? {catalogRecord: clone(catalogRecord)} : {})}};
1248
1306
  };
1307
+ // A record a slide carried is embedded under its group (declared with its source when the document does not declare it)
1308
+ // while a restored slide still references it and the document does not already resolve it.
1249
1309
  const finalize = doc => {
1250
- const needed = [...chosen].filter(([id]) => array(doc.slides).some(item => item?.layout === id) && !layoutRecords(doc.catalogs).some(item => item?.id === id));
1251
- if (!needed.length) return doc;
1252
- const catalogs = object(doc.catalogs) ? doc.catalogs : {}, current = catalogs.layouts, records = needed.map(([, record]) => clone(record));
1253
- doc.catalogs = {...catalogs, layouts: Array.isArray(current) ? [...current, ...records] : {...(object(current) ? current : {}), records: [...array(current?.records), ...records]}};
1310
+ for (const [layout, item] of resolved) {
1311
+ if (!item.add || !array(doc.slides).some(entry => entry?.layout === layout) || resolveReference(doc, 'layouts', layout, {catalogs: []})) continue;
1312
+ const {group: name, id, source} = item.add;
1313
+ const catalogs = object(doc.catalogs) ? {...doc.catalogs} : {};
1314
+ const current = catalogs[name];
1315
+ if (current === false) continue;
1316
+ const holder = object(current) ? {...current} : (name !== 'custom' && source !== undefined ? {source} : {});
1317
+ holder.layouts = {...(object(holder.layouts) ? holder.layouts : {}), [id]: clone(item.record)};
1318
+ catalogs[name] = holder;
1319
+ doc.catalogs = catalogs;
1320
+ }
1254
1321
  return doc;
1255
1322
  };
1256
1323
  return {slide, finalize};