@openpresentation/opf-pptx 0.17.0 → 0.18.1

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.
@@ -3,7 +3,8 @@ import {attachTextTags, decodeTextTag, encodeTextTag} from './code-provenance.js
3
3
  import {sourceLineParagraphs} from './text-provenance.js';
4
4
  import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, 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';
@@ -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,11 +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, format settings and the authored text of a zone whose
76
- // `text` uses built-in variables (`templates`, FA-31; `authored` is the document as given to toPptx, before the deck-wide pass). Current native shapes supply all words, image bytes and
77
- // alt text, even after edits; a recorded format or template is kept only while the current text still matches it exactly.
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.
78
83
  export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map(), authored) {
79
- const definitions = {}, formats = {}, templates = {};
84
+ const definitions = {}, formats = {}, templates = {}, images = {};
80
85
  for (const kind of kinds) {
81
86
  const local = slide.design?.[kind] !== undefined;
82
87
  const source = local ? slide.design[kind] : presentation.design?.[kind];
@@ -84,8 +89,14 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
84
89
  const value = source === false ? false : {};
85
90
  if (value !== false) for (const zone of zones) if (source[zone] !== undefined) {
86
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};
87
97
  for (const field of fields) if (source[zone][field] !== undefined) {
88
98
  const current = source[zone][field];
99
+ if (field === 'image' && reference !== undefined && !imagePart) continue;
89
100
  value[zone][field] = field === 'image' ? 'native' : typeof current === 'string' ? 'literal' : current;
90
101
  }
91
102
  const format = Object.fromEntries(settings.filter(setting => typeof source[zone][setting] === 'string').map(setting => [setting, source[zone][setting]]));
@@ -103,35 +114,19 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
103
114
  if (!Object.keys(definitions).length) return null;
104
115
  const organizations = array(presentation.organization);
105
116
  const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
106
- // Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
107
- // definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
108
- // so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
109
- const logos = [];
110
- for (const kind of kinds) {
111
- const source = (slide.design?.[kind] !== undefined ? slide.design : presentation.design)?.[kind];
112
- for (const zone of zones) if (definitions[kind]?.value?.[zone] && source?.[zone]?.logo === true) {
113
- logos.push({kind, zone, drawn: layout.parts.some(part => part.field === 'logo' && part.kind === kind && part.zone === zone)});
114
- }
115
- }
116
117
  return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
117
- ...(Object.keys(templates).length ? {templates} : {}),
118
+ ...(Object.keys(templates).length ? {templates} : {}), ...(Object.keys(images).length ? {images} : {}),
118
119
  ...(layout.parts.some(part => part.field === 'socials') ? {organizationId: organization?.id} : {}),
119
- parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
120
+ parts: layout.parts.map((part, index) => ({kind: part.kind, zone: part.zone, field: part.field,
120
121
  type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
121
122
  ...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
122
123
  ...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
123
124
  // RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
124
- ...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
125
- ...(logos.length ? {logos} : {})};
125
+ ...(natives.has(index) ? {ph: natives.get(index)} : {})}))};
126
126
  }
127
127
 
128
- // The manifest index of a layout part: logo parts are not in the manifest, so later parts shift down.
129
- export const manifestPartIndex = (parts, index) => parts.slice(0, index).filter(part => part.field !== 'logo').length;
130
-
131
- export function attachFurnitureTags(entries, records, manifests, logoRecords = new Map()) {
128
+ export function attachFurnitureTags(entries, records, manifests) {
132
129
  attachTextTags(entries, records, TAG, 'opfFurniture', 'furniture', {pictures: true});
133
- // A generated logo picture carries the logo tag (never the furniture tag): see logo-provenance.js.
134
- attachTextTags(entries, logoRecords, LOGO_TAG, 'opfFurnitureLogo', 'furniture logo', {pictures: true});
135
130
  const types = [];
136
131
  for (const [path, manifest] of manifests) {
137
132
  const part = `ppt/tags/opfFurnitureSlide${manifest.group}.xml`;
@@ -153,6 +148,49 @@ export function attachFurnitureTags(entries, records, manifests, logoRecords = n
153
148
  if (types.length) entries['[Content_Types].xml'] = enc.encode(dec.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
154
149
  }
155
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
+
156
194
  function readTags(container, relationships, entries) {
157
195
  const tags = [];
158
196
  let unreadable = false;
@@ -167,6 +205,29 @@ function readTags(container, relationships, entries) {
167
205
  ambiguous: unreadable || tags.filter(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== SLIDE_TAG).length !== 1};
168
206
  }
169
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
+
170
231
  function validateManifest(manifest) {
171
232
  check(manifest?.v === 1 && manifest.role === 'slide' && /^\d{1,10}$/.test(manifest.group), 'Invalid slide identity.');
172
233
  check(object(manifest.definitions) && Object.keys(manifest.definitions).length > 0 && Object.keys(manifest.definitions).every(kind => kinds.includes(kind)), 'Invalid furniture definitions.');
@@ -187,14 +248,16 @@ function validateManifest(manifest) {
187
248
  }
188
249
  }
189
250
  }
190
- if (manifest.logos !== undefined) {
191
- check(Array.isArray(manifest.logos) && manifest.logos.length <= 6, 'Invalid furniture logos.');
192
- const seen = new Set();
193
- for (const logo of manifest.logos) {
194
- const slot = `${logo?.kind}.${logo?.zone}`;
195
- check(object(logo) && kinds.includes(logo.kind) && zones.includes(logo.zone) && typeof logo.drawn === 'boolean' && Object.keys(logo).length === 3 &&
196
- object(manifest.definitions[logo.kind]?.value?.[logo.zone]) && !seen.has(slot), 'Invalid furniture logo.');
197
- 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.');
198
261
  }
199
262
  }
200
263
  if (manifest.templates !== undefined) {
@@ -218,6 +281,8 @@ function validateManifest(manifest) {
218
281
  check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
219
282
  check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
220
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.');
221
286
  if (part.ph !== undefined) check(isNativePlaceholderType(part.ph) && part.kind === 'footer' && part.type === 'text' && part.count === 1 &&
222
287
  NATIVE_PLACEHOLDERS[part.ph].field === part.field && manifest.parts.filter(other => other.ph === part.ph).length === 1, 'Invalid native placeholder part.');
223
288
  if (part.field === 'socials') check(Array.isArray(part.socials) && part.socials.length >= 1 && part.socials.length <= 100
@@ -229,17 +294,6 @@ function validateManifest(manifest) {
229
294
  if (manifest.parts.some(part => part.field === 'socials')) check(typeof manifest.organizationId === 'string' && /^[a-zA-Z0-9_-]+$/.test(manifest.organizationId), 'Invalid organization identity.');
230
295
  }
231
296
 
232
- // The logo tags of a picture: any other OPF tag next to one is not an identity conflict for the furniture reader.
233
- function readLogoTags(container, relationships, entries) {
234
- const found = [];
235
- for (const link of array(container?.['p:tags'])) {
236
- const rel = relationships.get(link['r:id']);
237
- if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) continue;
238
- found.push(...array(parser.parse(dec.decode(entries[rel.path]))['p:tagLst']?.['p:tag']).filter(tag => tag.name?.toUpperCase() === LOGO_TAG));
239
- }
240
- return found;
241
- }
242
-
243
297
  // The native fields of the lines of one tagged text part, with offsets into the text sourceLineParagraphs joins from them.
244
298
  function orderedFields(ordered, paragraphs) {
245
299
  const fields = [];
@@ -270,8 +324,37 @@ function importedZoneText(text, nativeFields, template, position) {
270
324
  return slideNumberTemplate(text, nativeFields);
271
325
  }
272
326
 
273
- function readSlide(context, entries, slideIndex, slideCount, report, taggedText, section, draw) {
274
- const {root, shapes, paragraphs, pictures, relationships, readPicture} = context;
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
+ }
350
+ }
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}};
354
+ }
355
+
356
+ function readSlide(context, entries, slideIndex, slideCount, report, taggedText, section, draw, logoOwner) {
357
+ const {root, shapes, pictures, relationships} = context;
275
358
  const candidates = {};
276
359
  const records = [];
277
360
  let invalidRecord = false;
@@ -285,26 +368,19 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
285
368
  } catch { invalidRecord = true; }
286
369
  }
287
370
  }
288
- // Generated logo pictures: identity only, the bytes come from design.logo.
289
- const logoPictures = [];
290
- for (const [index, node] of pictures.entries()) try {
291
- for (const tag of readLogoTags(node['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries)) {
292
- const data = decodeTextTag(tag.val);
293
- if (data?.role === 'furniture') logoPictures.push({index, data});
294
- }
295
- } catch { invalidRecord = true; }
296
371
  const tags = readTags(root['p:cSld']?.['p:custDataLst'], relationships, entries);
297
- if (!tags.tags.length && !records.length && !invalidRecord && !logoPictures.length) return candidates;
372
+ if (!tags.tags.length && !records.length && !invalidRecord) return candidates;
298
373
  let manifest;
299
374
  try {
300
375
  check(!tags.ambiguous && tags.tags.length === 1 && !invalidRecord, 'Missing or ambiguous furniture manifest/shape tags.');
301
- manifest = decodeTextTag(tags.tags[0].val);
376
+ manifest = withSharedParts(decodeTextTag(tags.tags[0].val));
302
377
  validateManifest(manifest);
303
378
  for (const record of records) {
304
379
  const data = record.data, part = manifest.parts[data?.part];
305
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.');
306
381
  }
307
382
  } catch (error) { report(slideIndex, error.message); return candidates; }
383
+ const inherited = manifest.parts.some(part => part.on) ? inheritedRecords(context, entries) : undefined;
308
384
  for (const kind of kinds) {
309
385
  const definition = manifest.definitions[kind];
310
386
  if (!definition) continue;
@@ -315,24 +391,41 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
315
391
  value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
316
392
  }
317
393
  const candidate = {scope: definition.scope, value, text: [], pictures: [], socials: []};
318
- // logo: true returns as its flag; the generated picture is consumed, never content or an image field.
319
- for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
320
- const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
321
- check(found.length <= 1, 'Duplicated furniture logo.');
322
- if (found.length) { candidate.pictures.push(found[0].index); value[logo.zone].logo = true; }
323
- else if (!logo.drawn) value[logo.zone].logo = true;
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;
324
398
  }
325
399
  for (const [partIndex, part] of manifest.parts.entries()) {
326
400
  if (part.kind !== kind) continue;
327
- 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; }
328
408
  // A native placeholder PowerPoint's Header & Footer dialog removed is a deliberate removal, not damage.
329
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 {
330
417
  check(group.length === part.count, 'Incomplete or duplicated furniture part.');
331
418
  if (part.type === 'image') {
332
- const current = readPicture(pictures[group[0].index]);
333
- check(current?.kind === 'image', 'Furniture image is no longer recoverable.');
334
- value[part.zone].image = current.payload.image;
335
- 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);
336
429
  } else {
337
430
  const ordered = Array(part.count);
338
431
  for (const record of group) {
@@ -348,7 +441,20 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText,
348
441
  candidate.socials.push({id: manifest.organizationId, socials: Object.fromEntries(part.socials.map((line, index) => [line.platform, line.scheme + lines[index]]))});
349
442
  }
350
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;
351
- candidate.text.push(...ordered.map(record => record.index));
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
+ }
352
458
  }
353
459
  }
354
460
  check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
@@ -398,9 +504,13 @@ function importedDate(flag, format, text, nativeFields, zone, marker, ordered, p
398
504
  // `options.organizations` the stored organizations (OPF_DOCUMENT_V1) a socials part can name by id, and `options.draw(template,
399
505
  // {index, count, section})` draws a stored zone text for a slide (its built-in variables resolved from the document's metadata).
400
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
+ };
401
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.`});
402
512
  const taggedText = contexts.map(() => new Set());
403
- const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw));
513
+ const candidates = contexts.map((context, index) => readSlide(context, entries, index, contexts.length, report, taggedText[index], sections[index], draw, logoOwner));
404
514
  // Social profiles belong to the organization the manifest names by id, which only the stored document metadata can supply
405
515
  // (no furniture part shows the organization's name any more); without it (or with disagreeing values) the lines stay
406
516
  // ordinary current text.
@@ -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';
@@ -271,3 +271,57 @@ export function importImages({pictures, shapes, relationships, entries, slideInd
271
271
  }
272
272
  return {blocks, background, backgroundOverlay, backgroundOverlayShape, consumed, consumedShapes};
273
273
  }
274
+
275
+ // opf-pptx#221: the "Image unavailable" placeholder of an image block whose source could not be embedded (a remote URL is never
276
+ // fetched) is one native group, `OPF image placeholder N group`, that carries the panel's accessible name (descr) and holds the
277
+ // dashed panel `OPF image placeholder N` and its label lines (`... text line i`) or cross strokes (`... icon i`), each an
278
+ // editable shape. In `full` mode the panel carries OPF_IMAGE_PLACEHOLDER_V1: the slide, the block path, the authored image value
279
+ // and the block's own fields. Import turns an intact tagged panel back into that image block and consumes its label shapes;
280
+ // without a valid tag (provenance false or references-only, a damaged tag) the shapes stay ordinary content, as before.
281
+ export const IMAGE_PLACEHOLDER_TAG = 'OPF_IMAGE_PLACEHOLDER_V1';
282
+ export const imagePlaceholderGroupName = panel => `${panel} group`;
283
+ const PLACEHOLDER_PANEL = /^OPF image placeholder \d+$/;
284
+ const PLACEHOLDER_GROUP = /^OPF image placeholder \d+ group$/;
285
+ const BLOCK_PATH = /^slides\.\d+(?:\.[A-Za-z0-9_:+-]{1,64}){1,32}$/;
286
+ const placeholderPart = (panel, name) => typeof name === 'string' && name.startsWith(`${panel} `) && /^(?:text line|icon) \d+$/.test(name.slice(panel.length + 1));
287
+
288
+ /** Whether a native group is an exported image placeholder group with an identity transform (its children keep their boxes). */
289
+ export function isImagePlaceholderGroup(group) {
290
+ if (!PLACEHOLDER_GROUP.test(String(group?.['p:nvGrpSpPr']?.['p:cNvPr']?.name ?? ''))) return false;
291
+ const xfrm = group?.['p:grpSpPr']?.['a:xfrm'];
292
+ return ['x', 'y'].every(key => String(xfrm?.['a:off']?.[key]) === String(xfrm?.['a:chOff']?.[key]))
293
+ && ['cx', 'cy'].every(key => String(xfrm?.['a:ext']?.[key]) === String(xfrm?.['a:chExt']?.[key]));
294
+ }
295
+
296
+ /**
297
+ * Recover the image blocks of tagged unavailable-image placeholders on one slide. `shapes` are the slide's native text shapes
298
+ * (nativeTextShapes order, group members included); `valid(payload)` checks a recovered image block against the OPF format.
299
+ * Returns `items`: [{panel (shape index), members (shape indexes, the panel first), payload}] and `consumed`, every member index.
300
+ */
301
+ export function importImagePlaceholders({shapes, relationships, entries, slideIndex, valid, report}) {
302
+ const slide = `slides.${slideIndex}`;
303
+ const items = [], consumed = new Set();
304
+ const names = shapes.map(shape => shape?.['p:nvSpPr']?.['p:cNvPr']?.name);
305
+ const invalid = message => report({code: 'invalid-image-provenance', path: slide, message});
306
+ for (const [index, shape] of shapes.entries()) {
307
+ let manifest;
308
+ try { manifest = ownTag(shape?.['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries, IMAGE_PLACEHOLDER_TAG); }
309
+ catch { invalid('A tagged OPF image placeholder is ambiguous; its shapes were imported as ordinary content.'); continue; }
310
+ if (manifest === null) continue;
311
+ const name = names[index];
312
+ const image = manifest?.image;
313
+ const treatment = manifest?.treatment ?? {};
314
+ if (manifest?.v !== 1 || manifest.slide !== slide || typeof manifest.path !== 'string' || !BLOCK_PATH.test(manifest.path) || !manifest.path.startsWith(`${slide}.`)
315
+ || !PLACEHOLDER_PANEL.test(name ?? '') || !(typeof image === 'string' || object(image)) || !object(treatment)
316
+ || Object.keys(treatment).some(key => !IMAGE_TREATMENT_KEYS.includes(key))) {
317
+ invalid('An edited or invalid tagged OPF image placeholder was imported as ordinary content; its image block is not reconstructed.');
318
+ continue;
319
+ }
320
+ const payload = {type: 'image', image: structuredClone(image), ...structuredClone(treatment)};
321
+ if (!valid(payload)) { invalid('A tagged OPF image placeholder does not form a valid image block; its shapes were imported as ordinary content.'); continue; }
322
+ const members = [index, ...names.flatMap((other, position) => position !== index && placeholderPart(name, other) ? [position] : [])];
323
+ for (const member of members) consumed.add(member);
324
+ items.push({panel: index, members, payload});
325
+ }
326
+ return {items, consumed};
327
+ }
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
  */