@openpresentation/opf-pptx 0.14.0 → 0.16.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']);
@@ -112,6 +125,16 @@ const SLIDE_DESIGN_FIELDS = [...STYLE_REFERENCES, 'background', ...COMPOSITION_H
112
125
  // every new design or metadata key here, never to the legacy sections.
113
126
  const DOCUMENT_SUPPLEMENT = Object.freeze({design: BRAND_ASSETS, metadata: Object.freeze(['filename', 'extensions'])});
114
127
  const SLIDE_SUPPLEMENT = Object.freeze({design: BRAND_ASSETS});
128
+ // RR-59: document-level values the import reads back although export only baked them in from engine defaults: the
129
+ // canonical `$schema`, the package title (`name`), the language the runs carry, and the theme, colour scheme, font scheme
130
+ // and slide size of the generated theme. In `full` mode OPF_DOCUMENT_V1.absent lists the ones the source did not state
131
+ // (a top-level key, which every published importer ignores), and import leaves each of them absent while the native
132
+ // value it would be read from is still the default the export wrote; a value edited in PowerPoint is imported as observed.
133
+ // (`author`, the deck background and the slides inheriting it already return absent whenever the document tag exists.)
134
+ export const DEFAULTED = Object.freeze(['$schema', 'name', 'language', 'design.theme', 'design.colorScheme', 'design.fontScheme', 'design.dimensions']);
135
+ // The package title export writes when the document has no `name` (and no `filename`).
136
+ export const DEFAULT_TITLE = 'OPF Presentation';
137
+ const statedAt = (presentation, path) => path.startsWith('design.') ? own(presentation.design, path.slice(7)) : own(presentation, path);
115
138
 
116
139
  // Storage shape: move the supplement keys out of the legacy sections.
117
140
  function toStoredShape(record, spec) {
@@ -294,9 +317,15 @@ function nativeSlide(entries, path) {
294
317
  const root = parser.parse(dec.decode(entries[path]))?.['p:sld'];
295
318
  const cSld = root?.['p:cSld'], tree = cSld?.['p:spTree'];
296
319
  const rels = relationships(entries, path);
320
+ // FA-23: an image background with alt text is a back picture and its overlay is a shape (src/image-provenance.js); both
321
+ // are evidence of design.background with the native slide background, so moving or recolouring them is a background edit.
322
+ const named = (nodes, key) => array(nodes).filter(node => /^OPF background /.test(String(node?.[key]?.['p:cNvPr']?.name ?? '')));
323
+ const backgroundObjects = [...named(tree?.['p:pic'], 'p:nvPicPr').map(node => ({spPr: node['p:spPr'], blipFill: node['p:blipFill']})),
324
+ ...named(tree?.['p:sp'], 'p:nvSpPr').map(node => ({spPr: node['p:spPr']}))];
325
+ const background = backgroundObjects.length ? {bg: cSld?.['p:bg'] ?? null, objects: backgroundObjects} : cSld?.['p:bg'] ?? null;
297
326
  return {
298
327
  structure: hash(structure(tree)),
299
- background: hash(canonical(withResolvedRelationships(withoutExtensions(cSld?.['p:bg'] ?? null), rels, entries))),
328
+ background: hash(canonical(withResolvedRelationships(withoutExtensions(background), rels, entries))),
300
329
  style: hash(styleSignature(withoutExtensions(tree ?? null)))
301
330
  };
302
331
  }
@@ -309,17 +338,18 @@ const sizeOf = value => enc.encode(JSON.stringify(value)).byteLength;
309
338
  // The tag value is the UTF-8 JSON as uppercase hex: two characters per byte.
310
339
  const tagChars = value => sizeOf(value) * 2;
311
340
  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'});
341
+ // An image source: an asset id, a URL, a data URI or a path relative to the document (the 0.15 image source forms).
342
+ const SOURCE_PREFIX = /^(asset:|data:|https?:|file:|\.\.?\/)/i;
343
+ // Metadata fields that hold content references (OPF 0.15: `id` or `name:id`; for audience and purpose a string that is not
344
+ // a reference is free text). `language` is an engine vocabulary (a BCP-47 tag), not a catalog reference.
345
+ const METADATA_REFERENCES = Object.freeze(['narrative', 'tone', 'purpose', 'audience']);
346
+ const LAYOUT_SCHEMA = 'https://openpresentation.org/schema/opf-layout/v1';
347
+ const GROUP_NAME = /^[a-z][a-z0-9-]*$/;
314
348
 
315
349
  function collectStrings(value, into = new Set()) {
316
350
  if (typeof value === 'string') into.add(value);
317
351
  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
- }
352
+ else if (object(value)) for (const item of Object.values(value)) collectStrings(item, into);
323
353
  return into;
324
354
  }
325
355
 
@@ -331,66 +361,93 @@ function carriesSource(value) {
331
361
  return false;
332
362
  }
333
363
 
334
- // Inline catalog records are kept only when referenced, so restored ids
335
- // resolve without copying unrelated catalog content.
336
- function pruneCatalogs(catalogs, referenced) {
364
+ // A reference's id: a string reference, or the `id` of an object reference (`{id, ...overrides}`).
365
+ const referenceOf = value => typeof value === 'string' ? value : object(value) && typeof value.id === 'string' ? value.id : undefined;
366
+
367
+ // The content references a document (or the stored subset of one) writes, as core resolves them: the root narrative,
368
+ // audience, purpose and tone, a language object's font schemes, the deck and slide designs and each slide's layout.
369
+ function documentReferences(doc) {
370
+ const references = [];
371
+ const push = (kind, value) => {
372
+ const reference = referenceOf(value);
373
+ if (reference !== undefined && parseReference(reference)) references.push({kind, reference});
374
+ };
375
+ const design = value => {
376
+ if (!object(value)) return;
377
+ push('themes', value.theme);
378
+ push('colorSchemes', value.colorScheme);
379
+ push('fontSchemes', value.fontScheme);
380
+ };
381
+ push('narratives', doc.narrative);
382
+ for (const audience of array(doc.audience)) push('audiences', audience);
383
+ push('purposes', doc.purpose);
384
+ push('tones', doc.tone);
385
+ if (object(doc.language)) { push('fontSchemes', doc.language.fontScheme); push('fontSchemes', doc.language.googleFontScheme); }
386
+ design(doc.design);
387
+ for (const slide of array(doc.slides)) if (object(slide)) { push('layouts', slide.layout); design(slide.design); }
388
+ return references;
389
+ }
390
+
391
+ // The embedded catalogs a set of references needs: each referenced record once, in the group it resolves in, plus the
392
+ // records it references (a theme's colour and font schemes, resolved in the theme's group first), and every group
393
+ // declaration (its `source`, or `default: false`). Only embedded records are kept; nothing resolves in a host catalog here.
394
+ export function referencedCatalogs(catalogs, references) {
337
395
  if (!object(catalogs)) return undefined;
396
+ const document = {catalogs};
338
397
  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;
398
+ for (const [name, group] of Object.entries(catalogs)) {
399
+ if (name === 'default' && group === false) result[name] = false;
400
+ else if (object(group) && typeof group.source === 'string') result[name] = {source: group.source};
401
+ }
402
+ const queue = [...references], seen = new Set();
403
+ while (queue.length) {
404
+ const {kind, reference, group} = queue.shift();
405
+ const key = `${group ?? ''}|${kind}|${reference}`;
406
+ if (seen.has(key)) continue;
407
+ seen.add(key);
408
+ const found = resolveReference(document, kind, reference, {catalogs: [], ...(group !== undefined ? {group} : {})});
409
+ if (!found || found.origin !== 'document') continue;
410
+ const holder = result[found.group] = object(result[found.group]) ? result[found.group] : {};
411
+ holder[kind] = {...(object(holder[kind]) ? holder[kind] : {}), [found.id]: clone(found.record)};
412
+ if (kind === 'themes') for (const [schemeKind, field] of [['colorSchemes', 'colorScheme'], ['fontSchemes', 'fontScheme']]) {
413
+ const inner = referenceOf(found.record[field]);
414
+ if (inner !== undefined && parseReference(inner)) queue.push({kind: schemeKind, reference: inner, group: found.group});
347
415
  }
348
416
  }
349
417
  return Object.keys(result).length ? result : undefined;
350
418
  }
351
419
 
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
420
  // Presentation validation deliberately accepts arbitrary inline catalog objects.
368
421
  // Recovered layouts must also pass their companion schema before composition
369
422
  // 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; }
423
+ // An embedded record carries no `$schema` or `id` (its key is the id): both are
424
+ // supplied for validation only; the authored record is never changed.
425
+ function validLayoutRecord(value, id) {
426
+ // An authored `$schema` or `id` is validated as it is (a foreign `$schema` fails).
427
+ try { return object(value) && validateCatalogRecord('layouts', {$schema: LAYOUT_SCHEMA, id, ...value}).valid; }
374
428
  catch { return false; }
375
429
  }
376
430
 
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;
431
+ // Every embedded layout record of the stored catalogs, with packaged media resolved; a record that does not validate is
432
+ // left out (its group stays) and reported at catalogs.<group>.layouts.<id>.
433
+ function validatedLayoutCatalog(catalogs, entries, report) {
434
+ if (!object(catalogs)) return catalogs;
435
+ const result = {};
436
+ for (const [name, group] of Object.entries(catalogs)) {
437
+ if (!object(group) || !object(group.layouts)) { result[name] = group; continue; }
438
+ const layouts = {};
439
+ for (const [id, record] of Object.entries(group.layouts)) {
440
+ const {value, unresolved} = resolveMedia(record, entries);
441
+ if (!unresolved && validLayoutRecord(value, id)) { layouts[id] = value; continue; }
442
+ const path = `catalogs.${name}.layouts.${id}`;
443
+ report({code: unresolved ? 'unresolved-asset-reference' : 'invalid-document-provenance', path,
444
+ 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.`});
445
+ }
446
+ const kept = {...group};
447
+ if (Object.keys(layouts).length) kept.layouts = layouts; else delete kept.layouts;
448
+ result[name] = kept;
449
+ }
450
+ return result;
394
451
  }
395
452
 
396
453
  const assetReferences = value => [...collectStrings(value)].filter(item => item.startsWith('asset:')).map(item => item.slice(6));
@@ -409,7 +466,7 @@ const assetReferences = value => [...collectStrings(value)].filter(item => item.
409
466
  * catalog records those ids need. It stores no organization, speaker,
410
467
  * free-text metadata, slide ids or assets.
411
468
  */
412
- export function documentProvenance(presentation, {mode = 'full', isCatalogId = () => false, report = () => {}} = {}) {
469
+ export function documentProvenance(presentation, {mode = 'full', layoutOf = () => undefined, report = () => {}} = {}) {
413
470
  const referencesOnly = mode === 'references-only';
414
471
  const design = {}, metadata = {};
415
472
  for (const key of DESIGN_FIELDS) {
@@ -422,21 +479,24 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
422
479
  const value = presentation[key];
423
480
  if (key === 'author' && (referencesOnly || !authorNeedsRecord(value))) continue;
424
481
  if (referencesOnly) {
425
- const kind = METADATA_CATALOGS[key];
482
+ // A language tag is stored as it is; a reference field only while every value is a reference (never free text).
426
483
  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;
484
+ if (!ids || !(key === 'language' || (METADATA_REFERENCES.includes(key) && ids.every(id => parseReference(id))))) continue;
428
485
  }
429
486
  metadata[key] = clone(value);
430
487
  }
431
488
  const slides = presentation.slides.map((slide, index) => {
432
489
  const record = {v: 1, slide: index};
433
490
  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;
491
+ // The record the document embeds for the slide's layout reference, with the group it resolved in: core's provenance of
492
+ // the slide's resolved layout (resolveSlideContext().resolved.provenance.layout), passed in by the exporter.
493
+ const found = typeof slide.layout === 'string' ? layoutOf(index) : undefined;
435
494
  // The layout record is a catalog record. 'references-only' stores it only
436
495
  // when it names no image, file or URL; neither mode stores the assets it
437
496
  // 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);
497
+ if (found?.origin === 'document' && !(referencesOnly && carriesSource(found.record))) {
498
+ record.layoutRecord = {group: found.group, id: found.id, ...(found.source !== undefined ? {source: found.source} : {}), record: clone(found.record)};
499
+ }
440
500
  const slideDesign = {};
441
501
  for (const key of SLIDE_DESIGN_FIELDS) {
442
502
  if (!own(slide.design, key) || (referencesOnly && carriesSource(slide.design[key]))) continue;
@@ -446,17 +506,22 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
446
506
  return record;
447
507
  });
448
508
  const document = {v: 1, slides: slides.length};
509
+ // 'references-only' stores no absence: it leaves stated values out, so a missing key there proves nothing.
510
+ if (!referencesOnly) {
511
+ const absent = DEFAULTED.filter(path => !statedAt(presentation, path));
512
+ if (absent.length) document.absent = absent;
513
+ }
449
514
  if (Object.keys(design).length) document.design = design;
450
515
  if (Object.keys(metadata).length) document.metadata = metadata;
451
- const stored = [design, metadata, slides];
452
516
  // 'full' stores the whole registry, so unreferenced assets return as authored;
453
517
  // 'references-only' stores no assets at all.
454
518
  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));
519
+ // The embedded records (and group declarations) the stored values reference.
520
+ const catalogRecords = referencedCatalogs(presentation.catalogs, documentReferences({...metadata, design, slides}));
457
521
  if (catalogRecords) document.catalogs = catalogRecords;
458
- // Slide content topology is added per slide by the exporter (recordContentTopology).
459
- const stated = Object.keys(design).length || Object.keys(metadata).length || document.assets !== undefined || slides.some(record => Object.keys(record).length > 2);
522
+ // Slide content topology is added per slide by the exporter (recordContentTopology). In 'full' mode every document states
523
+ // something: the defaults it leaves to the engine (`absent`) are worth the tags, so import can leave them absent.
524
+ const stated = !referencesOnly || Object.keys(design).length || Object.keys(metadata).length || document.assets !== undefined || slides.some(record => Object.keys(record).length > 2);
460
525
  return {mode, document, slides, report, stated: Boolean(stated)};
461
526
  }
462
527
 
@@ -599,6 +664,7 @@ function storable(entries, provenance) {
599
664
  };
600
665
  const source = provenance.document;
601
666
  const document = {v: 1, slides: source.slides};
667
+ if (source.absent) document.absent = [...source.absent];
602
668
  const assets = {};
603
669
  for (const [id, asset] of Object.entries(source.assets ?? {})) {
604
670
  const value = prepare(`assets.${id}`, asset);
@@ -689,8 +755,9 @@ function storable(entries, provenance) {
689
755
  * `entries` maps part paths to bytes and is updated in place.
690
756
  */
691
757
  export function attachDocumentProvenance(entries, provenance) {
692
- // A document that states nothing (no references, metadata, slide fields or
693
- // content topology) gets no tags and its bytes are unchanged.
758
+ // A document that states nothing (in 'references-only' mode: no references,
759
+ // metadata, slide fields or content topology) gets no tags and its bytes are
760
+ // unchanged. In 'full' mode the defaults it leaves absent are always recorded.
694
761
  if (!provenance || !(provenance.stated || provenance.slides.some(record => record.content !== undefined))) return;
695
762
  const presentationXml = dec.decode(entries['ppt/presentation.xml']);
696
763
  const presentationRoot = parser.parse(presentationXml)['p:presentation'];
@@ -780,6 +847,9 @@ function validateDocument(stored) {
780
847
  const references = stored.references;
781
848
  if (references !== undefined && (!Array.isArray(references) || references.length > 4096 || !references.every(object))) throw Error('Invalid references record.');
782
849
  validateOmitted(value.omitted);
850
+ // Untrusted: only a short list of strings is read, and a path this importer does not know is ignored (a newer exporter's).
851
+ if (value.absent !== undefined && (!Array.isArray(value.absent) || value.absent.length > 64 || !value.absent.every(path => typeof path === 'string' && path.length <= 256))) throw Error('Invalid absent field list.');
852
+ if (value.absent !== undefined) value.absent = value.absent.filter(path => DEFAULTED.includes(path));
783
853
  if (author !== undefined || references !== undefined) {
784
854
  const {author: _author, references: _references, ...rest} = value;
785
855
  return {...rest, metadata: {...rest.metadata, ...(author !== undefined ? {author} : {}), ...(references !== undefined ? {references} : {})}};
@@ -794,7 +864,9 @@ function validateSlide(stored) {
794
864
  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
865
  const value = fromStoredShape(stored, SLIDE_SUPPLEMENT);
796
866
  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.');
867
+ const layoutRecord = value.layoutRecord;
868
+ if (layoutRecord !== undefined && (!object(layoutRecord) || !GROUP_NAME.test(layoutRecord.group ?? '') || !GROUP_NAME.test(layoutRecord.id ?? '') || !object(layoutRecord.record)
869
+ || (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
870
  if (value.section !== undefined && typeof value.section !== 'string') throw Error('Invalid slide section record.');
799
871
  if (value.extensions !== undefined && !object(value.extensions)) throw Error('Invalid slide extensions record.');
800
872
  if (value.content !== undefined) validateTopology(value.content);
@@ -865,14 +937,19 @@ export function slideListBreaks(entries, path, root, rels) {
865
937
  * slides[i] = {layout, structure: 'match' | 'changed' | 'untagged', record, catalogRecord}
866
938
  * is the contract for layout-structure recovery (FF-29): `record` is the
867
939
  * validated OPF_SLIDE_V1 value (layout, type, composition, design hints, ...),
868
- * `catalogRecord` the stored inline layouts record for `layout`, if any.
940
+ * `catalogRecord` the stored embedded layout record for `layout`, if any
941
+ * (none when the layout resolves only in a registered host catalog).
942
+ *
943
+ * `catalogs` are the host's registered catalogs (core Catalog[]): a stored
944
+ * layout reference that neither the stored document catalogs nor a slide's
945
+ * own record resolves may still resolve there.
869
946
  *
870
947
  * `slides[i].contentBounds` are the native bounds (reference px) of the
871
948
  * imported blocks of slide i, in block order, for content topology matching.
872
949
  * `nativeSections` is the package's native section list as one name (or
873
950
  * undefined) per slide, or null when the package has no list.
874
951
  */
875
- export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, socialPlatformRecords, nativeSections = null}, report) {
952
+ export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, catalogs: hostCatalogs, nativeSections = null, schemaOption = false}, report) {
876
953
  const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
877
954
  let document;
878
955
  try {
@@ -881,8 +958,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
881
958
  else document = remapMedia(validateDocument(found.value), entries);
882
959
  } catch (error) { invalid(`${error.message}`); document = undefined; }
883
960
 
884
- const rejectedLayoutIds = new Set();
885
- if (document) document = {...document, catalogs: validatedLayoutCatalog(document.catalogs, entries, report, rejectedLayoutIds)};
961
+ if (document && document.catalogs !== undefined) document = {...document, catalogs: validatedLayoutCatalog(document.catalogs, entries, report)};
886
962
 
887
963
  const slideRecords = slides.map(({root, relationships: rels, path}, index) => {
888
964
  try {
@@ -898,7 +974,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
898
974
  const group = (field, ops, contentPaths) => groups.push({field, ops, ...(contentPaths ? {contentPaths} : {})});
899
975
  const set = (path, value) => ({path, value});
900
976
  const remove = path => ({path, remove: true});
901
- const intent = layoutIntent(document ? layoutRecords(document.catalogs) : [], Boolean(document), slideRecords, entries, group, report, rejectedLayoutIds);
977
+ const intent = layoutIntent(document?.catalogs, slideRecords, entries, group, report, hostCatalogs);
902
978
 
903
979
  // Slide and block ids must stay unique across the document; a duplicated
904
980
  // slide carries copies of both, and the first occurrence keeps them.
@@ -1024,6 +1100,21 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1024
1100
  group(`design.${key}`, [set(['design', key], value)]);
1025
1101
  }
1026
1102
  const styleIntact = STYLE_REFERENCES.every(key => restoredStyle[key] !== false);
1103
+ // RR-59: a default the source left absent stays absent while the native value it is read from is the one export wrote
1104
+ // (FF-24's theme recovery, for example, gives a theme-only deck its colour scheme). A native edit is imported as observed.
1105
+ const absent = new Set(array(document.absent));
1106
+ const defaultGates = {theme: match.colors && match.fonts, colorScheme: match.colors, fontScheme: match.fonts, dimensions: match.size};
1107
+ for (const [key, unchanged] of Object.entries(defaultGates)) {
1108
+ if (absent.has(`design.${key}`) && unchanged && storedDesign[key] === undefined && own(imported.design, key)) group(`design.${key}`, [remove(['design', key])]);
1109
+ }
1110
+ // No native field holds `$schema`; an importer option that names one keeps it.
1111
+ if (absent.has('$schema') && !schemaOption && own(imported, '$schema')) group('$schema', [remove(['$schema'])]);
1112
+ // The package title export wrote for a document with no name: its filename, else the default title.
1113
+ const defaultTitle = own(document.metadata, 'filename') ? document.metadata.filename : array(document.omitted).includes('filename') ? undefined : DEFAULT_TITLE;
1114
+ if (absent.has('name') && defaultTitle !== undefined && imported.name === defaultTitle) group('name', [remove(['name'])]);
1115
+ // The runs' language is compared with the engine default in reconcileLanguage (src/script-fonts.js), which keeps this
1116
+ // removal only while the runs still carry that default.
1117
+ if (absent.has('language') && own(imported, 'language')) group('language', [remove(['language'])]);
1027
1118
  // Brand images have no native gate (the P2 logo picture is consumed separately).
1028
1119
  for (const key of BRAND_ASSETS) {
1029
1120
  if (storedDesign[key] === undefined) continue;
@@ -1123,10 +1214,8 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1123
1214
  if (value === undefined) continue;
1124
1215
  if (key === 'organization' && object(imported.organization)) {
1125
1216
  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;
1217
+ // Core formats socials from its own SOCIAL_PLATFORMS vocabulary, exactly as export did.
1218
+ const merge = (stored, current) => object(current.socials) && object(stored?.socials) ? {...current, socials: authoredSocials(stored.socials, current.socials)} : current;
1130
1219
  const list = array(value);
1131
1220
  const index = list.findIndex(item => object(item) && item.id === observed.id);
1132
1221
  if (index < 0) value = clone(observed);
@@ -1148,13 +1237,13 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1148
1237
  // (a media asset read from a video placeholder) keeps its observed value.
1149
1238
  for (const [id, asset] of Object.entries(storedAssets)) if (!own(imported.assets, id)) group(`assets.${id}`, [set(['assets', id], clone(asset))]);
1150
1239
 
1151
- // Assets and inline catalog records follow what the restored document references.
1240
+ // Assets and embedded catalog records follow what the restored document references.
1152
1241
  const finalize = doc => {
1153
1242
  const needed = [...new Set(assetReferences(doc))].filter(id => !own(doc.assets, id) && Object.hasOwn(storedAssets, id));
1154
1243
  if (needed.length) doc.assets = {...(object(doc.assets) ? doc.assets : {}), ...Object.fromEntries(needed.map(id => [id, clone(storedAssets[id])]))};
1155
1244
  if (object(document.catalogs)) {
1156
1245
  const {catalogs: _ignored, ...rest} = doc;
1157
- const {value, unresolved} = resolveMedia(referencedCatalogs(document.catalogs, collectStrings(rest)), entries);
1246
+ const {value, unresolved} = resolveMedia(referencedCatalogs(document.catalogs, documentReferences(rest)), entries);
1158
1247
  if (value && !unresolved) doc.catalogs = value;
1159
1248
  }
1160
1249
  return intent.finalize(doc);
@@ -1163,74 +1252,79 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
1163
1252
  }
1164
1253
 
1165
1254
  /**
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`.
1255
+ * Layout intent of the tagged slides (FF-29): layout reference, type,
1256
+ * composition and composition hints are restored while a slide's arrangement
1257
+ * is unchanged. Each layout reference resolves to exactly one record, in the
1258
+ * order core resolves references (embedded records before registered ones):
1259
+ * - the record the stored document catalogs embed (OPF_DOCUMENT_V1);
1260
+ * - a slide's own `layoutRecord`, embedded under its group by `finalize` (a
1261
+ * slide whose group the stored document declares with another source cannot
1262
+ * carry its record there). When the reference also resolves in a registered
1263
+ * catalog, a slide's record is used only when every restored slide with that
1264
+ * reference carries the same one, so it never changes another slide's layout;
1265
+ * - a record of the host's registered catalogs (`hostCatalogs`), which the
1266
+ * imported document then references without embedding it.
1175
1267
  * 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.
1268
+ * without the layout reference (`layout-reference-changed`), and a reference
1269
+ * that resolves nowhere is not restored (`unresolved-reference`), so
1270
+ * the imported document always renders.
1180
1271
  */
1181
- function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group, report, rejectedDocumentIds) {
1182
- const bundled = id => array(bundledCatalogs.layouts).find(item => item?.id === id);
1272
+ export function layoutIntent(storedCatalogs, slideRecords, entries, group, report, hostCatalogs) {
1273
+ const stored = {catalogs: object(storedCatalogs) ? storedCatalogs : {}};
1274
+ const declared = name => Object.hasOwn(stored.catalogs, name) ? stored.catalogs[name] : undefined;
1183
1275
  const owns = slideRecords.map(entry => {
1184
1276
  if (!entry) return null;
1185
1277
  const {record, native: observed} = entry;
1186
1278
  const structureMatch = record.native.structure === observed.structure;
1187
1279
  const layout = typeof record.layout === 'string' ? record.layout : undefined;
1280
+ const parsed = layout === undefined ? undefined : parseReference(layout);
1188
1281
  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; }
1282
+ // A record for another reference (another id, or a group the reference does not name) is ignored and reported.
1283
+ const matches = own !== undefined && parsed !== undefined && own.id === parsed.id
1284
+ && (parsed.group !== undefined ? own.group === parsed.group : own.group === 'custom' || own.group === 'default');
1285
+ if (own !== undefined && !matches) { problem = layout === undefined ? undefined : 'mismatch'; own = undefined; }
1191
1286
  else if (own !== undefined) {
1192
- const {value, unresolved} = resolveMedia(own, entries);
1287
+ const {value, unresolved} = resolveMedia(own.record, entries);
1288
+ const target = declared(own.group);
1193
1289
  if (unresolved) { own = undefined; problem = 'media'; }
1194
- else if (!validLayoutRecord(value)) { own = undefined; problem = 'schema'; }
1195
- else own = value;
1290
+ else if (!validLayoutRecord(value, own.id)) { own = undefined; problem = 'schema'; }
1291
+ else if (own.group !== 'custom' && target !== undefined && (target === false || !object(target) || target.source !== own.source)) { own = undefined; problem = 'source'; }
1292
+ else own = {...own, record: value};
1196
1293
  }
1197
1294
  return {structureMatch, layout, own, problem, stated: record.layoutRecord};
1198
1295
  });
1199
1296
  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
- }
1297
+ for (const layout of new Set(owns.filter(item => item?.structureMatch && item.layout !== undefined).map(item => item.layout))) {
1298
+ const members = owns.filter(item => item?.structureMatch && item.layout === layout);
1299
+ const fromDocument = resolveReference(stored, 'layouts', layout, {catalogs: []});
1300
+ const first = members.find(item => item.own);
1301
+ const fromHost = fromDocument ? undefined : resolveReference(stored, 'layouts', layout, {catalogs: Array.isArray(hostCatalogs) ? hostCatalogs : []});
1302
+ const agreed = first && members.every(item => item.own && same(item.own.record, first.own.record));
1303
+ if (fromDocument) resolved.set(layout, {record: fromDocument.record, source: 'document'});
1304
+ else if (first && (!fromHost || agreed)) resolved.set(layout, {record: first.own.record, source: 'slides', add: first.own});
1305
+ else if (fromHost) resolved.set(layout, {record: fromHost.record, source: 'host'});
1212
1306
  }
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'};
1307
+ const against = {document: 'the document', host: 'the registered catalog', slides: 'another slide'};
1215
1308
  // `typeInContent`: the slide stores a content topology, so its `type` is restored with that group (or not at all).
1216
1309
  const slide = (index, {record}, typeInContent = false) => {
1217
1310
  const {structureMatch, layout, own, problem, stated} = owns[index];
1218
1311
  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.`});
1312
+ 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
1313
  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
1314
  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.`});
1315
+ 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
1316
  const target = layout === undefined ? undefined : resolved.get(layout);
1223
1317
  let restoreLayout = structureMatch;
1224
1318
  if (structureMatch && layout !== undefined) {
1225
1319
  if (!target) {
1226
1320
  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)) {
1321
+ 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.`});
1322
+ } else if (own && !same(own.record, target.record)) {
1229
1323
  restoreLayout = false;
1230
1324
  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
1325
  }
1232
1326
  }
1233
- const catalogRecord = target && target.source !== 'bundled' ? target.record : undefined;
1327
+ const catalogRecord = target && target.source !== 'host' ? target.record : undefined;
1234
1328
  const at = (...path) => ['slides', index, ...path];
1235
1329
  for (const key of SLIDE_STRUCTURE) {
1236
1330
  if (record[key] === undefined || (key === 'type' && typeInContent)) continue;
@@ -1246,11 +1340,20 @@ function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group
1246
1340
  }
1247
1341
  return {structureMatch, entry: {layout, structure: structureMatch ? 'match' : 'changed', record: clone(recordValue), ...(catalogRecord !== undefined ? {catalogRecord: clone(catalogRecord)} : {})}};
1248
1342
  };
1343
+ // A record a slide carried is embedded under its group (declared with its source when the document does not declare it)
1344
+ // while a restored slide still references it and the document does not already resolve it.
1249
1345
  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]}};
1346
+ for (const [layout, item] of resolved) {
1347
+ if (!item.add || !array(doc.slides).some(entry => entry?.layout === layout) || resolveReference(doc, 'layouts', layout, {catalogs: []})) continue;
1348
+ const {group: name, id, source} = item.add;
1349
+ const catalogs = object(doc.catalogs) ? {...doc.catalogs} : {};
1350
+ const current = catalogs[name];
1351
+ if (current === false) continue;
1352
+ const holder = object(current) ? {...current} : (name !== 'custom' && source !== undefined ? {source} : {});
1353
+ holder.layouts = {...(object(holder.layouts) ? holder.layouts : {}), [id]: clone(item.record)};
1354
+ catalogs[name] = holder;
1355
+ doc.catalogs = catalogs;
1356
+ }
1254
1357
  return doc;
1255
1358
  };
1256
1359
  return {slide, finalize};