@openpresentation/opf-pptx 0.11.8 → 0.12.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 +69 -9
- package/dist/annotation-export.js +83 -0
- package/dist/annotation-provenance.js +246 -0
- package/dist/body-text-import.js +4 -1
- package/dist/chart-options.js +276 -0
- package/dist/chartex.js +42 -11
- package/dist/code-provenance.js +34 -10
- package/dist/content-topology.js +280 -53
- package/dist/document-provenance.js +83 -10
- package/dist/furniture-provenance.js +67 -3
- package/dist/image-fallback-browser.js +12 -0
- package/dist/image-fallback-node.js +22 -0
- package/dist/import-signals.js +809 -0
- package/dist/index.d.ts +302 -4
- package/dist/index.js +486 -102
- package/dist/logo-provenance.js +8 -6
- package/dist/metric-provenance.js +6 -3
- package/dist/native-furniture.js +297 -0
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +90 -14
- package/dist/slide-image-provenance.js +6 -4
- package/dist/svg-image.js +354 -0
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/dist/watermark-provenance.js +4 -3
- package/package.json +7 -7
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks} from './content-topology.js';
|
|
4
|
+
import {runColorRecord, validateRunColors} from './run-colors.js';
|
|
4
5
|
// Namespace import: cores before FF-34 do not export resolveSocialProfile.
|
|
5
6
|
import * as opfCore from '@openpresentation/opf';
|
|
6
7
|
|
|
@@ -45,6 +46,7 @@ const REL_TAGS = 'http://schemas.openxmlformats.org/officeDocument/2006/relation
|
|
|
45
46
|
const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main';
|
|
46
47
|
const TAGS_TYPE = 'application/vnd.openxmlformats-officedocument.presentationml.tags+xml';
|
|
47
48
|
const MAX_OMITTED = 256;
|
|
49
|
+
const MAX_AUTHOR_LENGTH = 4096;
|
|
48
50
|
const MAX_FIELD_BYTES = 256 * 1024, MAX_CATALOG_BYTES = 1024 * 1024, MAX_TAG_CHARS = 16 * 1024 * 1024;
|
|
49
51
|
|
|
50
52
|
const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
|
|
@@ -73,7 +75,26 @@ function authoredSocials(stored, observed, records) {
|
|
|
73
75
|
// native PowerPoint counterpart and round-trips from the stored value.
|
|
74
76
|
export const DESIGN_REFERENCES = Object.freeze(['theme', 'colorScheme', 'fontScheme', 'dimensions', 'background']);
|
|
75
77
|
export const COMPOSITION_HINTS = Object.freeze(['titleAlignment', 'contentAlignment', 'contentBox', 'contentDirection', 'chartPrimary', 'imageFill', 'listBullet']);
|
|
76
|
-
|
|
78
|
+
// `references` (RR-34): the deck's cited sources. Like `author`, stored as a top-level key of OPF_DOCUMENT_V1 (importers up to
|
|
79
|
+
// 0.11.9 drop a tag with an unknown `supplement` field but ignore an unknown top-level key) and read back into metadata.
|
|
80
|
+
export const METADATA = Object.freeze(['narrative', 'tone', 'audience', 'purpose', 'language', 'organization', 'speaker', 'takeaway', 'duration', 'tags', 'variables', 'filename', 'extensions', 'author', 'references']);
|
|
81
|
+
|
|
82
|
+
// `author` is native (docProps/core.xml dc:creator), where several authors share one field joined by "; " (the join the
|
|
83
|
+
// schema documents). Import splits a creator on exactly that separator, and only when every part is a non-empty name
|
|
84
|
+
// with no outer whitespace (so the split joins back to the identical text); a single name is never cut. The authored form (an array, or a string that itself holds a "; ") returns from the stored record
|
|
85
|
+
// while the creator still equals it; plain PPTX has no record and gets the split. The record is the top-level `author`
|
|
86
|
+
// of OPF_DOCUMENT_V1: importers up to 0.11.8 reject an unknown key inside `supplement` (dropping the whole tag) but ignore
|
|
87
|
+
// an unknown top-level key.
|
|
88
|
+
export const joinAuthors = author => Array.isArray(author) ? author.join('; ') : author;
|
|
89
|
+
export function splitAuthors(creator) {
|
|
90
|
+
if (typeof creator !== 'string') return creator;
|
|
91
|
+
const parts = creator.split('; ');
|
|
92
|
+
return parts.length > 1 && parts.every(part => part && part === part.trim()) ? parts : creator;
|
|
93
|
+
}
|
|
94
|
+
// The creator the package gets when the document names no author. An imported default is not an authored value.
|
|
95
|
+
export const DEFAULT_AUTHOR = 'OpenPresentation';
|
|
96
|
+
// Stored only when the native creator cannot carry the authored form by itself (or would read as the default).
|
|
97
|
+
const authorNeedsRecord = author => Array.isArray(author) || (typeof author === 'string' && (author === DEFAULT_AUTHOR || Array.isArray(splitAuthors(author))));
|
|
77
98
|
// Brand images have no native gate: they return from the stored value once
|
|
78
99
|
// their media and asset references resolve. The P2 logo picture (`OPF_LOGO_V1`)
|
|
79
100
|
// consumes the drawn logo; `design.logo` itself always returns from here.
|
|
@@ -399,6 +420,7 @@ export function documentProvenance(presentation, {mode = 'full', isCatalogId = (
|
|
|
399
420
|
for (const key of METADATA) {
|
|
400
421
|
if (!own(presentation, key)) continue;
|
|
401
422
|
const value = presentation[key];
|
|
423
|
+
if (key === 'author' && (referencesOnly || !authorNeedsRecord(value))) continue;
|
|
402
424
|
if (referencesOnly) {
|
|
403
425
|
const kind = METADATA_CATALOGS[key];
|
|
404
426
|
const ids = typeof value === 'string' ? [value] : key === 'audience' && Array.isArray(value) && value.every(item => typeof item === 'string') ? value : null;
|
|
@@ -447,7 +469,16 @@ export function recordContentTopology(provenance, slide, index, items) {
|
|
|
447
469
|
if (!provenance || provenance.mode !== 'full' || !provenance.slides[index]) return;
|
|
448
470
|
// An unstorable structure is reported with the other omissions (storable) and recorded in the tag.
|
|
449
471
|
const topology = contentTopology(slide, items, index, reason => { provenance.slides[index].omittedContent = reason; });
|
|
450
|
-
if (topology)
|
|
472
|
+
if (!topology) return;
|
|
473
|
+
provenance.slides[index].content = topology;
|
|
474
|
+
// Variable, scheme-slot and role names of text runs (the native colour holds only what they resolve to).
|
|
475
|
+
const colors = runColorRecord(slide);
|
|
476
|
+
if (colors) provenance.slides[index].colors = colors;
|
|
477
|
+
// Whitespace of the hard line breaks inside list items (their native lines carry no tag).
|
|
478
|
+
const omittedLists = [];
|
|
479
|
+
const lists = listLineBreaks(items, (path, reason) => omittedLists.push([path, reason]));
|
|
480
|
+
if (lists) provenance.slides[index].lists = lists;
|
|
481
|
+
if (omittedLists.length) provenance.slides[index].omittedLists = omittedLists;
|
|
451
482
|
}
|
|
452
483
|
|
|
453
484
|
function base64ToBytes(value) {
|
|
@@ -578,6 +609,11 @@ function storable(entries, provenance) {
|
|
|
578
609
|
const fields = {};
|
|
579
610
|
for (const [key, value] of Object.entries(source[section] ?? {})) {
|
|
580
611
|
const path = section === 'design' ? `design.${key}` : key;
|
|
612
|
+
if (section === 'metadata' && (key === 'author' || key === 'references')) {
|
|
613
|
+
const prepared = prepare(path, value);
|
|
614
|
+
if (prepared !== undefined) document[key] = prepared;
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
581
617
|
// A design reference is only restorable together with its asset.
|
|
582
618
|
if (section === 'design' && unstoredAsset(value)) { omit(path, 'the asset it references could not be stored'); continue; }
|
|
583
619
|
const prepared = prepare(path, value);
|
|
@@ -593,7 +629,8 @@ function storable(entries, provenance) {
|
|
|
593
629
|
const slides = provenance.slides.map((record, index) => {
|
|
594
630
|
const result = {v: 1, slide: index};
|
|
595
631
|
if (record.omittedContent) omit(`slides.${index}.content`, record.omittedContent);
|
|
596
|
-
for (const
|
|
632
|
+
for (const [path, reason] of record.omittedLists ?? []) omit(path, reason);
|
|
633
|
+
for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists']) {
|
|
597
634
|
if (record[key] === undefined) continue;
|
|
598
635
|
const value = prepare(`slides.${index}.${key}`, record[key]);
|
|
599
636
|
if (value !== undefined) result[key] = value;
|
|
@@ -630,7 +667,7 @@ function storable(entries, provenance) {
|
|
|
630
667
|
}
|
|
631
668
|
for (const [index, record] of slides.entries()) {
|
|
632
669
|
while (tagChars(record) > budget) {
|
|
633
|
-
const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
|
|
670
|
+
const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
|
|
634
671
|
...Object.keys(record.design ?? {}).map(key => ({key, design: true, size: sizeOf(record.design[key])}))];
|
|
635
672
|
if (!candidates.length) break;
|
|
636
673
|
const largest = candidates.sort((a, b) => b.size - a.size)[0];
|
|
@@ -738,7 +775,15 @@ function validateDocument(stored) {
|
|
|
738
775
|
for (const key of Object.keys(value.metadata ?? {})) if (!METADATA.includes(key)) throw Error(`Unknown metadata field ${key}.`);
|
|
739
776
|
if (value.metadata?.filename !== undefined && typeof value.metadata.filename !== 'string') throw Error('Invalid filename record.');
|
|
740
777
|
if (value.metadata?.extensions !== undefined && !object(value.metadata.extensions)) throw Error('Invalid extensions record.');
|
|
778
|
+
const author = stored.author;
|
|
779
|
+
if (author !== undefined && !((typeof author === 'string' && author.length <= MAX_AUTHOR_LENGTH * 256) || (Array.isArray(author) && author.length > 0 && author.length <= 256 && author.every(name => typeof name === 'string' && name.length <= MAX_AUTHOR_LENGTH)))) throw Error('Invalid author record.');
|
|
780
|
+
const references = stored.references;
|
|
781
|
+
if (references !== undefined && (!Array.isArray(references) || references.length > 4096 || !references.every(object))) throw Error('Invalid references record.');
|
|
741
782
|
validateOmitted(value.omitted);
|
|
783
|
+
if (author !== undefined || references !== undefined) {
|
|
784
|
+
const {author: _author, references: _references, ...rest} = value;
|
|
785
|
+
return {...rest, metadata: {...rest.metadata, ...(author !== undefined ? {author} : {}), ...(references !== undefined ? {references} : {})}};
|
|
786
|
+
}
|
|
742
787
|
return value;
|
|
743
788
|
}
|
|
744
789
|
|
|
@@ -753,6 +798,8 @@ function validateSlide(stored) {
|
|
|
753
798
|
if (value.section !== undefined && typeof value.section !== 'string') throw Error('Invalid slide section record.');
|
|
754
799
|
if (value.extensions !== undefined && !object(value.extensions)) throw Error('Invalid slide extensions record.');
|
|
755
800
|
if (value.content !== undefined) validateTopology(value.content);
|
|
801
|
+
if (value.colors !== undefined) validateRunColors(value.colors);
|
|
802
|
+
if (value.lists !== undefined) validateListBreaks(value.lists);
|
|
756
803
|
validateOmitted(value.omitted);
|
|
757
804
|
return value;
|
|
758
805
|
}
|
|
@@ -792,6 +839,23 @@ function pruneDangling(value, dangling, path, removed) {
|
|
|
792
839
|
return Object.fromEntries(Object.entries(value).flatMap(([key, item]) => isDangling(item) ? (removed.push(`${path}.${key}`), []) : [[key, pruneDangling(item, dangling, `${path}.${key}`, removed)]]));
|
|
793
840
|
}
|
|
794
841
|
|
|
842
|
+
/**
|
|
843
|
+
* The hard line breaks stored for the lists of one slide: Map(list path -> Map(line number -> gap)), or null when the slide
|
|
844
|
+
* carries none, its record is unreadable, or its arrangement changed since export (the lines are then no longer the exported
|
|
845
|
+
* ones). Read before the slide's shapes are merged into list blocks; any damage is reported later by restoreDocumentProvenance.
|
|
846
|
+
*/
|
|
847
|
+
export function slideListBreaks(entries, path, root, rels) {
|
|
848
|
+
try {
|
|
849
|
+
const found = readTag(entries, root?.['p:cSld']?.['p:custDataLst'], rels, SLIDE_TAG);
|
|
850
|
+
if (found.missing) return null;
|
|
851
|
+
const record = validateSlide(found.value);
|
|
852
|
+
if (record.lists === undefined || record.native.structure !== nativeSlide(entries, path).structure) return null;
|
|
853
|
+
return new Map(record.lists.map(([list, lines]) => [list, new Map(lines)]));
|
|
854
|
+
} catch {
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
795
859
|
/**
|
|
796
860
|
* Read OPF_DOCUMENT_V1 / OPF_SLIDE_V1 and decide what still matches the
|
|
797
861
|
* package. Nothing is modified here: the result lists restore groups (one per
|
|
@@ -831,7 +895,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
831
895
|
}
|
|
832
896
|
});
|
|
833
897
|
const groups = [];
|
|
834
|
-
const group = (field, ops) => groups.push({field, ops});
|
|
898
|
+
const group = (field, ops, contentPaths) => groups.push({field, ops, ...(contentPaths ? {contentPaths} : {})});
|
|
835
899
|
const set = (path, value) => ({path, value});
|
|
836
900
|
const remove = path => ({path, remove: true});
|
|
837
901
|
const intent = layoutIntent(document ? layoutRecords(document.catalogs) : [], Boolean(document), slideRecords, entries, group, report, rejectedLayoutIds);
|
|
@@ -852,7 +916,8 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
852
916
|
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
917
|
return false;
|
|
854
918
|
}
|
|
855
|
-
|
|
919
|
+
let placedPaths;
|
|
920
|
+
const result = rebuildContent(record.content, imported.slides[index]?.blocks ?? [], slides[index]?.contentBounds ?? [], paths => { placedPaths = paths; });
|
|
856
921
|
if (result.reason) {
|
|
857
922
|
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
923
|
return false;
|
|
@@ -873,7 +938,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
873
938
|
ops.push(set(['slides', index, key], value));
|
|
874
939
|
}
|
|
875
940
|
if (record.type !== undefined) ops.push(set(['slides', index, 'type'], clone(record.type)));
|
|
876
|
-
group(`slides.${index}.content`, ops);
|
|
941
|
+
group(`slides.${index}.content`, ops, {slide: index, paths: placedPaths});
|
|
877
942
|
return true;
|
|
878
943
|
};
|
|
879
944
|
|
|
@@ -1036,12 +1101,20 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
1036
1101
|
// content (the furniture organization name, linked socials) win over their
|
|
1037
1102
|
// stored values; stored-only fields (logo, role ...) return.
|
|
1038
1103
|
const metadata = document.metadata ?? {};
|
|
1104
|
+
// The exporter writes the default creator when no author was authored: with the document record present, that default is not the author.
|
|
1105
|
+
if (metadata.author === undefined && imported.author === DEFAULT_AUTHOR) group('author', [remove(['author'])]);
|
|
1039
1106
|
for (const key of METADATA) {
|
|
1040
1107
|
if (metadata[key] === undefined) continue;
|
|
1041
1108
|
if (key === 'organization' && organizationConflict) {
|
|
1042
1109
|
report({code: 'metadata-reference-changed', path: 'organization', message: 'Slides now show different organization names in their furniture, so the stored organization was not restored; each slide keeps its visible text.'});
|
|
1043
1110
|
continue;
|
|
1044
1111
|
}
|
|
1112
|
+
if (key === 'author') {
|
|
1113
|
+
// Native evidence: the stored authored form only stands for the creator it was joined into.
|
|
1114
|
+
if (joinAuthors(imported.author) === joinAuthors(metadata.author)) group('author', [set(['author'], clone(metadata.author))]);
|
|
1115
|
+
else report({code: 'metadata-reference-changed', path: 'author', message: 'The PowerPoint author field no longer matches the stored author, so the stored author was not restored; the current field is kept.'});
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1045
1118
|
let value = restorable(key, metadata[key], {prune: true});
|
|
1046
1119
|
if (value === undefined) continue;
|
|
1047
1120
|
if (key === 'organization' && object(imported.organization)) {
|
|
@@ -1199,10 +1272,10 @@ export function applyDocumentProvenance(imported, {groups, finalize}, validate,
|
|
|
1199
1272
|
return tidy(doc);
|
|
1200
1273
|
};
|
|
1201
1274
|
const all = finalize(build(groups));
|
|
1202
|
-
if (validate(all).valid) return all;
|
|
1275
|
+
if (validate(all).valid) { for (const item of groups) item.applied = true; return all; }
|
|
1203
1276
|
const accepted = [];
|
|
1204
1277
|
for (const item of groups) {
|
|
1205
|
-
if (validate(build([...accepted, item])).valid) accepted.push(item);
|
|
1278
|
+
if (validate(build([...accepted, item])).valid) { accepted.push(item); item.applied = true; }
|
|
1206
1279
|
else report({code: 'invalid-document-provenance', path: item.field, message: `The stored ${item.field} does not form a valid document with the imported content, so it was not restored; the imported document keeps the values observed in the PPTX.`});
|
|
1207
1280
|
}
|
|
1208
1281
|
const result = finalize(build(accepted));
|
|
@@ -4,6 +4,7 @@ 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
6
|
import {LOGO_TAG} from './logo-provenance.js';
|
|
7
|
+
import {NATIVE_PLACEHOLDERS, footerValue, isNativePlaceholderType, readNativePlaceholders} from './native-furniture.js';
|
|
7
8
|
|
|
8
9
|
const TAG = 'OPF_FURNITURE_V1';
|
|
9
10
|
// A slide has one tag list; it also carries the document's OPF_SLIDE_V1 record.
|
|
@@ -26,7 +27,9 @@ const socialLines = part => part.links.map(link => ({platform: link.platform,
|
|
|
26
27
|
scheme: link.href && !/^[a-z][a-z0-9+.-]*:/i.test(link.text) ? 'https://' : ''}));
|
|
27
28
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
28
29
|
const key = part => `${part.kind}.${part.zone}.${part.field}`;
|
|
29
|
-
|
|
30
|
+
// Key order is not meaning: a value merged from native placeholders equals the same value read from a manifest.
|
|
31
|
+
const canonical = value => JSON.stringify(value, (key, item) => object(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
|
|
32
|
+
const same = (a, b) => canonical(a) === canonical(b);
|
|
30
33
|
const check = (condition, message) => { if (!condition) throw Error(message); };
|
|
31
34
|
|
|
32
35
|
// Change detection, not authentication: editable tags store no cached date words.
|
|
@@ -73,7 +76,7 @@ function unchangedStaticDate(marker, format, ordered, paragraphs) {
|
|
|
73
76
|
// settings only. Current native shapes supply all words, image bytes and alt
|
|
74
77
|
// text, even after edits; a recorded format is kept only while the current text
|
|
75
78
|
// still matches it exactly.
|
|
76
|
-
export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map()) {
|
|
79
|
+
export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map()) {
|
|
77
80
|
const definitions = {}, formats = {};
|
|
78
81
|
for (const kind of kinds) {
|
|
79
82
|
const local = slide.design?.[kind] !== undefined;
|
|
@@ -109,7 +112,9 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
109
112
|
parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
|
|
110
113
|
type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
|
|
111
114
|
...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
|
|
112
|
-
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {})
|
|
115
|
+
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
|
|
116
|
+
// RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
|
|
117
|
+
...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
|
|
113
118
|
...(logos.length ? {logos} : {})};
|
|
114
119
|
}
|
|
115
120
|
|
|
@@ -197,6 +202,9 @@ function validateManifest(manifest) {
|
|
|
197
202
|
for (const part of manifest.parts) {
|
|
198
203
|
check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
|
|
199
204
|
check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
|
|
205
|
+
// RR-11: a footer part that is a native PowerPoint placeholder (dt, ftr, sldNum), unique per slide.
|
|
206
|
+
if (part.ph !== undefined) check(isNativePlaceholderType(part.ph) && part.kind === 'footer' && part.type === 'text' && part.count === 1 &&
|
|
207
|
+
NATIVE_PLACEHOLDERS[part.ph].field === part.field && manifest.parts.filter(other => other.ph === part.ph).length === 1, 'Invalid native placeholder part.');
|
|
200
208
|
if (part.field === 'socials') check(Array.isArray(part.socials) && part.socials.length >= 1 && part.socials.length <= 100
|
|
201
209
|
&& part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
|
|
202
210
|
&& new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
|
|
@@ -257,6 +265,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
257
265
|
if (!definition) continue;
|
|
258
266
|
try {
|
|
259
267
|
const value = definition.value === false ? false : {};
|
|
268
|
+
let removedNative = false;
|
|
260
269
|
if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
|
|
261
270
|
value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
|
|
262
271
|
}
|
|
@@ -271,6 +280,8 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
271
280
|
for (const [partIndex, part] of manifest.parts.entries()) {
|
|
272
281
|
if (part.kind !== kind) continue;
|
|
273
282
|
const group = records.filter(record => record.data.part === partIndex);
|
|
283
|
+
// A native placeholder PowerPoint's Header & Footer dialog removed is a deliberate removal, not damage.
|
|
284
|
+
if (part.ph !== undefined && group.length === 0) { removedNative = true; continue; }
|
|
274
285
|
check(group.length === part.count, 'Incomplete or duplicated furniture part.');
|
|
275
286
|
if (part.type === 'image') {
|
|
276
287
|
const current = readPicture(pictures[group[0].index]);
|
|
@@ -304,6 +315,15 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
304
315
|
}
|
|
305
316
|
}
|
|
306
317
|
check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
|
|
318
|
+
if (removedNative) {
|
|
319
|
+
// The slide no longer follows the deck's definition: it is its own override, and the other slides can still agree.
|
|
320
|
+
candidate.scope = 'local';
|
|
321
|
+
candidate.derived = true;
|
|
322
|
+
if (value !== false) {
|
|
323
|
+
for (const zone of Object.keys(value)) if (!Object.keys(value[zone]).length) delete value[zone];
|
|
324
|
+
if (!Object.keys(value).length) candidate.value = false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
307
327
|
candidates[kind] = candidate;
|
|
308
328
|
} catch (error) { report(slideIndex, `${kind}: ${error.message}`); }
|
|
309
329
|
}
|
|
@@ -363,6 +383,28 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
363
383
|
delete slide[kind]; report(index, `${kind}: ${owner ? 'Current social profile metadata disagrees across repeated fields.' : 'Social profiles need a repeated organization name to rebuild organization metadata.'}`);
|
|
364
384
|
}
|
|
365
385
|
}
|
|
386
|
+
// RR-11: PowerPoint's own date, footer and slide-number placeholders that no OPF tag claims (a deck authored in
|
|
387
|
+
// PowerPoint, a placeholder the Header & Footer dialog added, a tag-stripped export) are footer furniture.
|
|
388
|
+
contexts.forEach((context, index) => {
|
|
389
|
+
const slide = candidates[index];
|
|
390
|
+
const claimed = new Set([...taggedText[index], ...Object.values(slide).flatMap(candidate => candidate.text)]);
|
|
391
|
+
const fields = readNativePlaceholders(context, claimed, context.relationshipsOf, context.readPart);
|
|
392
|
+
if (!fields.length) return;
|
|
393
|
+
const existing = slide.footer, accepted = [];
|
|
394
|
+
for (const item of fields) {
|
|
395
|
+
const current = existing && existing.value !== false ? existing.value[item.zone]?.[item.field] : undefined;
|
|
396
|
+
if (current !== undefined && current !== false) report(index, `footer.${item.zone}.${item.field}: A native PowerPoint placeholder duplicates a furniture part already read; it stays ordinary current text.`);
|
|
397
|
+
else accepted.push(item);
|
|
398
|
+
}
|
|
399
|
+
if (!accepted.length) return;
|
|
400
|
+
const merged = footerValue(accepted), indexes = accepted.map(item => item.index);
|
|
401
|
+
if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], socials: [], sections: []};
|
|
402
|
+
else {
|
|
403
|
+
if (existing.value === false) existing.value = {};
|
|
404
|
+
for (const zone of Object.keys(merged)) existing.value[zone] = {...existing.value[zone], ...merged[zone]};
|
|
405
|
+
existing.text.push(...indexes);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
366
408
|
const result = {design: {}, slides: candidates.map((slide, index) => {
|
|
367
409
|
const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
|
|
368
410
|
return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
|
|
@@ -380,6 +422,28 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
380
422
|
if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
|
|
381
423
|
result.design[kind] = inherited[0].value;
|
|
382
424
|
candidates.forEach((slide, index) => { if (slide[kind].scope === 'global') delete result.slides[index].design[kind]; });
|
|
425
|
+
} else if (!inherited.length && candidates.every(slide => slide[kind]?.derived && same(slide[kind].value, candidates[0][kind].value))) {
|
|
426
|
+
// Every slide's inherited definition lost the same native placeholder (the dialog's Apply to All): the deck's footer changed.
|
|
427
|
+
result.design[kind] = candidates[0][kind].value;
|
|
428
|
+
candidates.forEach((slide, index) => delete result.slides[index].design[kind]);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// A deck with no OPF footer provenance (PowerPoint's Header & Footer dialog, another tool): the footer most slides share is the
|
|
432
|
+
// deck's footer, a slide without one hides it, and any other slide keeps its own. One slide alone stays that slide's footer.
|
|
433
|
+
const nativeFooters = candidates.map(slide => slide.footer);
|
|
434
|
+
if (nativeFooters.some(Boolean) && nativeFooters.every(candidate => !candidate || candidate.scope === 'native')) {
|
|
435
|
+
const counts = new Map();
|
|
436
|
+
for (const candidate of nativeFooters) if (candidate && candidate.value !== false) {
|
|
437
|
+
const found = counts.get(canonical(candidate.value)) ?? {value: candidate.value, count: 0};
|
|
438
|
+
counts.set(canonical(candidate.value), {...found, count: found.count + 1});
|
|
439
|
+
}
|
|
440
|
+
const best = [...counts.entries()].sort((a, b) => b[1].count - a[1].count)[0];
|
|
441
|
+
if (best && (best[1].count >= 2 || contexts.length === 1)) {
|
|
442
|
+
result.design.footer = best[1].value;
|
|
443
|
+
nativeFooters.forEach((candidate, index) => {
|
|
444
|
+
if (!candidate) result.slides[index].design.footer = false;
|
|
445
|
+
else if (canonical(candidate.value) === best[0]) delete result.slides[index].design.footer;
|
|
446
|
+
});
|
|
383
447
|
}
|
|
384
448
|
}
|
|
385
449
|
candidates.forEach((slide, index) => { if (Object.keys(slide).length) onDiagnostic?.({code: 'furniture-import-reflow', path: `slides.${index}.design`, message: 'Complete furniture roles retain current text, source line boundaries, image content and unambiguous metadata. Native formatting, positioning, crop and other presentation metadata are not reconstructed; review the reflowed furniture.'}); });
|
|
@@ -31,3 +31,15 @@ export async function webpToPng(bytes) {
|
|
|
31
31
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// A browser has no deterministic SVG rasterizer here: pass options.svgRasterizer (for example one that draws the SVG
|
|
36
|
+
// on a canvas) to export SVG pictures. Without it the SVG picture exports as the "Image unavailable" placeholder.
|
|
37
|
+
export async function svgToPng() {
|
|
38
|
+
const unavailable = new Error('Browsers need options.svgRasterizer to export SVG pictures.');
|
|
39
|
+
unavailable.code = 'svg-rasterizer-unavailable';
|
|
40
|
+
throw unavailable;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readLocalFile() {
|
|
44
|
+
throw new Error('A browser cannot read a local file: pass the SVG as a data URI or through imageResolver.');
|
|
45
|
+
}
|
|
@@ -6,3 +6,25 @@ export async function webpToPng(bytes) {
|
|
|
6
6
|
.autoOrient().toColourspace('srgb').ensureAlpha()
|
|
7
7
|
.png({compressionLevel: 9, adaptiveFiltering: false}).toBuffer());
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
// The default rasterizer for the PNG fallback of an SVG picture: opf-render's resvg renderer (an optional peer), which
|
|
11
|
+
// is deterministic (no system fonts, no network or file access for the SVG's own references). `text` selects the
|
|
12
|
+
// bundled fonts; an SVG with no text needs none. Rejects with code `svg-rasterizer-unavailable` when opf-render is
|
|
13
|
+
// not installed.
|
|
14
|
+
export async function svgToPng(svg, {scale, text}) {
|
|
15
|
+
let render;
|
|
16
|
+
try {
|
|
17
|
+
({svgToPng: render} = await import('@openpresentation/opf-render'));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const unavailable = new Error('SVG pictures need the optional peer @openpresentation/opf-render (or an options.svgRasterizer).', {cause: error});
|
|
20
|
+
unavailable.code = 'svg-rasterizer-unavailable';
|
|
21
|
+
throw unavailable;
|
|
22
|
+
}
|
|
23
|
+
return new Uint8Array(await render(svg, {scale, background: 'rgba(0, 0, 0, 0)', useBundledFonts: text === true, loadSystemFonts: false}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A local SVG file (a path source), read as bytes; PptxGenJS reads raster paths itself.
|
|
27
|
+
export async function readLocalFile(path) {
|
|
28
|
+
const { readFile } = await import('node:fs/promises');
|
|
29
|
+
return new Uint8Array(await readFile(path));
|
|
30
|
+
}
|