@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.
@@ -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
- export const METADATA = Object.freeze(['narrative', 'tone', 'audience', 'purpose', 'language', 'organization', 'speaker', 'takeaway', 'duration', 'tags', 'variables', 'filename', 'extensions']);
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) provenance.slides[index].content = 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 key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content']) {
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
- const result = rebuildContent(record.content, imported.slides[index]?.blocks ?? [], slides[index]?.contentBounds ?? []);
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
- const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
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
+ }