@openpresentation/opf-pptx 0.11.6 → 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.
- package/README.md +17 -3
- package/dist/background.js +15 -13
- package/dist/chartex.js +9 -9
- package/dist/code-provenance.js +1 -1
- package/dist/content-topology.js +295 -0
- package/dist/document-provenance.js +260 -46
- package/dist/furniture-provenance.js +57 -4
- package/dist/index.d.ts +12 -6
- package/dist/index.js +362 -52
- package/dist/logo-provenance.js +173 -0
- package/dist/sections.js +111 -0
- package/dist/watermark-provenance.js +2 -1
- package/package.json +7 -6
|
@@ -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
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
615
|
-
if (!object(
|
|
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 (!
|
|
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(
|
|
625
|
-
if (!object(
|
|
626
|
-
if (!object(
|
|
627
|
-
if (
|
|
628
|
-
for (const key of
|
|
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
|
|
709
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|