@openpresentation/opf-pptx 0.11.5 → 0.11.7

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.
@@ -1,5 +1,6 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
3
+ import {contentTopology, rebuildContent, validateTopology} from './content-topology.js';
3
4
  // Namespace import: cores before FF-34 do not export resolveSocialProfile.
4
5
  import * as opfCore from '@openpresentation/opf';
5
6
 
@@ -28,6 +29,15 @@ import * as opfCore from '@openpresentation/opf';
28
29
  // kept on the slide as well as in the document's catalogs, so a slide keeps
29
30
  // its layout when OPF_DOCUMENT_V1 is missing or unreadable (for example a
30
31
  // slide pasted into another deck), and a pasted slide brings its own record.
32
+ //
33
+ // Spec-gap closure P1 adds, in `full` mode: the document `filename` and
34
+ // `extensions`, the whole `assets` registry, `design.logo` (BRAND_ASSETS, also
35
+ // per slide), the slide `section` and `extensions`, and the slide's content
36
+ // topology (`content`, src/content-topology.js): nested groups, promoted
37
+ // regions, the root payload form, block ids, block extensions and group
38
+ // composition, with the reference-pixel box of every leaf. A `data:` source
39
+ // that is not an exported media part is stored inline while its field stays
40
+ // under the per-field limit.
31
41
 
32
42
  export const DOCUMENT_TAG = 'OPF_DOCUMENT_V1';
33
43
  export const SLIDE_TAG = 'OPF_SLIDE_V1';
@@ -63,13 +73,61 @@ function authoredSocials(stored, observed, records) {
63
73
  // native PowerPoint counterpart and round-trips from the stored value.
64
74
  export const DESIGN_REFERENCES = Object.freeze(['theme', 'colorScheme', 'fontScheme', 'dimensions', 'background']);
65
75
  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']);
76
+ export const METADATA = Object.freeze(['narrative', 'tone', 'audience', 'purpose', 'language', 'organization', 'speaker', 'takeaway', 'duration', 'tags', 'variables', 'filename', 'extensions']);
77
+ // Brand images have no native gate: they return from the stored value once
78
+ // their media and asset references resolve. The P2 logo picture (`OPF_LOGO_V1`)
79
+ // consumes the drawn logo; `design.logo` itself always returns from here.
80
+ export const BRAND_ASSETS = Object.freeze(['logo']);
67
81
  const STYLE_REFERENCES = ['theme', 'colorScheme', 'fontScheme'];
68
82
  const SLIDE_STRUCTURE = ['layout', 'type', 'composition'];
83
+ // Slide fields with no native counterpart, restored from the stored value.
84
+ const SLIDE_METADATA = ['section', 'extensions'];
85
+ const DESIGN_FIELDS = [...DESIGN_REFERENCES, ...COMPOSITION_HINTS, ...BRAND_ASSETS];
86
+ const SLIDE_DESIGN_FIELDS = [...STYLE_REFERENCES, 'background', ...COMPOSITION_HINTS, ...BRAND_ASSETS];
87
+ // Importers up to 0.11.6 reject a document tag whose `design` or `metadata`
88
+ // holds a key they do not know (the whole tag is then dropped), and a slide tag
89
+ // whose `design` does. Keys added since are written under `supplement`, a
90
+ // top-level container those importers ignore, and merged back on read. Add
91
+ // every new design or metadata key here, never to the legacy sections.
92
+ const DOCUMENT_SUPPLEMENT = Object.freeze({design: BRAND_ASSETS, metadata: Object.freeze(['filename', 'extensions'])});
93
+ const SLIDE_SUPPLEMENT = Object.freeze({design: BRAND_ASSETS});
94
+
95
+ // Storage shape: move the supplement keys out of the legacy sections.
96
+ function toStoredShape(record, spec) {
97
+ const result = {...record};
98
+ const supplement = {};
99
+ for (const [section, keys] of Object.entries(spec)) {
100
+ if (!object(result[section])) continue;
101
+ const kept = {}, moved = {};
102
+ for (const [key, value] of Object.entries(result[section])) (keys.includes(key) ? moved : kept)[key] = value;
103
+ if (Object.keys(moved).length) supplement[section] = moved;
104
+ if (Object.keys(kept).length) result[section] = kept; else delete result[section];
105
+ }
106
+ if (Object.keys(supplement).length) result.supplement = supplement;
107
+ return result;
108
+ }
109
+
110
+ // Read shape: validate the container and merge it back; a legacy section
111
+ // must not carry a supplement key (an old importer would reject the tag).
112
+ function fromStoredShape(value, spec) {
113
+ if (value.supplement === undefined) return value;
114
+ if (!object(value.supplement)) throw Error('Invalid supplement record.');
115
+ const result = {...value};
116
+ delete result.supplement;
117
+ for (const [section, fields] of Object.entries(value.supplement)) {
118
+ const keys = spec[section];
119
+ if (!keys || !object(fields)) throw Error(`Invalid supplement section ${section}.`);
120
+ for (const key of Object.keys(fields)) if (!keys.includes(key)) throw Error(`Unknown supplement field ${section}.${key}.`);
121
+ if (result[section] !== undefined && !object(result[section])) throw Error(`Invalid ${section} record.`);
122
+ result[section] = {...(result[section] ?? {}), ...fields};
123
+ }
124
+ return result;
125
+ }
69
126
 
70
127
  // 53-bit non-cryptographic hash (cyrb53). Change detection only; tags are
71
128
  // writable by anyone who can edit the file, so no authenticity is claimed.
72
129
  const hash = text => hashUnits(text.length, index => text.charCodeAt(index));
130
+ export const hashText = hash;
73
131
  const hashBytes = bytes => hashUnits(bytes.byteLength, index => bytes[index]);
74
132
  function hashUnits(length, unit) {
75
133
  let h1 = 0xdeadbeef, h2 = 0x41c6ce57;
@@ -333,7 +391,7 @@ const assetReferences = value => [...collectStrings(value)].filter(item => item.
333
391
  export function documentProvenance(presentation, {mode = 'full', isCatalogId = () => false, report = () => {}} = {}) {
334
392
  const referencesOnly = mode === 'references-only';
335
393
  const design = {}, metadata = {};
336
- for (const key of [...DESIGN_REFERENCES, ...COMPOSITION_HINTS]) {
394
+ for (const key of DESIGN_FIELDS) {
337
395
  if (!own(presentation.design, key)) continue;
338
396
  if (referencesOnly && carriesSource(presentation.design[key])) continue;
339
397
  design[key] = clone(presentation.design[key]);
@@ -350,7 +408,7 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
350
408
  }
351
409
  const slides = presentation.slides.map((slide, index) => {
352
410
  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]);
411
+ for (const key of [...(referencesOnly ? [] : ['id', ...SLIDE_METADATA]), 'beat', ...SLIDE_STRUCTURE]) if (own(slide, key)) record[key] = clone(slide[key]);
354
412
  const layoutRecord = typeof slide.layout === 'string' ? layoutRecords(presentation.catalogs).find(item => item?.id === slide.layout) : undefined;
355
413
  // The layout record is a catalog record. 'references-only' stores it only
356
414
  // when it names no image, file or URL; neither mode stores the assets it
@@ -358,25 +416,38 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
358
416
  // A record's own $schema URL identifies its format and is not a source.
359
417
  if (layoutRecord && !(referencesOnly && carriesSource({...layoutRecord, $schema: undefined}))) record.layoutRecord = clone(layoutRecord);
360
418
  const slideDesign = {};
361
- for (const key of [...STYLE_REFERENCES, 'background', ...COMPOSITION_HINTS]) {
419
+ for (const key of SLIDE_DESIGN_FIELDS) {
362
420
  if (!own(slide.design, key) || (referencesOnly && carriesSource(slide.design[key]))) continue;
363
421
  slideDesign[key] = clone(slide.design[key]);
364
422
  }
365
423
  if (Object.keys(slideDesign).length) record.design = slideDesign;
366
424
  return record;
367
425
  });
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
426
  const document = {v: 1, slides: slides.length};
371
427
  if (Object.keys(design).length) document.design = design;
372
428
  if (Object.keys(metadata).length) document.metadata = metadata;
373
429
  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])]));
430
+ // 'full' stores the whole registry, so unreferenced assets return as authored;
431
+ // 'references-only' stores no assets at all.
432
+ if (!referencesOnly && object(presentation.assets) && Object.keys(presentation.assets).length) document.assets = clone(presentation.assets);
376
433
  const {catalogs, ...rest} = presentation;
377
434
  const catalogRecords = referencedCatalogs(catalogs, collectStrings(referencesOnly ? stored : rest));
378
435
  if (catalogRecords) document.catalogs = catalogRecords;
379
- return {mode, document, slides, report};
436
+ // Slide content topology is added per slide by the exporter (recordContentTopology).
437
+ const stated = Object.keys(design).length || Object.keys(metadata).length || document.assets !== undefined || slides.some(record => Object.keys(record).length > 2);
438
+ return {mode, document, slides, report, stated: Boolean(stated)};
439
+ }
440
+
441
+ /**
442
+ * Record the content topology of slide `index` from its composed geometry
443
+ * items (`full` mode only). Structures the record cannot hold are reported as
444
+ * `document-provenance-omitted` at `slides.N.content`.
445
+ */
446
+ export function recordContentTopology(provenance, slide, index, items) {
447
+ if (!provenance || provenance.mode !== 'full' || !provenance.slides[index]) return;
448
+ // An unstorable structure is reported with the other omissions (storable) and recorded in the tag.
449
+ const topology = contentTopology(slide, items, index, reason => { provenance.slides[index].omittedContent = reason; });
450
+ if (topology) provenance.slides[index].content = topology;
380
451
  }
381
452
 
382
453
  function base64ToBytes(value) {
@@ -393,10 +464,54 @@ function bytesToBase64(bytes) {
393
464
  }
394
465
 
395
466
  const sameBytes = (a, b) => a.byteLength === b.byteLength && a.every((value, index) => value === b[index]);
467
+ const mediaKey = bytes => `${bytes.byteLength}:${hashBytes(bytes)}`;
468
+
469
+ // PowerPoint renames media parts (image-1-1.png becomes image1.png), renumbers relationships and merges identical
470
+ // images when it saves, so a stored $opfMedia part path can name another picture or none. The record therefore also
471
+ // carries `mediaHash` (a top-level key released importers ignore): the content key of every referenced part. On read a
472
+ // reference whose part no longer has those bytes follows the bytes to wherever the package keeps them now.
473
+ function mediaHashes(value, entries) {
474
+ const hashes = {};
475
+ const visit = item => {
476
+ if (Array.isArray(item)) return item.forEach(visit);
477
+ if (!object(item)) return;
478
+ if (typeof item.$opfMedia === 'string') { if (entries[item.$opfMedia]) hashes[item.$opfMedia] = mediaKey(entries[item.$opfMedia]); return; }
479
+ Object.values(item).forEach(visit);
480
+ };
481
+ visit(value);
482
+ return hashes;
483
+ }
396
484
 
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.
485
+ function remapMedia(value, entries) {
486
+ const hashes = value.mediaHash;
487
+ if (!object(hashes)) return value;
488
+ let byKey;
489
+ const find = key => {
490
+ if (!byKey) {
491
+ byKey = new Map();
492
+ for (const [path, bytes] of Object.entries(entries)) if (/^ppt\/media\/[^/]+$/.test(path)) byKey.set(mediaKey(bytes), byKey.get(mediaKey(bytes)) ?? path);
493
+ }
494
+ return byKey.get(key);
495
+ };
496
+ const visit = item => {
497
+ if (Array.isArray(item)) return item.map(visit);
498
+ if (!object(item)) return item;
499
+ if (typeof item.$opfMedia === 'string') {
500
+ const key = hashes[item.$opfMedia];
501
+ if (typeof key !== 'string' || (entries[item.$opfMedia] && mediaKey(entries[item.$opfMedia]) === key)) return item;
502
+ const found = find(key);
503
+ return found ? {...item, $opfMedia: found} : item;
504
+ }
505
+ return Object.fromEntries(Object.entries(item).map(([name, child]) => [name, visit(child)]));
506
+ };
507
+ const {mediaHash, ...rest} = value;
508
+ return visit(rest);
509
+ }
510
+
511
+ // A data URI whose bytes are an exported media part becomes a reference to
512
+ // that part (no duplication). Any other data: source (an undrawn logo, a
513
+ // speaker photo, an unused asset) stays inline; the per-field size limit then
514
+ // decides whether the field is stored.
400
515
  function mediaExternalizer(entries) {
401
516
  const media = new Map();
402
517
  for (const [path, bytes] of Object.entries(entries)) {
@@ -406,22 +521,20 @@ function mediaExternalizer(entries) {
406
521
  }
407
522
  const find = bytes => (media.get(`${bytes.byteLength}:${hashBytes(bytes)}`) ?? []).find(path => sameBytes(entries[path], bytes));
408
523
  return function externalize(value) {
409
- let missing = false;
410
524
  const visit = item => {
411
525
  if (typeof item === 'string' && /^data:/i.test(item)) {
412
526
  const match = item.match(/^data:([\w.+-]+\/[\w.+-]+)?((?:;[^;,]*)*?)(;base64)?,([\s\S]*)$/i);
413
527
  let bytes = null;
414
528
  try { if (match) bytes = match[3] ? base64ToBytes(match[4]) : enc.encode(decodeURIComponent(match[4])); } catch { bytes = null; }
415
529
  const path = bytes && find(bytes);
416
- if (!path) { missing = true; return item; }
530
+ if (!path) return item;
417
531
  return {$opfMedia: path, prefix: `data:${match[1] || 'application/octet-stream'};base64,`};
418
532
  }
419
533
  if (Array.isArray(item)) return item.map(visit);
420
534
  if (object(item)) return Object.fromEntries(Object.entries(item).map(([key, value]) => [key, visit(value)]));
421
535
  return item;
422
536
  };
423
- const result = visit(value);
424
- return {value: result, missing};
537
+ return {value: visit(value)};
425
538
  };
426
539
  }
427
540
 
@@ -449,8 +562,7 @@ function storable(entries, provenance) {
449
562
  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
563
  const externalize = mediaExternalizer(entries);
451
564
  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; }
565
+ const {value: result} = externalize(value);
454
566
  if (sizeOf(result) > limit) { omit(path, `it is larger than ${limit / 1024} KiB`); return undefined; }
455
567
  return result;
456
568
  };
@@ -480,7 +592,8 @@ function storable(entries, provenance) {
480
592
  }
481
593
  const slides = provenance.slides.map((record, index) => {
482
594
  const result = {v: 1, slide: index};
483
- for (const key of ['id', 'beat', ...SLIDE_STRUCTURE]) {
595
+ if (record.omittedContent) omit(`slides.${index}.content`, record.omittedContent);
596
+ for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content']) {
484
597
  if (record[key] === undefined) continue;
485
598
  const value = prepare(`slides.${index}.${key}`, record[key]);
486
599
  if (value !== undefined) result[key] = value;
@@ -517,7 +630,7 @@ function storable(entries, provenance) {
517
630
  }
518
631
  for (const [index, record] of slides.entries()) {
519
632
  while (tagChars(record) > budget) {
520
- const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
633
+ const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
521
634
  ...Object.keys(record.design ?? {}).map(key => ({key, design: true, size: sizeOf(record.design[key])}))];
522
635
  if (!candidates.length) break;
523
636
  const largest = candidates.sort((a, b) => b.size - a.size)[0];
@@ -539,7 +652,9 @@ function storable(entries, provenance) {
539
652
  * `entries` maps part paths to bytes and is updated in place.
540
653
  */
541
654
  export function attachDocumentProvenance(entries, provenance) {
542
- if (!provenance) return;
655
+ // A document that states nothing (no references, metadata, slide fields or
656
+ // content topology) gets no tags and its bytes are unchanged.
657
+ if (!provenance || !(provenance.stated || provenance.slides.some(record => record.content !== undefined))) return;
543
658
  const presentationXml = dec.decode(entries['ppt/presentation.xml']);
544
659
  const presentationRoot = parser.parse(presentationXml)['p:presentation'];
545
660
  const rels = relationships(entries, 'ppt/presentation.xml');
@@ -547,7 +662,8 @@ export function attachDocumentProvenance(entries, provenance) {
547
662
  if (paths.length !== provenance.slides.length) throw Error('Generated slide count differs from the document.');
548
663
  const prepared = storable(entries, provenance);
549
664
  const types = [];
550
- const document = {...prepared.document, native: nativeDocument(entries, presentationRoot, rels)};
665
+ const documentHashes = mediaHashes(prepared.document, entries);
666
+ const document = toStoredShape({...prepared.document, ...(Object.keys(documentHashes).length ? {mediaHash: documentHashes} : {}), native: nativeDocument(entries, presentationRoot, rels)}, DOCUMENT_SUPPLEMENT);
551
667
 
552
668
  // CT_Presentation: custDataLst follows photoAlbum and precedes kinsoku/defaultTextStyle/modifyVerifier/extLst.
553
669
  const documentPart = 'ppt/tags/opfDocument.xml';
@@ -562,7 +678,8 @@ export function attachDocumentProvenance(entries, provenance) {
562
678
  types.push(documentPart);
563
679
 
564
680
  for (const [index, path] of paths.entries()) {
565
- const record = {...prepared.slides[index], native: nativeSlide(entries, path)};
681
+ const slideHashes = mediaHashes(prepared.slides[index], entries);
682
+ const record = toStoredShape({...prepared.slides[index], ...(Object.keys(slideHashes).length ? {mediaHash: slideHashes} : {}), native: nativeSlide(entries, path)}, SLIDE_SUPPLEMENT);
566
683
  const tag = `<p:tag name="${SLIDE_TAG}" val="${encodeTextTag(record)}"/>`;
567
684
  let xml = dec.decode(entries[path]);
568
685
  const slideRels = relsPath(path);
@@ -611,22 +728,31 @@ function validateOmitted(value) {
611
728
  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
729
  }
613
730
 
614
- function validateDocument(value) {
615
- if (!object(value) || value.v !== 1 || !Number.isSafeInteger(value.slides) || value.slides < 1) throw Error('Unsupported document provenance version.');
731
+ function validateDocument(stored) {
732
+ if (!object(stored) || stored.v !== 1 || !Number.isSafeInteger(stored.slides) || stored.slides < 1) throw Error('Unsupported document provenance version.');
733
+ const value = fromStoredShape(stored, DOCUMENT_SUPPLEMENT);
616
734
  for (const key of ['design', 'metadata', 'catalogs', 'assets']) if (value[key] !== undefined && !object(value[key])) throw Error(`Invalid ${key} record.`);
735
+ for (const [section, keys] of Object.entries(DOCUMENT_SUPPLEMENT)) for (const key of keys) if (object(stored[section]) && stored[section][key] !== undefined) throw Error(`Supplement field ${section}.${key} stored in the legacy section.`);
617
736
  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}.`);
737
+ for (const key of Object.keys(value.design ?? {})) if (!DESIGN_FIELDS.includes(key)) throw Error(`Unknown design field ${key}.`);
619
738
  for (const key of Object.keys(value.metadata ?? {})) if (!METADATA.includes(key)) throw Error(`Unknown metadata field ${key}.`);
739
+ if (value.metadata?.filename !== undefined && typeof value.metadata.filename !== 'string') throw Error('Invalid filename record.');
740
+ if (value.metadata?.extensions !== undefined && !object(value.metadata.extensions)) throw Error('Invalid extensions record.');
620
741
  validateOmitted(value.omitted);
621
742
  return value;
622
743
  }
623
744
 
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}.`);
745
+ function validateSlide(stored) {
746
+ if (!object(stored) || stored.v !== 1 || !Number.isSafeInteger(stored.slide) || stored.slide < 0) throw Error('Unsupported slide provenance version.');
747
+ if (!object(stored.native) || typeof stored.native.structure !== 'string' || typeof stored.native.background !== 'string' || typeof stored.native.style !== 'string') throw Error('Missing slide native evidence.');
748
+ if (stored.design !== undefined && !object(stored.design)) throw Error('Invalid slide design record.');
749
+ 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.`);
750
+ const value = fromStoredShape(stored, SLIDE_SUPPLEMENT);
751
+ for (const key of Object.keys(value.design ?? {})) if (!SLIDE_DESIGN_FIELDS.includes(key)) throw Error(`Unknown slide design field ${key}.`);
629
752
  if (value.layoutRecord !== undefined && (!object(value.layoutRecord) || typeof value.layoutRecord.id !== 'string')) throw Error('Invalid slide layout record.');
753
+ if (value.section !== undefined && typeof value.section !== 'string') throw Error('Invalid slide section record.');
754
+ if (value.extensions !== undefined && !object(value.extensions)) throw Error('Invalid slide extensions record.');
755
+ if (value.content !== undefined) validateTopology(value.content);
630
756
  validateOmitted(value.omitted);
631
757
  return value;
632
758
  }
@@ -676,14 +802,19 @@ function pruneDangling(value, dangling, path, removed) {
676
802
  * is the contract for layout-structure recovery (FF-29): `record` is the
677
803
  * validated OPF_SLIDE_V1 value (layout, type, composition, design hints, ...),
678
804
  * `catalogRecord` the stored inline layouts record for `layout`, if any.
805
+ *
806
+ * `slides[i].contentBounds` are the native bounds (reference px) of the
807
+ * imported blocks of slide i, in block order, for content topology matching.
808
+ * `nativeSections` is the package's native section list as one name (or
809
+ * undefined) per slide, or null when the package has no list.
679
810
  */
680
- export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, socialPlatformRecords}, report) {
811
+ export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, socialPlatformRecords, nativeSections = null}, report) {
681
812
  const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
682
813
  let document;
683
814
  try {
684
815
  const found = readTag(entries, presentationRoot?.['p:custDataLst'], presentationRels, DOCUMENT_TAG);
685
816
  if (found.missing) { if (found.unreadable) invalid('Presentation customer data could not be read.'); }
686
- else document = validateDocument(found.value);
817
+ else document = remapMedia(validateDocument(found.value), entries);
687
818
  } catch (error) { invalid(`${error.message}`); document = undefined; }
688
819
 
689
820
  const rejectedLayoutIds = new Set();
@@ -693,7 +824,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
693
824
  try {
694
825
  const found = readTag(entries, root?.['p:cSld']?.['p:custDataLst'], rels, SLIDE_TAG);
695
826
  if (found.missing) return null;
696
- return {record: validateSlide(found.value), native: nativeSlide(entries, path)};
827
+ return {record: remapMedia(validateSlide(found.value), entries), native: nativeSlide(entries, path)};
697
828
  } catch (error) {
698
829
  report({code: 'invalid-document-provenance', path: `slides.${index}`, message: `${error.message} This slide keeps the values observed in the PPTX.`});
699
830
  return null;
@@ -701,14 +832,82 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
701
832
  });
702
833
  const groups = [];
703
834
  const group = (field, ops) => groups.push({field, ops});
835
+ const set = (path, value) => ({path, value});
836
+ const remove = path => ({path, remove: true});
704
837
  const intent = layoutIntent(document ? layoutRecords(document.catalogs) : [], Boolean(document), slideRecords, entries, group, report, rejectedLayoutIds);
705
838
 
839
+ // Slide and block ids must stay unique across the document; a duplicated
840
+ // slide carries copies of both, and the first occurrence keeps them.
841
+ const seenIds = new Set();
842
+ // Content topology: while the slide's arrangement is unchanged, the imported
843
+ // flat blocks are matched to the stored leaves and the authored form returns
844
+ // (root payload, blocks with nested groups, or promoted regions). The slide
845
+ // `type` joins that group, since it is only valid with the authored form.
846
+ const restoreContent = (index, {record, native: observed}) => {
847
+ if (record.content === undefined) return false;
848
+ // The stored `type` belongs to the authored form: when that form is not restored, neither is the type.
849
+ const typeNote = record.type === undefined ? '' : ` The stored slide type ${JSON.stringify(record.type)} was not restored either.`;
850
+ if (record.native.structure !== observed.structure) {
851
+ report({code: 'slide-reference-changed', path: `slides.${index}.content`, message: `The slide's objects were moved, resized, added or removed since export, so the authored content structure of slides.${index} (groups, regions, block ids) was not restored; the imported slide keeps its flat blocks.`});
852
+ if (record.type !== undefined) report({code: 'slide-reference-changed', path: `slides.${index}.type`, message: `The slide's objects were moved, resized, added or removed since export, so slides.${index}.type ${JSON.stringify(record.type)} was not restored; the imported slide keeps its observed arrangement.`});
853
+ return false;
854
+ }
855
+ const result = rebuildContent(record.content, imported.slides[index]?.blocks ?? [], slides[index]?.contentBounds ?? []);
856
+ if (result.reason) {
857
+ report({code: 'content-structure-changed', path: `slides.${index}`, message: `The slide's content no longer matches its stored structure (${result.reason}), so the authored groups, regions and block ids of slides.${index} were not restored; the imported slide keeps its flat blocks.${typeNote}`});
858
+ return false;
859
+ }
860
+ const dedupe = (value, path) => {
861
+ if (Array.isArray(value)) { value.forEach((item, position) => dedupe(item, `${path}.${position}`)); return; }
862
+ if (!object(value)) return;
863
+ if (typeof value.id === 'string') {
864
+ if (seenIds.has(value.id)) { report({code: 'duplicate-block-id', path: `${path}.id`, message: `Block id '${value.id}' appears more than once (a duplicated slide); the first keeps it.`}); delete value.id; }
865
+ else seenIds.add(value.id);
866
+ }
867
+ if (Array.isArray(value.blocks)) dedupe(value.blocks, `${path}.blocks`);
868
+ };
869
+ const ops = [];
870
+ for (const [key, value] of Object.entries(result.fields)) {
871
+ if (value === null) { ops.push(remove(['slides', index, key])); continue; }
872
+ if (key === 'blocks') dedupe(value, `slides.${index}.blocks`); else if (record.content.form === 'regions') dedupe(value, `slides.${index}.${key}`);
873
+ ops.push(set(['slides', index, key], value));
874
+ }
875
+ if (record.type !== undefined) ops.push(set(['slides', index, 'type'], clone(record.type)));
876
+ group(`slides.${index}.content`, ops);
877
+ return true;
878
+ };
879
+
880
+ // Sections: three sources can name a slide's section. The native section
881
+ // list (PowerPoint's sections pane), the section text a footer shows, and the
882
+ // stored OPF_SLIDE_V1 value. The list is native data and wins, except when
883
+ // the list still equals the stored value and the footer text differs: then
884
+ // the footer was edited and its text is kept, as before. Without a list the
885
+ // stored value is the fallback for a slide whose footer shows none.
886
+ // Labels compare the way the native list stores them: tab, LF and CR are
887
+ // spaces in an XML attribute, and a blank label is no section.
888
+ const blank = value => typeof value !== 'string' || value.trim() === '';
889
+ const normalized = value => blank(value) ? '' : value.replace(/[\t\n\r]/g, ' ');
890
+ const sameSection = (a, b) => normalized(a) === normalized(b);
891
+ const reconcileSection = (index, stored) => {
892
+ const shown = imported.slides[index]?.section;
893
+ if (!nativeSections) {
894
+ if (stored !== undefined && shown === undefined) group(`slides.${index}.section`, [set(['slides', index, 'section'], clone(stored))]);
895
+ return;
896
+ }
897
+ const listed = nativeSections[index];
898
+ if (sameSection(shown, listed) || (stored !== undefined && sameSection(listed, stored) && shown !== undefined)) return;
899
+ if (shown !== undefined) report({code: 'section-reference-changed', path: `slides.${index}.section`, message: `The footer of slides.${index} shows section '${shown}' but PowerPoint's section list ${listed === undefined ? 'has the slide in no named section' : `names '${listed}'`}; the section list wins and the footer keeps its current text.`});
900
+ group(`slides.${index}.section`, [listed === undefined ? remove(['slides', index, 'section']) : set(['slides', index, 'section'], listed)]);
901
+ };
902
+
706
903
  // Without document provenance (a slide pasted into another deck, or a
707
904
  // 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.
905
+ // its layout intent and content topology, and the native section list
906
+ // applies; deck references, metadata, slide ids, stored sections and
907
+ // extensions need the document record and are not restored.
710
908
  if (!document) {
711
- const layouts = imported.slides.map((_, index) => slideRecords[index] ? intent.slide(index, slideRecords[index]).entry : {structure: 'untagged'});
909
+ const layouts = imported.slides.map((_, index) => slideRecords[index] ? intent.slide(index, slideRecords[index], (restoreContent(index, slideRecords[index]), slideRecords[index].record.content !== undefined)).entry : {structure: 'untagged'});
910
+ if (nativeSections) imported.slides.forEach((_, index) => reconcileSection(index, undefined));
712
911
  return {groups, slides: layouts, finalize: doc => intent.finalize(doc)};
713
912
  }
714
913
 
@@ -732,8 +931,6 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
732
931
  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
932
  return pruned;
734
933
  };
735
- const set = (path, value) => ({path, value});
736
- const remove = path => ({path, remove: true});
737
934
 
738
935
  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
936
  for (const path of array(document.omitted)) notStored(path);
@@ -762,26 +959,38 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
762
959
  group(`design.${key}`, [set(['design', key], value)]);
763
960
  }
764
961
  const styleIntact = STYLE_REFERENCES.every(key => restoredStyle[key] !== false);
962
+ // Brand images have no native gate (the P2 logo picture is consumed separately).
963
+ for (const key of BRAND_ASSETS) {
964
+ if (storedDesign[key] === undefined) continue;
965
+ const value = restorable(`design.${key}`, storedDesign[key]);
966
+ if (value !== undefined) group(`design.${key}`, [set(['design', key], value)]);
967
+ }
765
968
 
766
969
  // Slide-level references.
767
970
  const layouts = [];
768
- const seenIds = new Set();
769
971
  let allStructure = slideRecords.length === document.slides && slideRecords.every(Boolean);
770
972
  const backgroundInheritors = [];
771
973
  for (const [index, slide] of imported.slides.entries()) {
772
974
  const entry = slideRecords[index];
773
- if (!entry) { layouts.push({structure: 'untagged'}); allStructure = false; continue; }
975
+ if (!entry) { layouts.push({structure: 'untagged'}); allStructure = false; reconcileSection(index, undefined); continue; }
774
976
  const {record, native: observed} = entry;
775
- const {entry: layoutEntry, structureMatch} = intent.slide(index, entry);
776
- if (!structureMatch) allStructure = false;
777
- layouts.push(layoutEntry);
778
977
  const at = (...path) => ['slides', index, ...path];
779
978
  if (record.id !== undefined) {
780
979
  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
980
  else { seenIds.add(record.id); group(`slides.${index}.id`, [set(at('id'), clone(record.id))]); }
782
981
  }
982
+ const {entry: layoutEntry, structureMatch} = intent.slide(index, entry, (restoreContent(index, entry), entry.record.content !== undefined));
983
+ if (!structureMatch) allStructure = false;
984
+ layouts.push(layoutEntry);
783
985
  if (record.beat !== undefined) group(`slides.${index}.beat`, [set(at('beat'), clone(record.beat))]);
986
+ reconcileSection(index, record.section);
987
+ if (record.extensions !== undefined) group(`slides.${index}.extensions`, [set(at('extensions'), clone(record.extensions))]);
784
988
  const recordDesign = record.design ?? {};
989
+ for (const key of BRAND_ASSETS) {
990
+ if (recordDesign[key] === undefined) continue;
991
+ const value = restorable(`slides.${index}.design.${key}`, recordDesign[key]);
992
+ if (value !== undefined) group(`slides.${index}.design.${key}`, [set(at('design', key), value)]);
993
+ }
785
994
  const styleMatch = record.native.style === observed.style;
786
995
  for (const key of STYLE_REFERENCES) {
787
996
  if (recordDesign[key] === undefined) continue;
@@ -850,6 +1059,10 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
850
1059
  group(key, [set([key], value)]);
851
1060
  }
852
1061
 
1062
+ // The whole stored asset registry returns; an id the import already provides
1063
+ // (a media asset read from a video placeholder) keeps its observed value.
1064
+ for (const [id, asset] of Object.entries(storedAssets)) if (!own(imported.assets, id)) group(`assets.${id}`, [set(['assets', id], clone(asset))]);
1065
+
853
1066
  // Assets and inline catalog records follow what the restored document references.
854
1067
  const finalize = doc => {
855
1068
  const needed = [...new Set(assetReferences(doc))].filter(id => !own(doc.assets, id) && Object.hasOwn(storedAssets, id));
@@ -914,7 +1127,8 @@ function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group
914
1127
  }
915
1128
  const chosen = new Map([...resolved].filter(([, item]) => item.add).map(([id, item]) => [id, item.record]));
916
1129
  const against = {document: 'the document', bundled: 'the built-in layout', slides: 'another slide'};
917
- const slide = (index, {record}) => {
1130
+ // `typeInContent`: the slide stores a content topology, so its `type` is restored with that group (or not at all).
1131
+ const slide = (index, {record}, typeInContent = false) => {
918
1132
  const {structureMatch, layout, own, problem, stated} = owns[index];
919
1133
  const {native: _native, ...recordValue} = record;
920
1134
  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.`});
@@ -934,7 +1148,7 @@ function layoutIntent(documentRecords, hasDocument, slideRecords, entries, group
934
1148
  const catalogRecord = target && target.source !== 'bundled' ? target.record : undefined;
935
1149
  const at = (...path) => ['slides', index, ...path];
936
1150
  for (const key of SLIDE_STRUCTURE) {
937
- if (record[key] === undefined) continue;
1151
+ if (record[key] === undefined || (key === 'type' && typeInContent)) continue;
938
1152
  if (!structureMatch) report({code: key === 'layout' ? 'layout-reference-changed' : 'slide-reference-changed', path: `slides.${index}.${key}`,
939
1153
  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
1154
  else if (key !== 'layout' || restoreLayout) group(`slides.${index}.${key}`, [{path: at(key), value: clone(record[key])}]);
@@ -3,6 +3,7 @@ import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js
3
3
  import {sourceLineParagraphs} from './text-provenance.js';
4
4
  import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatSlideNumber, parseDate} from './furniture-fields.js';
5
5
  import {schemas} from '@openpresentation/opf';
6
+ import {LOGO_TAG} from './logo-provenance.js';
6
7
 
7
8
  const TAG = 'OPF_FURNITURE_V1';
8
9
  // A slide has one tag list; it also carries the document's OPF_SLIDE_V1 record.
@@ -93,16 +94,32 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
93
94
  if (!Object.keys(definitions).length) return null;
94
95
  const organizations = array(presentation.organization);
95
96
  const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
97
+ // Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
98
+ // definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
99
+ // so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
100
+ const logos = [];
101
+ for (const kind of kinds) {
102
+ const source = (slide.design?.[kind] !== undefined ? slide.design : presentation.design)?.[kind];
103
+ for (const zone of zones) if (definitions[kind]?.value?.[zone] && source?.[zone]?.logo === true) {
104
+ logos.push({kind, zone, drawn: layout.parts.some(part => part.field === 'logo' && part.kind === kind && part.zone === zone)});
105
+ }
106
+ }
96
107
  return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
97
108
  ...(layout.parts.some(part => part.field === 'organization' || part.field === 'socials') ? {organizationId: organization?.id} : {}),
98
- parts: layout.parts.map((part, index) => ({kind: part.kind, zone: part.zone, field: part.field,
109
+ parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
99
110
  type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
100
111
  ...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
101
- ...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {})}))};
112
+ ...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {})}]),
113
+ ...(logos.length ? {logos} : {})};
102
114
  }
103
115
 
104
- export function attachFurnitureTags(entries, records, manifests) {
116
+ // The manifest index of a layout part: logo parts are not in the manifest, so later parts shift down.
117
+ export const manifestPartIndex = (parts, index) => parts.slice(0, index).filter(part => part.field !== 'logo').length;
118
+
119
+ export function attachFurnitureTags(entries, records, manifests, logoRecords = new Map()) {
105
120
  attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
121
+ // A generated logo picture carries the logo tag (never the furniture tag): see logo-provenance.js.
122
+ attachTextTags(entries, logoRecords, LOGO_TAG, 'opfFurnitureLogo', 'furniture logo', {pictures: true});
106
123
  const types = [];
107
124
  for (const [path, manifest] of manifests) {
108
125
  const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
@@ -158,6 +175,16 @@ function validateManifest(manifest) {
158
175
  }
159
176
  }
160
177
  }
178
+ if (manifest.logos !== undefined) {
179
+ check(Array.isArray(manifest.logos) && manifest.logos.length <= 6, 'Invalid furniture logos.');
180
+ const seen = new Set();
181
+ for (const logo of manifest.logos) {
182
+ const slot = `${logo?.kind}.${logo?.zone}`;
183
+ check(object(logo) && kinds.includes(logo.kind) && zones.includes(logo.zone) && typeof logo.drawn === 'boolean' && Object.keys(logo).length === 3 &&
184
+ object(manifest.definitions[logo.kind]?.value?.[logo.zone]) && !seen.has(slot), 'Invalid furniture logo.');
185
+ seen.add(slot);
186
+ }
187
+ }
161
188
  if (manifest.formats !== undefined) {
162
189
  check(object(manifest.formats), 'Invalid furniture formats.');
163
190
  for (const [slot, format] of Object.entries(manifest.formats)) {
@@ -179,6 +206,17 @@ function validateManifest(manifest) {
179
206
  if (manifest.parts.some(part => part.field === 'organization' || part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
180
207
  }
181
208
 
209
+ // The logo tags of a picture: any other OPF tag next to one is not an identity conflict for the furniture reader.
210
+ function readLogoTags(container, relationships, entries) {
211
+ const found = [];
212
+ for (const link of array(container?.['p:tags'])) {
213
+ const rel = relationships.get(link['r:id']);
214
+ if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) continue;
215
+ found.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag => tag.name?.toUpperCase() === LOGO_TAG));
216
+ }
217
+ return found;
218
+ }
219
+
182
220
  function readSlide(context, entries, slideIndex, slideCount, report, taggedText) {
183
221
  const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
184
222
  const candidates = {};
@@ -194,8 +232,16 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
194
232
  } catch { invalidRecord = true; }
195
233
  }
196
234
  }
235
+ // Generated logo pictures: identity only, the bytes come from design.logo.
236
+ const logoPictures = [];
237
+ for (const [index, node] of pictures.entries()) try {
238
+ for (const tag of readLogoTags(node['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries)) {
239
+ const data = decodeTextTag(tag.val);
240
+ if (data?.role === 'furniture') logoPictures.push({index, data});
241
+ }
242
+ } catch { invalidRecord = true; }
197
243
  const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
198
- if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
244
+ if (!tags.tags.length && !records.length && !invalidRecord && !logoPictures.length) return candidates;
199
245
  let manifest;
200
246
  try {
201
247
  check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
@@ -215,6 +261,13 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
215
261
  value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
216
262
  }
217
263
  const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], socials: [], sections: []};
264
+ // logo: true returns as its flag; the generated picture is consumed, never content or an image field.
265
+ for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
266
+ const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
267
+ check(found.length <= 1, 'Duplicated furniture logo.');
268
+ if (found.length) { candidate.pictures.push(found[0].index); value[logo.zone].logo = true; }
269
+ else if (!logo.drawn) value[logo.zone].logo = true;
270
+ }
218
271
  for (const [partIndex, part] of manifest.parts.entries()) {
219
272
  if (part.kind !== kind) continue;
220
273
  const group = records.filter(record => record.data.part === partIndex);