@openpresentation/opf-pptx 0.16.1 → 0.18.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,9 +1,10 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js';
3
3
  import {sourceLineParagraphs} from './text-provenance.js';
4
- import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatSlideNumber, parseDate} from './furniture-fields.js';
4
+ import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, isBuiltinTemplate, mentionsSlideToken, parseDate, slideNumberTemplate} from './furniture-fields.js';
5
5
  import {schemas} from '@openpresentation/opf';
6
- import {LOGO_TAG} from './logo-provenance.js';
6
+ import {resolveLogo} from '@openpresentation/opf/composition';
7
+ import {mediaKey} from './media-dedupe.js';
7
8
  import {NATIVE_PLACEHOLDERS, footerValue, isNativePlaceholderType, readNativePlaceholders} from './native-furniture.js';
8
9
 
9
10
  const TAG = 'OPF_FURNITURE_V1';
@@ -15,8 +16,8 @@ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
15
16
  const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
16
17
  const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
17
18
  const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
18
- const fields = ['text', 'image', 'organization', 'speaker', 'socials', 'section', 'slideNumber', 'date'];
19
- const settings = ['slideNumberFormat', 'dateFormat'];
19
+ const fields = ['text', 'image', 'socials', 'date'];
20
+ const settings = ['dateFormat'];
20
21
  const dateFormatForField = Object.fromEntries(Object.entries(NATIVE_DATE_FIELDS).map(([format, type]) => [type, format]));
21
22
  // Platform keys follow the Socials schema's propertyNames pattern exactly, so
22
23
  // every key a valid document can carry re-imports (and nothing else does).
@@ -26,6 +27,8 @@ const platformId = new RegExp(schemas.presentation.$defs.Socials.propertyNames.p
26
27
  const socialLines = part => part.links.map(link => ({platform: link.platform,
27
28
  scheme: link.href && !/^[a-z][a-z0-9+.-]*:/i.test(link.text) ? 'https://' : ''}));
28
29
  const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
30
+ const LOGO_REFERENCE = /^var:organization(?:\.[A-Za-z0-9_-]{1,64}){1,3}$/;
31
+ const isLogoReference = value => typeof value === 'string' && value.length <= 200 && LOGO_REFERENCE.test(value);
29
32
  const key = part => `${part.kind}.${part.zone}.${part.field}`;
30
33
  // Key order is not meaning: a value merged from native placeholders equals the same value read from a manifest.
31
34
  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);
@@ -72,12 +75,13 @@ function unchangedStaticDate(marker, format, ordered, paragraphs) {
72
75
  }), 'Current static date text or boundaries differ from the exported lines.');
73
76
  }
74
77
 
75
- // This manifest contains topology, identities, inactive flags and format
76
- // settings only. Current native shapes supply all words, image bytes and alt
77
- // text, even after edits; a recorded format is kept only while the current text
78
- // still matches it exactly.
79
- export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map()) {
80
- const definitions = {}, formats = {};
78
+ // This manifest contains topology, identities, inactive flags, format settings, the authored text of a zone whose `text` uses
79
+ // built-in variables (`templates`, FA-31; `authored` is the document as given to toPptx, before the deck-wide pass) and the
80
+ // organization logo reference of a zone `image` (`images`, RR-71: `{reference, media?, drawn?}` per `kind.zone`). Current native
81
+ // shapes supply all words, image bytes and alt text, even after edits; a recorded format, template or logo reference is kept
82
+ // only while the current text or picture still matches it exactly.
83
+ export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map(), authored) {
84
+ const definitions = {}, formats = {}, templates = {}, images = {};
81
85
  for (const kind of kinds) {
82
86
  const local = slide.design?.[kind] !== undefined;
83
87
  const source = local ? slide.design[kind] : presentation.design?.[kind];
@@ -85,50 +89,44 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
85
89
  const value = source === false ? false : {};
86
90
  if (value !== false) for (const zone of zones) if (source[zone] !== undefined) {
87
91
  value[zone] = {};
92
+ // An organization logo reference (`var:organization.logo.icon`): core lays out an image part with `reference`; the manifest keeps
93
+ // the authored string. With no part (the organization has no logo) the reference is all there is, and import restores it.
94
+ const reference = isLogoReference(source[zone].image) ? source[zone].image : undefined;
95
+ const imagePart = layout.parts.find(part => part.kind === kind && part.zone === zone && part.type === 'image');
96
+ if (reference !== undefined) images[`${kind}.${zone}`] = imagePart ? {reference} : {reference, drawn: false};
88
97
  for (const field of fields) if (source[zone][field] !== undefined) {
89
98
  const current = source[zone][field];
99
+ if (field === 'image' && reference !== undefined && !imagePart) continue;
90
100
  value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
91
101
  }
92
102
  const format = Object.fromEntries(settings.filter(setting => typeof source[zone][setting] === 'string').map(setting => [setting, source[zone][setting]]));
93
103
  if (Object.keys(format).length) formats[`${kind}.${zone}`] = format;
104
+ // The authored text with its built-in tokens, so import restores them while the words still match this slide:
105
+ // {{slide.number}} is a native field; {{deck.slideCount}} and {{slide.section}} are fixed words; the deck-wide built-ins
106
+ // ({{organization.name}}) were resolved before export. A text that also uses a user variable is not stored (the filled deck
107
+ // round-trips); then only a slide-scoped token in the resolved text is.
108
+ const original = (local ? authored?.slide?.design?.[kind] : authored?.presentation?.design?.[kind])?.[zone]?.text;
109
+ if (isBuiltinTemplate(original)) templates[`${kind}.${zone}`] = original;
110
+ else if (mentionsSlideToken(source[zone].text)) templates[`${kind}.${zone}`] = source[zone].text;
94
111
  }
95
112
  definitions[kind] = {scope: local ? 'local' : 'global', value};
96
113
  }
97
114
  if (!Object.keys(definitions).length) return null;
98
115
  const organizations = array(presentation.organization);
99
116
  const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
100
- const speaker = array(presentation.speaker)[0];
101
- // Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
102
- // definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
103
- // so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
104
- const logos = [];
105
- for (const kind of kinds) {
106
- const source = (slide.design?.[kind] !== undefined ? slide.design : presentation.design)?.[kind];
107
- for (const zone of zones) if (definitions[kind]?.value?.[zone] && source?.[zone]?.logo === true) {
108
- logos.push({kind, zone, drawn: layout.parts.some(part => part.field === 'logo' && part.kind === kind && part.zone === zone)});
109
- }
110
- }
111
117
  return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
112
- ...(layout.parts.some(part => part.field === 'organization' || part.field === 'socials') ? {organizationId: organization?.id} : {}),
113
- ...(layout.parts.some(part => part.field === 'speaker') ? {speakerId: speaker?.id} : {}),
114
- parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
118
+ ...(Object.keys(templates).length ? {templates} : {}), ...(Object.keys(images).length ? {images} : {}),
119
+ ...(layout.parts.some(part => part.field === 'socials') ? {organizationId: organization?.id} : {}),
120
+ parts: layout.parts.map((part, index) => ({kind: part.kind, zone: part.zone, field: part.field,
115
121
  type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
116
122
  ...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
117
- // The speaker part is one line, "Name, Title" or "Name": the name's length lets import split it again (lengths, never words).
118
- ...(part.field === 'speaker' ? {nameLength: speaker.name.length} : {}),
119
123
  ...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
120
124
  // RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
121
- ...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
122
- ...(logos.length ? {logos} : {})};
125
+ ...(natives.has(index) ? {ph: natives.get(index)} : {})}))};
123
126
  }
124
127
 
125
- // The manifest index of a layout part: logo parts are not in the manifest, so later parts shift down.
126
- export const manifestPartIndex = (parts, index) => parts.slice(0, index).filter(part => part.field !== 'logo').length;
127
-
128
- export function attachFurnitureTags(entries, records, manifests, logoRecords = new Map()) {
128
+ export function attachFurnitureTags(entries, records, manifests) {
129
129
  attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
130
- // A generated logo picture carries the logo tag (never the furniture tag): see logo-provenance.js.
131
- attachTextTags(entries, logoRecords, LOGO_TAG, 'opfFurnitureLogo', 'furniture logo', {pictures: true});
132
130
  const types = [];
133
131
  for (const [path, manifest] of manifests) {
134
132
  const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
@@ -150,6 +148,49 @@ export function attachFurnitureTags(entries, records, manifests, logoRecords = n
150
148
  if (types.length) entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
151
149
  }
152
150
 
151
+ /**
152
+ * An organization `logo` value reduced to what logo resolution needs (RR-71): the shapes and tones that exist, each as a
153
+ * placeholder string (a stored picture, an asset reference and a path all count), or undefined when it has none.
154
+ */
155
+ export function logoShape(value) {
156
+ if (typeof value === 'string') return value.trim() ? 'x' : undefined;
157
+ if (!object(value)) return undefined;
158
+ if (typeof value.src === 'string') return value.src.trim() ? 'x' : undefined;
159
+ if (typeof value.$opfMedia === 'string') return 'x';
160
+ const kept = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, logoShape(item)]).filter(([, item]) => item !== undefined));
161
+ return Object.keys(kept).length ? kept : undefined;
162
+ }
163
+
164
+ // The content key of the embedded bytes of a picture: its main blip's media part.
165
+ function pictureKey(picture, relationships, entries) {
166
+ const target = relationships.get(picture?.['p:blipFill']?.['a:blip']?.['r:embed'])?.path;
167
+ return target && entries[target] ? mediaKey(entries[target]) : undefined;
168
+ }
169
+
170
+ /**
171
+ * Finish the `images` of each slide manifest once the package is final (SVG parts, format conversion and media de-duplication
172
+ * change what a picture embeds): the media key of every logo-reference picture, or `drawn: false` when the picture could not be
173
+ * embedded. Rewrites the slide's furniture tag part. `names` maps `<slide part>|<kind>.<zone>` to the picture's object name.
174
+ */
175
+ export function stampFurnitureImageKeys(entries, manifests, names, relationshipsOf) {
176
+ for (const [path, manifest] of manifests) {
177
+ if (!manifest.images) continue;
178
+ const xml = dec.decode(entries[path]), relationships = relationshipsOf(entries, path);
179
+ const pictures = [...xml.matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)].map(match => match[0]);
180
+ for (const [slot, entry] of Object.entries(manifest.images)) {
181
+ if (entry.drawn === false) continue;
182
+ const name = names.get(`${path}|${slot}`);
183
+ const embed = name === undefined ? undefined : pictures.find(picture => picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] === name)?.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
184
+ const target = embed === undefined ? undefined : relationships.get(embed)?.path;
185
+ if (target && entries[target]) entry.media = mediaKey(entries[target]);
186
+ else entry.drawn = false;
187
+ }
188
+ const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
189
+ if (!entries[part]) throw Error('Missing furniture tag part.');
190
+ entries[part] = enc.encode(dec.decode(entries[part]).replace(/\bval="[^"]*"/, () => `val="${encodeTextTag(manifest)}"`));
191
+ }
192
+ }
193
+
153
194
  function readTags(container, relationships, entries) {
154
195
  const tags = [];
155
196
  let unreadable = false;
@@ -164,6 +205,29 @@ function readTags(container, relationships, entries) {
164
205
  ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== SLIDE_TAG).length !== 1};
165
206
  }
166
207
 
208
+ // RR-72: a part written once on the slide master or a layout is stored under `shared` (keyed by slot, with `on`, the definition's
209
+ // flag and its text template or logo reference), so importers that read only slide shapes ignore it. Put it back in place, as a
210
+ // part marked `on`, before the manifest is validated as one record.
211
+ function withSharedParts(manifest) {
212
+ if (manifest?.shared === undefined) return manifest;
213
+ check(object(manifest.shared) && object(manifest.definitions) && Array.isArray(manifest.parts), 'Invalid shared furniture.');
214
+ const result = {...manifest, definitions: structuredClone(manifest.definitions), parts: [...manifest.parts],
215
+ ...(manifest.templates ? {templates: {...manifest.templates}} : {}), ...(manifest.images ? {images: {...manifest.images}} : {})};
216
+ delete result.shared;
217
+ for (const [slot, entry] of Object.entries(manifest.shared)) {
218
+ check(object(entry) && ['master', 'layout'].includes(entry.on) && slot === key(entry) && entry.ph === undefined, 'Invalid shared furniture part.');
219
+ const {on, flag, template, image, ...part} = entry;
220
+ const zone = result.definitions[entry.kind]?.value?.[entry.zone];
221
+ check(object(zone) && zone[entry.field] === undefined && flag !== undefined, 'Invalid shared furniture part.');
222
+ zone[entry.field] = flag;
223
+ const place = `${entry.kind}.${entry.zone}`;
224
+ if (template !== undefined) { check(entry.field === 'text' && result.templates?.[place] === undefined, 'Invalid shared furniture text.'); (result.templates ??= {})[place] = template; }
225
+ if (image !== undefined) { check(entry.field === 'image' && result.images?.[place] === undefined, 'Invalid shared furniture image.'); (result.images ??= {})[place] = image; }
226
+ result.parts.push({...part, on});
227
+ }
228
+ return result;
229
+ }
230
+
167
231
  function validateManifest(manifest) {
168
232
  check(manifest?.v === 1 && manifest.role === 'slide' && /^\d{1,10}$/.test(manifest.group), 'Invalid slide identity.');
169
233
  check(object(manifest.definitions) && Object.keys(manifest.definitions).length > 0 && Object.keys(manifest.definitions).every(kind => kinds.includes(kind)), 'Invalid furniture definitions.');
@@ -184,14 +248,24 @@ function validateManifest(manifest) {
184
248
  }
185
249
  }
186
250
  }
187
- if (manifest.logos !== undefined) {
188
- check(Array.isArray(manifest.logos) && manifest.logos.length <= 6, 'Invalid furniture logos.');
189
- const seen = new Set();
190
- for (const logo of manifest.logos) {
191
- const slot = `${logo?.kind}.${logo?.zone}`;
192
- check(object(logo) && kinds.includes(logo.kind) && zones.includes(logo.zone) && typeof logo.drawn === 'boolean' && Object.keys(logo).length === 3 &&
193
- object(manifest.definitions[logo.kind]?.value?.[logo.zone]) && !seen.has(slot), 'Invalid furniture logo.');
194
- seen.add(slot);
251
+ if (manifest.images !== undefined) {
252
+ check(object(manifest.images), 'Invalid furniture images.');
253
+ for (const [slot, entry] of Object.entries(manifest.images)) {
254
+ const [kind, zone, extra] = slot.split('.');
255
+ const flag = manifest.definitions[kind]?.value?.[zone]?.image;
256
+ check(extra === undefined && object(manifest.definitions[kind]?.value?.[zone]) && object(entry) && Object.keys(entry).every(name => ['reference', 'media', 'drawn'].includes(name)) &&
257
+ isLogoReference(entry.reference) && (entry.media === undefined || (typeof entry.media === 'string' && /^\d{1,12}:[0-9a-f]{1,32}$/.test(entry.media))) &&
258
+ (entry.drawn === undefined || (entry.drawn === false && entry.media === undefined)) &&
259
+ // A reference with no part at all has no image flag; one with a part has the part's flag.
260
+ (flag === 'native' || (flag === undefined && entry.drawn === false)), 'Invalid furniture image.');
261
+ }
262
+ }
263
+ if (manifest.templates !== undefined) {
264
+ check(object(manifest.templates), 'Invalid furniture texts.');
265
+ for (const [slot, template] of Object.entries(manifest.templates)) {
266
+ const [kind, zone, extra] = slot.split('.');
267
+ check(extra === undefined && manifest.definitions[kind]?.value?.[zone]?.text === 'literal' && typeof template === 'string' &&
268
+ template.length <= 100000 && template.includes('{{'), 'Invalid furniture text.');
195
269
  }
196
270
  }
197
271
  if (manifest.formats !== undefined) {
@@ -207,34 +281,80 @@ function validateManifest(manifest) {
207
281
  check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
208
282
  check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
209
283
  // RR-11: a footer part that is a native PowerPoint placeholder (dt, ftr, sldNum), unique per slide.
284
+ // RR-72: a part drawn once on the slide master or a layout.
285
+ check(part.on === undefined || (['master', 'layout'].includes(part.on) && part.ph === undefined), 'Invalid shared furniture part.');
210
286
  if (part.ph !== undefined) check(isNativePlaceholderType(part.ph) && part.kind === 'footer' && part.type === 'text' && part.count === 1 &&
211
287
  NATIVE_PLACEHOLDERS[part.ph].field === part.field && manifest.parts.filter(other => other.ph === part.ph).length === 1, 'Invalid native placeholder part.');
212
288
  if (part.field === 'socials') check(Array.isArray(part.socials) && part.socials.length >= 1 && part.socials.length <= 100
213
289
  && part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
214
290
  && new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
215
291
  else check(part.socials === undefined, 'Unexpected social profile lines.');
216
- if (part.field === 'speaker') check(Number.isSafeInteger(part.nameLength) && part.nameLength >= 1 && part.nameLength <= 100000, 'Invalid speaker name length.');
217
- else check(part.nameLength === undefined, 'Unexpected speaker name length.');
218
292
  expected.delete(key(part));
219
293
  }
220
- if (manifest.parts.some(part => part.field === 'speaker')) check(typeof manifest.speakerId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.speakerId), 'Invalid speaker identity.');
221
- else check(manifest.speakerId === undefined, 'Unexpected speaker identity.');
222
- if (manifest.parts.some(part => part.field === 'organization' || part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
294
+ if (manifest.parts.some(part => part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
223
295
  }
224
296
 
225
- // The logo tags of a picture: any other OPF tag next to one is not an identity conflict for the furniture reader.
226
- function readLogoTags(container, relationships, entries) {
227
- const found = [];
228
- for (const link of array(container?.['p:tags'])) {
229
- const rel = relationships.get(link['r:id']);
230
- if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) continue;
231
- found.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag => tag.name?.toUpperCase() === LOGO_TAG));
297
+ // The native fields of the lines of one tagged text part, with offsets into the text sourceLineParagraphs joins from them.
298
+ function orderedFields(ordered, paragraphs) {
299
+ const fields = [];
300
+ let base = 0;
301
+ for (const item of ordered) {
302
+ const current = paragraphs[item.index] ?? [];
303
+ let at = base;
304
+ for (const paragraph of current) {
305
+ for (const field of paragraph.fields ?? []) fields.push({...field, start: at + field.start});
306
+ at += paragraph.text.length + 1;
307
+ }
308
+ base += current.map(paragraph => paragraph.text).join('\n').length + item.data.separator.length;
309
+ }
310
+ return fields;
311
+ }
312
+
313
+ // A zone `text`: the stored template while it still draws exactly the words now on the slide (`position.draw` resolves its
314
+ // deck-wide built-ins from the document's own metadata and its slide-scoped tokens for this slide); otherwise the words as they
315
+ // stand, with each native slide-number field as `{{slide.number}}`.
316
+ function importedZoneText(text, nativeFields, template, position) {
317
+ if (template !== undefined) {
318
+ const drawn = position.draw(template, position);
319
+ if (typeof drawn !== 'string') return slideNumberTemplate(text, nativeFields);
320
+ // A section name is an XML attribute in the native list: tab, LF and CR come back as spaces (src/sections.js).
321
+ const spaced = value => value.replace(/[\t\n\r]/g, ' ');
322
+ if (drawn === text || (position.section !== undefined && spaced(drawn) === spaced(text))) return template;
323
+ }
324
+ return slideNumberTemplate(text, nativeFields);
325
+ }
326
+
327
+ // RR-72: the tagged furniture shapes of the slide's layout and master, by `on` and slot, and whether the slide shows them: a slide
328
+ // whose "Hide background graphics" is on (showMasterSp="0") shows neither; a layout with it hides the master's.
329
+ function inheritedRecords(context, entries) {
330
+ const inherited = context.inherited?.() ?? {};
331
+ const records = new Map(), damaged = {};
332
+ for (const on of ['layout', 'master']) {
333
+ const source = inherited[on];
334
+ if (!source) continue;
335
+ for (const [type, nodes, properties] of [['text', source.shapes, 'p:nvSpPr'], ['image', source.pictures, 'p:nvPicPr']]) {
336
+ for (const [index, node] of nodes.entries()) {
337
+ const container = node[properties]?.['p:nvPr']?.['p:custDataLst'];
338
+ const result = readTags(container, source.relationships, entries);
339
+ // A tag that cannot be read (a missing part, two identities) leaves the part's shape unknown: that is damage, not a removal.
340
+ if (array(container?.['p:tags']).length && result.tags.length !== 1 && result.ambiguous) damaged[on] = true;
341
+ for (const tag of result.tags) {
342
+ let data;
343
+ try { data = decodeTextTag(tag.val); } catch { damaged[on] = true; continue; }
344
+ if (result.ambiguous || data?.v !== 1 || typeof data.slot !== 'string' || data.role !== type) { damaged[on] = true; continue; }
345
+ const slot = `${on}|${data.slot}`;
346
+ records.set(slot, [...(records.get(slot) ?? []), {type, index, data}]);
347
+ }
348
+ }
349
+ }
232
350
  }
233
- return found;
351
+ const hiddenSlide = context.root?.showMasterSp === '0' || context.root?.showMasterSp === 'false';
352
+ const hiddenLayout = inherited.layout?.root?.showMasterSp === '0' || inherited.layout?.root?.showMasterSp === 'false';
353
+ return {sources: inherited, records, damaged, hidden: {layout: hiddenSlide, master: hiddenSlide || hiddenLayout}};
234
354
  }
235
355
 
236
- function readSlide(context, entries, slideIndex, slideCount, report, taggedText) {
237
- const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
356
+ function readSlide(context, entries, slideIndex, slideCount, report, taggedText, section, draw, logoOwner) {
357
+ const {root, shapes, pictures, relationships} = context;
238
358
  const candidates = {};
239
359
  const records = [];
240
360
  let invalidRecord = false;
@@ -248,26 +368,19 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
248
368
  } catch { invalidRecord = true; }
249
369
  }
250
370
  }
251
- // Generated logo pictures: identity only, the bytes come from design.logo.
252
- const logoPictures = [];
253
- for (const [index, node] of pictures.entries()) try {
254
- for (const tag of readLogoTags(node['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries)) {
255
- const data = decodeTextTag(tag.val);
256
- if (data?.role === 'furniture') logoPictures.push({index, data});
257
- }
258
- } catch { invalidRecord = true; }
259
371
  const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
260
- if (!tags.tags.length && !records.length && !invalidRecord && !logoPictures.length) return candidates;
372
+ if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
261
373
  let manifest;
262
374
  try {
263
375
  check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
264
- manifest = decodeTextTag(tags.tags[0].val);
376
+ manifest = withSharedParts(decodeTextTag(tags.tags[0].val));
265
377
  validateManifest(manifest);
266
378
  for (const record of records) {
267
379
  const data = record.data, part = manifest.parts[data?.part];
268
380
  check(data?.v === 1 && data.group === manifest.group && Number.isSafeInteger(data.part) && part && data.role === record.type && part.type === record.type, 'Unmatched furniture identity.');
269
381
  }
270
382
  } catch (error) { report(slideIndex, error.message); return candidates; }
383
+ const inherited = manifest.parts.some(part => part.on) ? inheritedRecords(context, entries) : undefined;
271
384
  for (const kind of kinds) {
272
385
  const definition = manifest.definitions[kind];
273
386
  if (!definition) continue;
@@ -277,25 +390,42 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
277
390
  if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
278
391
  value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
279
392
  }
280
- const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], speakers: [], socials: [], sections: []};
281
- // logo: true returns as its flag; the generated picture is consumed, never content or an image field.
282
- for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
283
- const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
284
- check(found.length <= 1, 'Duplicated furniture logo.');
285
- if (found.length) { candidate.pictures.push(found[0].index); value[logo.zone].logo = true; }
286
- else if (!logo.drawn) value[logo.zone].logo = true;
393
+ const candidate = {scope: definition.scope, value, text: [], pictures: [], socials: []};
394
+ // A logo reference with no part (the organization had no logo to draw) returns as the reference.
395
+ for (const [slot, entry] of Object.entries(manifest.images ?? {})) {
396
+ const [slotKind, slotZone] = slot.split('.');
397
+ if (slotKind === kind && entry.drawn === false && !definition.value?.[slotZone]?.image && value !== false) value[slotZone].image = entry.reference;
287
398
  }
288
399
  for (const [partIndex, part] of manifest.parts.entries()) {
289
400
  if (part.kind !== kind) continue;
290
- const group = records.filter(record => record.data.part === partIndex);
401
+ // A part on the master or a layout is read there; the slide's own shapes otherwise.
402
+ const source = part.on ? inherited.sources[part.on] : context;
403
+ const group = part.on ? (inherited.hidden[part.on] || !source ? [] : inherited.records.get(`${part.on}|${key(part)}`) ?? []) : records.filter(record => record.data.part === partIndex);
404
+ const {paragraphs, relationships} = source ?? context;
405
+ const logo = part.type === 'image' ? manifest.images?.[`${kind}.${part.zone}`] : undefined;
406
+ // The picture of a logo reference could not be embedded at export: the reference is all there is.
407
+ if (logo?.drawn === false && group.length === 0) { value[part.zone].image = logo.reference; continue; }
291
408
  // A native placeholder PowerPoint's Header & Footer dialog removed is a deliberate removal, not damage.
292
409
  if (part.ph !== undefined && group.length === 0) { removedNative = true; continue; }
410
+ // So is a master or layout part the slide no longer shows (deleted there, another layout, or the slide's background graphics hidden).
411
+ if (part.on !== undefined && group.length === 0) {
412
+ check(inherited.hidden[part.on] || !inherited.damaged[part.on], 'Damaged furniture tags on the slide master or layout.');
413
+ removedNative = true;
414
+ continue;
415
+ }
416
+ try {
293
417
  check(group.length === part.count, 'Incomplete or duplicated furniture part.');
294
418
  if (part.type === 'image') {
295
- const current = readPicture(pictures[group[0].index]);
296
- check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
297
- value[part.zone].image = current.payload.image;
298
- candidate.pictures.push(group[0].index);
419
+ // An unchanged picture of an organization logo returns as its reference (the organization is stored with its logo, and the
420
+ // picture still embeds the bytes export drew); an edited, replaced or foreign one is an ordinary image.
421
+ const picture = source.pictures[group[0].index];
422
+ if (logo && logoOwner(logo.reference) && logo.media !== undefined && logo.media === pictureKey(picture, relationships, entries)) value[part.zone].image = logo.reference;
423
+ else {
424
+ const current = source.readPicture(picture);
425
+ check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
426
+ value[part.zone].image = current.payload.image;
427
+ }
428
+ if (!part.on) candidate.pictures.push(group[0].index);
299
429
  } else {
300
430
  const ordered = Array(part.count);
301
431
  for (const record of group) {
@@ -305,22 +435,26 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
305
435
  }
306
436
  const text = sourceLineParagraphs(ordered, paragraphs)[0].text;
307
437
  const format = manifest.formats?.[`${kind}.${part.zone}`] ?? {};
308
- if (part.field === 'slideNumber') {
309
- // The live field renumbers in PowerPoint; any fixed text around it
310
- // must still match the recorded format at this slide's position.
311
- check(text === formatSlideNumber(format.slideNumberFormat ?? '{current}', slideIndex + 1, slideCount), 'Current slide number differs from its position; retain the visible number as ordinary text.');
312
- if (format.slideNumberFormat !== undefined) value[part.zone].slideNumberFormat = format.slideNumberFormat;
313
- }
314
- if (part.field === 'section') candidate.sections.push(text);
315
- if (part.field === 'organization') candidate.organizations.push({id: manifest.organizationId, name: text});
316
- if (part.field === 'speaker') candidate.speakers.push(importedSpeaker(manifest.speakerId, text, part.nameLength));
317
438
  if (part.field === 'socials') {
318
439
  const lines = text.split('\n');
319
440
  check(lines.length === part.socials.length && lines.every(line => line.trim()), 'Current social profile lines no longer match their platforms.');
320
441
  candidate.socials.push({id: manifest.organizationId, socials: Object.fromEntries(part.socials.map((line, index) => [line.platform, line.scheme + lines[index]]))});
321
442
  }
322
- value[part.zone][part.field] = part.field === 'text' ? text : part.field === 'date' ? importedDate(definition.value[part.zone].date, format, text, ordered.flatMap(record => (paragraphs[record.index] ?? []).flatMap(paragraph => paragraph.fields ?? [])), value[part.zone], part.staticDate, ordered, paragraphs, message => report(slideIndex, `${kind}.${part.zone}.date: ${message}`)) : true;
323
- candidate.text.push(...ordered.map(record => record.index));
443
+ value[part.zone][part.field] = part.field === 'text' ? importedZoneText(text, orderedFields(ordered, paragraphs), manifest.templates?.[`${kind}.${part.zone}`], {index: slideIndex, count: slideCount, section, draw}) : part.field === 'date' ? importedDate(definition.value[part.zone].date, format, text, ordered.flatMap(record => (paragraphs[record.index] ?? []).flatMap(paragraph => paragraph.fields ?? [])), value[part.zone], part.staticDate, ordered, paragraphs, message => report(slideIndex, `${kind}.${part.zone}.date: ${message}`)) : true;
444
+ if (!part.on) candidate.text.push(...ordered.map(record => record.index));
445
+ }
446
+ } catch (error) {
447
+ // A part on the slide master or a layout is never slide content: when its record no longer reads, its current words (or
448
+ // picture) stay the part's value, as literal text, and the damage is reported.
449
+ if (!part.on) throw error;
450
+ report(slideIndex, `${kind}.${part.zone}.${part.field}: ${error.message} The current ${part.type === 'image' ? 'picture' : 'words'} on the slide master or layout are kept.`);
451
+ if (part.type === 'image') {
452
+ const current = source.readPicture(source.pictures[group[0].index]);
453
+ if (current?.kind === 'image') value[part.zone].image = current.payload.image;
454
+ } else if (part.field !== 'socials') {
455
+ const lines = [...group].sort((a, b) => (Number.isSafeInteger(a.data.line) ? a.data.line : 0) - (Number.isSafeInteger(b.data.line) ? b.data.line : 0) || a.index - b.index);
456
+ value[part.zone][part.field] = lines.map(record => (paragraphs[record.index] ?? []).map(paragraph => paragraph.text).join('\n')).join('\n');
457
+ }
324
458
  }
325
459
  }
326
460
  check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
@@ -339,14 +473,6 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
339
473
  return candidates;
340
474
  }
341
475
 
342
- // The generated speaker line is "Name, Title" (or "Name"). The recorded name length splits it again; a line edited in
343
- // PowerPoint that no longer has the separator at that position is the visible text as the name, with no title.
344
- function importedSpeaker(id, text, nameLength) {
345
- const rest = text.slice(nameLength);
346
- if (text.length > nameLength && rest.startsWith(', ') && rest.length > 2) return {id, name: text.slice(0, nameLength), title: rest.slice(2)};
347
- return {id, name: text};
348
- }
349
-
350
476
  // A current native date field wins. Full-mode static-wrap evidence can recover
351
477
  // generated OPF intent without turning the existing PPTX text into a live field. A fixed
352
478
  // date keeps its ISO value and pattern only when the current text round-trips.
@@ -374,37 +500,25 @@ function importedDate(flag, format, text, nativeFields, zone, marker, ordered, p
374
500
  // Reconcile metadata before consuming any shapes. Global inheritance is promoted
375
501
  // only when every slide has a valid definition/override and all inherited values
376
502
  // agree. A missing tag must never cause another slide's furniture to reappear.
377
- export function importFurniture(contexts, entries, onDiagnostic) {
503
+ // `options.sections` are the native section names by slide index (a slide's `{{slide.section}}` is checked against them),
504
+ // `options.organizations` the stored organizations (OPF_DOCUMENT_V1) a socials part can name by id, and `options.draw(template,
505
+ // {index, count, section})` draws a stored zone text for a slide (its built-in variables resolved from the document's metadata).
506
+ export function importFurniture(contexts, entries, onDiagnostic, {sections = [], organizations = [], draw = () => undefined} = {}) {
507
+ // A logo reference returns only while the stored organizations still give it a logo (RR-71): `organizations` carry `logo`.
508
+ const logoOwner = reference => {
509
+ try { return resolveLogo({organization: organizations.filter(item => item.logo !== undefined)}, {}, {reference}) !== null; } catch { return false; }
510
+ };
378
511
  const report = (index, message) => onDiagnostic?.({code: 'invalid-furniture-provenance', path: `slides.${index}.design`, message: `${message} Ordinary import retains current native content; no old source words are restored.`});
379
512
  const taggedText = contexts.map(() => new Set());
380
- const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index]));
381
- const organizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
382
- const organizationConflict = organizations.some(item => !same(item, organizations[0]));
383
- if (organizationConflict) {
384
- for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.organizations.length) {
385
- delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
386
- }
387
- }
388
- const speakers = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.speakers));
389
- const speakerConflict = speakers.some(item => !same(item, speakers[0]));
390
- if (speakerConflict) {
391
- for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.speakers.length) {
392
- delete slide[kind]; report(index, `${kind}: Current speaker metadata disagrees across repeated fields.`);
393
- }
394
- }
395
- for (const [index, slide] of candidates.entries()) {
396
- const sections = Object.values(slide).flatMap(candidate => candidate.sections);
397
- if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
398
- delete slide[kind]; report(index, `${kind}: Current section metadata disagrees on this slide.`);
399
- }
400
- }
401
- // Social profiles belong to the organization named by repeated furniture; without
402
- // that name (or with disagreeing values) the lines stay ordinary current text.
403
- const owner = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations))[0];
513
+ const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw, logoOwner));
514
+ // Social profiles belong to the organization the manifest names by id, which only the stored document metadata can supply
515
+ // (no furniture part shows the organization's name any more); without it (or with disagreeing values) the lines stay
516
+ // ordinary current text.
404
517
  const socials = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.socials));
518
+ const owner = socials.length ? organizations.find(item => item.id === socials[0].id) : undefined;
405
519
  if (socials.length && (!owner || socials.some(item => item.id !== owner.id || !same(item, socials[0])))) {
406
520
  for (const [index, slide] of candidates.entries()) for (const kind of kinds) if (slide[kind]?.socials.length) {
407
- 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.'}`);
521
+ delete slide[kind]; report(index, `${kind}: ${owner ? 'Current social profile metadata disagrees across repeated fields.' : 'Social profiles need the stored organization metadata to rebuild organization.socials.'}`);
408
522
  }
409
523
  }
410
524
  // RR-11: PowerPoint's own date, footer and slide-number placeholders that no OPF tag claims (a deck authored in
@@ -422,7 +536,7 @@ export function importFurniture(contexts, entries, onDiagnostic) {
422
536
  }
423
537
  if (!accepted.length) return;
424
538
  const merged = footerValue(accepted), indexes = accepted.map(item => item.index);
425
- if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], speakers: [], socials: [], sections: []};
539
+ if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], socials: []};
426
540
  else {
427
541
  if (existing.value === false) existing.value = {};
428
542
  for (const zone of Object.keys(merged)) existing.value[zone] = {...existing.value[zone], ...merged[zone]};
@@ -430,20 +544,13 @@ export function importFurniture(contexts, entries, onDiagnostic) {
430
544
  }
431
545
  });
432
546
  const result = {design: {}, slides: candidates.map((slide, index) => {
433
- const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
547
+ const values = Object.values(slide);
434
548
  return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
435
- ...(sections.length ? {section: sections[0]} : {}),
436
549
  text: new Set(values.flatMap(candidate => candidate.text)), pictures: new Set(values.flatMap(candidate => candidate.pictures)), taggedText: taggedText[index]};
437
550
  })};
438
- const validOrganizations = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.organizations));
439
- if (validOrganizations.length) result.organization = validOrganizations[0];
551
+ // The organization the socials belong to: its stored identity, with the profiles the slides show now.
440
552
  const validSocials = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.socials));
441
- if (result.organization && validSocials.length) result.organization = {...result.organization, socials: validSocials[0].socials};
442
- // Stored document metadata (FF-32) must not override disagreeing visible names.
443
- if (organizationConflict) result.organizationConflict = true;
444
- const validSpeakers = candidates.flatMap(slide => Object.values(slide).flatMap(candidate => candidate.speakers));
445
- if (validSpeakers.length) result.speaker = validSpeakers[0];
446
- if (speakerConflict) result.speakerConflict = true;
553
+ if (owner && validSocials.length) result.organization = {id: owner.id, name: owner.name, socials: validSocials[0].socials};
447
554
  for (const kind of kinds) {
448
555
  const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
449
556
  if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
@@ -13,12 +13,12 @@ export async function webpToPng(bytes) {
13
13
  // not installed, and also when it is installed but its PNG converter (`@resvg/resvg-js`, an optional peer of opf-render
14
14
  // 0.16) is not: opf-render's `converter-missing` error maps to the same code, with its install command on `install`.
15
15
  //
16
- // This is the one place the exporter calls the renderer. opf-render's svgToPng(svg, {fonts, scale, background}) takes the
16
+ // This is the one place the exporter calls the renderer. opf-render's toPng(svg, {fonts, scale, background}) takes the
17
17
  // fonts handle for the faces it may draw with: here only the bundled ones, never the system's.
18
18
  export async function svgToPng(svg, {scale, text}) {
19
19
  let render;
20
20
  try {
21
- ({svgToPng: render} = await import('@openpresentation/opf-render'));
21
+ ({toPng: render} = await import('@openpresentation/opf-render'));
22
22
  } catch (error) {
23
23
  const unavailable = new Error('SVG pictures need the optional peer @openpresentation/opf-render (or an options.svgRasterizer).', {cause: error});
24
24
  unavailable.code = 'svg-rasterizer-unavailable';
package/dist/index.d.ts CHANGED
@@ -97,7 +97,7 @@ export interface ToPptxOptions {
97
97
  /**
98
98
  * OPF_DOCUMENT_V1 / OPF_SLIDE_V1 customer-data tags that let fromPptx restore
99
99
  * catalog references, layout ids, authoring metadata (`filename` and `extensions`
100
- * included), `design.logo`, the asset registry, slide `section`/`extensions` and
100
+ * included), `design.logo` (a logo reference or `false`), the organizations with their logos, the asset registry, slide `section`/`extensions` and
101
101
  * the slide content structure: nested groups, promoted regions, the root payload
102
102
  * form, block ids and extensions, group composition (docs/document-roundtrip.md).
103
103
  * Tags are not shown in PowerPoint's UI. Default "full"; "references-only"
@@ -131,7 +131,7 @@ export interface ToPptxOptions {
131
131
  /**
132
132
  * Draws the PNG fallback of an SVG picture (the raster older viewers show; PowerPoint 2016 and Microsoft 365 draw the SVG itself). It receives the
133
133
  * sanitized SVG text and the pixel size to draw (the SVG's own aspect, about 192 dpi of the displayed size) and returns PNG bytes with a transparent
134
- * background. It must be deterministic for the export to be. Default in Node: opf-render's `svgToPng` (resvg with the bundled fonts), loaded only when an
134
+ * background. It must be deterministic for the export to be. Default in Node: opf-render's `toPng` (resvg with the bundled fonts), loaded only when an
135
135
  * SVG is exported (optional peer `@openpresentation/opf-render`). Without it, and without opf-render, an SVG picture exports as the "Image unavailable"
136
136
  * placeholder with an `unresolved-asset` diagnostic (`reason: "svg-rasterizer-unavailable"`); a browser build has no default.
137
137
  */
@@ -172,7 +172,7 @@ export interface ToPptxOptions {
172
172
  }
173
173
 
174
174
  export interface FromPptxOptions {
175
- /** Reports native details that import cannot preserve, including code provenance fallback/reflow and grouped text transforms. Table paths identify native frame and row/cell indexes (including headers). Stored catalog references that no longer match the package report `design-reference-changed` / `layout-reference-changed` at the reference path; a stored slide layout reference that resolves neither in the stored document catalogs, nor in the slide's own stored record, nor in `catalogs` reports `unresolved-reference`; a slide whose imported blocks no longer fit the stored content structure reports `content-structure-changed` at `slides.N`, a block id repeated by a duplicated slide `duplicate-block-id`, and a footer section text that disagrees with PowerPoint's section list `section-reference-changed` (docs/document-roundtrip.md). */
175
+ /** Reports native details that import cannot preserve, including code provenance fallback/reflow and grouped text transforms. Table paths identify native frame and row/cell indexes (including headers). Stored catalog references that no longer match the package report `design-reference-changed` / `layout-reference-changed` at the reference path; a stored slide layout reference that resolves neither in the stored document catalogs, nor in the slide's own stored record, nor in `catalogs` reports `unresolved-reference`; a slide whose imported blocks no longer fit the stored content structure reports `content-structure-changed` at `slides.N`, a block id repeated by a duplicated slide `duplicate-block-id` (docs/document-roundtrip.md). */
176
176
  onDiagnostic?: (diagnostic: {code: string; path: string; message: string}) => void;
177
177
  fallbackName?: string;
178
178
  schema?: string;