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