@openpresentation/opf-pptx 0.13.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,7 +15,7 @@ const enc = new TextEncoder(), dec = new TextDecoder('utf-8', {fatal: true});
15
15
  const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
16
16
  const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
17
17
  const kinds = ['header', 'footer'], zones = ['left', 'center', 'right'];
18
- const fields = ['text', 'image', 'organization', 'socials', 'section', 'slideNumber', 'date'];
18
+ const fields = ['text', 'image', 'organization', 'speaker', 'socials', 'section', 'slideNumber', 'date'];
19
19
  const settings = ['slideNumberFormat', 'dateFormat'];
20
20
  const dateFormatForField = Object.fromEntries(Object.entries(NATIVE_DATE_FIELDS).map(([format, type]) => [type, format]));
21
21
  // Platform keys follow the Socials schema's propertyNames pattern exactly, so
@@ -97,6 +97,7 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
97
97
  if (!Object.keys(definitions).length) return null;
98
98
  const organizations = array(presentation.organization);
99
99
  const organization = organizations.find(item => item.role === 'primary') ?? organizations[0];
100
+ const speaker = array(presentation.speaker)[0];
100
101
  // Generated deck logos (logo: true) are listed beside the topology, never inside it: the part list and the field
101
102
  // definitions are validated strictly by every released importer (0.11.6 and earlier reject an unknown field there),
102
103
  // so they keep describing only what those importers know. `drawn` is false when no logo resolved at export.
@@ -109,9 +110,12 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
109
110
  }
110
111
  return {v: 1, role: 'slide', group: String(slideIndex), definitions, ...(Object.keys(formats).length ? {formats} : {}),
111
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} : {}),
112
114
  parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
113
115
  type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
114
116
  ...(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} : {}),
115
119
  ...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
116
120
  // RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
117
121
  ...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
@@ -209,8 +213,12 @@ function validateManifest(manifest) {
209
213
  && part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
210
214
  && new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
211
215
  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.');
212
218
  expected.delete(key(part));
213
219
  }
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.');
214
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.');
215
223
  }
216
224
 
@@ -269,7 +277,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
269
277
  if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
270
278
  value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
271
279
  }
272
- const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], socials: [], sections: []};
280
+ const candidate = {scope: definition.scope, value, text: [], pictures: [], organizations: [], speakers: [], socials: [], sections: []};
273
281
  // logo: true returns as its flag; the generated picture is consumed, never content or an image field.
274
282
  for (const logo of (manifest.logos ?? []).filter(item => item.kind === kind)) {
275
283
  const found = logoPictures.filter(item => item.data.v === 1 && item.data.group === manifest.group && item.data.furniture === kind && item.data.zone === logo.zone);
@@ -305,6 +313,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
305
313
  }
306
314
  if (part.field === 'section') candidate.sections.push(text);
307
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));
308
317
  if (part.field === 'socials') {
309
318
  const lines = text.split('\n');
310
319
  check(lines.length === part.socials.length && lines.every(line => line.trim()), 'Current social profile lines no longer match their platforms.');
@@ -330,6 +339,14 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
330
339
  return candidates;
331
340
  }
332
341
 
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
+
333
350
  // A current native date field wins. Full-mode static-wrap evidence can recover
334
351
  // generated OPF intent without turning the existing PPTX text into a live field. A fixed
335
352
  // date keeps its ISO value and pattern only when the current text round-trips.
@@ -368,6 +385,13 @@ export function importFurniture(contexts, entries, onDiagnostic) {
368
385
  delete slide[kind]; report(index, `${kind}: Current organization metadata disagrees across repeated fields.`);
369
386
  }
370
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
+ }
371
395
  for (const [index, slide] of candidates.entries()) {
372
396
  const sections = Object.values(slide).flatMap(candidate => candidate.sections);
373
397
  if (sections.some(section => section !== sections[0])) for (const kind of kinds) if (slide[kind]?.sections.length) {
@@ -398,7 +422,7 @@ export function importFurniture(contexts, entries, onDiagnostic) {
398
422
  }
399
423
  if (!accepted.length) return;
400
424
  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: []};
425
+ if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], speakers: [], socials: [], sections: []};
402
426
  else {
403
427
  if (existing.value === false) existing.value = {};
404
428
  for (const zone of Object.keys(merged)) existing.value[zone] = {...existing.value[zone], ...merged[zone]};
@@ -417,6 +441,9 @@ export function importFurniture(contexts, entries, onDiagnostic) {
417
441
  if (result.organization && validSocials.length) result.organization = {...result.organization, socials: validSocials[0].socials};
418
442
  // Stored document metadata (FF-32) must not override disagreeing visible names.
419
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;
420
447
  for (const kind of kinds) {
421
448
  const inherited = candidates.map(slide => slide[kind]).filter(candidate => candidate?.scope === 'global');
422
449
  if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
@@ -1,6 +1,7 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
2
  import {attachTextTags,decodeTextTag} from './code-provenance.js';
3
3
  import {sourceLineParagraphs} from './text-provenance.js';
4
+ import {cleanBase,headingValue,joinRichLines} from './rich-heading.js';
4
5
  const TAG='OPF_HEADING_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
5
6
  const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
6
7
  const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
@@ -8,7 +9,8 @@ export const attachHeadingTags=(entries,records)=>attachTextTags(entries,records
8
9
 
9
10
  // Tags carry only roles and line ordering. Current native text always wins;
10
11
  // neither original source text nor old geometry can be restored from these tags.
11
- export function importHeadingGroups(shapes,paragraphs,relationships,entries,report) {
12
+ // `readBody` (optional) is the native body reader: with it a group also returns `richText`, the heading as string | TextRun[] (FA-10).
13
+ export function importHeadingGroups(shapes,paragraphs,relationships,entries,report,readBody) {
12
14
  const groups=new Map(),consumed=new Set(),items=[];
13
15
  for(const [index,shape] of shapes.entries()) {
14
16
  const tags=[];let unreadable=false;
@@ -37,8 +39,13 @@ export function importHeadingGroups(shapes,paragraphs,relationships,entries,repo
37
39
  }
38
40
  const restored=sourceLineParagraphs(ordered,paragraphs,{legacy:true});
39
41
  for(const item of ordered)consumed.add(item.shape);
40
- items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored});
41
- report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Formatting and geometry are not reconstructed.'});
42
+ // The same boundaries as the plain text: a new tag's separator, or a native line break between legacy-tagged lines.
43
+ const source=ordered.some(item=>item.data.boundary!==undefined||item.data.separator!==undefined);
44
+ // Only a rich export records the heading's own look (tag.base); a heading exported as a plain string stays one.
45
+ const base=cleanBase(ordered[0].data.base);
46
+ const parts=base?joinRichLines(ordered,readBody,(item,last)=>source?item.data.separator:last?'':'\n'):undefined;
47
+ items.push({field:first.field,shapes:ordered.map(item=>item.shape),paragraphs:restored,...(parts?{richText:headingValue(parts,base)}:{})});
48
+ report({code:'heading-import-reflow',message:'Complete tagged heading lines retain their roles, order and current native text. New boundary tags also retain authored whitespace and line endings; legacy tags retain native line breaks. Run formatting that differs between words is read back as TextRun[]; geometry is not reconstructed.'});
42
49
  }catch(error){report({code:'invalid-heading-provenance',message:`${error.message} Ordinary import retains visible native text.`});}
43
50
  return {consumed,items};
44
51
  }
@@ -11,6 +11,9 @@ export async function webpToPng(bytes) {
11
11
  // is deterministic (no system fonts, no network or file access for the SVG's own references). `text` selects the
12
12
  // bundled fonts; an SVG with no text needs none. Rejects with code `svg-rasterizer-unavailable` when opf-render is
13
13
  // not installed.
14
+ //
15
+ // This is the one place the exporter calls the renderer. opf-render's svgToPng(svg, {fonts, scale, background}) takes the
16
+ // fonts handle for the faces it may draw with: here only the bundled ones, never the system's.
14
17
  export async function svgToPng(svg, {scale, text}) {
15
18
  let render;
16
19
  try {
@@ -20,7 +23,7 @@ export async function svgToPng(svg, {scale, text}) {
20
23
  unavailable.code = 'svg-rasterizer-unavailable';
21
24
  throw unavailable;
22
25
  }
23
- return new Uint8Array(await render(svg, {scale, background: 'rgba(0, 0, 0, 0)', useBundledFonts: text === true, loadSystemFonts: false}));
26
+ return new Uint8Array(await render(svg, {fonts: {useBundledFonts: text === true, loadSystemFonts: false}, scale, background: 'rgba(0, 0, 0, 0)'}));
24
27
  }
25
28
 
26
29
  // A local SVG file (a path source), read as bytes; PptxGenJS reads raster paths itself.
@@ -0,0 +1,273 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
3
+ import {attachTextTags, decodeTextTag} from './code-provenance.js';
4
+
5
+ // FA-23 (OPF 0.15): image blocks and image backgrounds as native pictures.
6
+ //
7
+ // Every image block (core ComposedItem.image) exports as one native picture at core's frame. Its fit and focus are an
8
+ // a:srcRect; its treatments keep the DrawingML mapping 0.14's slide image used: the mask is a:prstGeom with core's guide
9
+ // values, the border an a:ln centered on the outline with a miter join, and the recolor and opacity a:grayscl or
10
+ // a:duotone then a:alphaModFix on the blip. An overlay is one native shape directly above its picture.
11
+ //
12
+ // An image background (core SlideComposition.backgroundImage) with alt text is a full-slide picture at the back of the
13
+ // slide (screen readers read its descr); without alt it is the native slide background fill (src/background.js), and its
14
+ // overlay is the first shape of the slide.
15
+ //
16
+ // OPF_IMAGE_V1 binds a picture to the image block's (or background's) own authored fields and the exact native geometry it
17
+ // was written with, so an unchanged picture imports back with its treatments and placement while an edited one stays an
18
+ // ordinary picture. OPF_IMAGE_OVERLAY_V1 does the same for an overlay shape: an edited overlay drops only the overlay.
19
+ export const IMAGE_TAG = 'OPF_IMAGE_V1', IMAGE_OVERLAY_TAG = 'OPF_IMAGE_OVERLAY_V1';
20
+ const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
21
+ const EMUS_PER_INCH = 914400;
22
+ const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false});
23
+ const decoder = new TextDecoder('utf-8', {fatal:true}), encoder = new TextEncoder();
24
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
25
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ // Whitespace-only text between elements is not identity: the vendored writer indents, PowerPoint drops it on save.
27
+ const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
28
+ ? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
29
+
30
+ /** An image block's own fields (everything but its type, asset, id, caption and extensions). */
31
+ export const IMAGE_TREATMENT_KEYS = Object.freeze(['fit', 'focus', 'shape', 'cornerRadius', 'border', 'opacity', 'recolor', 'overlay', 'aspectRatio', 'placement']);
32
+ /** An image background's own fields besides its source. */
33
+ export const BACKGROUND_IMAGE_KEYS = Object.freeze(['alt', 'fit', 'focus', 'opacity', 'recolor', 'overlay']);
34
+
35
+ export const imageName = number => `OPF image ${number}`;
36
+ export const imageOverlayName = pictureName => `${pictureName} overlay`;
37
+ export const backgroundImageName = slide => `OPF background ${slide}`;
38
+ export const backgroundOverlayName = slide => `OPF background overlay ${slide}`;
39
+
40
+ // DrawingML for the shared treatment: preset geometry (core guide values), a centered solid line, and blip
41
+ // recolor/alpha effects applied in that order.
42
+ const presetXml = shape => `<a:prstGeom prst="${shape?.preset ?? 'rect'}"><a:avLst>${Object.entries(shape?.adjust ?? {}).map(([name, value]) => `<a:gd name="${name}" fmla="val ${value}"/>`).join('')}</a:avLst></a:prstGeom>`;
43
+ const colorXml = ({hex, alpha}, extraAlpha = 1) => {
44
+ const value = Math.round(alpha * extraAlpha * 100000);
45
+ return `<a:srgbClr val="${hex}">${value < 100000 ? `<a:alpha val="${value}"/>` : ''}</a:srgbClr>`;
46
+ };
47
+ const lineXml = border => border ? `<a:ln w="${Math.round(border.width / 96 * EMUS_PER_INCH)}" algn="ctr"><a:solidFill>${colorXml(border)}</a:solidFill><a:miter lim="800000"/></a:ln>` : '';
48
+ function blipEffects(effects) {
49
+ let xml = '';
50
+ if (effects?.recolor?.type === 'grayscale') xml += '<a:grayscl/>';
51
+ if (effects?.recolor?.type === 'duotone') xml += `<a:duotone>${colorXml({...effects.recolor.dark, alpha: 1})}${colorXml({...effects.recolor.light, alpha: 1})}</a:duotone>`;
52
+ if (typeof effects?.opacity === 'number') xml += `<a:alphaModFix amt="${Math.round(effects.opacity * 100000)}"/>`;
53
+ return xml;
54
+ }
55
+
56
+ // Native blip fill without its package-local relationship id.
57
+ function blipFillIdentity(blipFill) {
58
+ const {['a:blip']: blip, ...rest} = normalizeCrop(blipFill) ?? {};
59
+ const {['r:embed']: _embed, ...blipRest} = withoutSvgBlip(blip) ?? {};
60
+ return {...rest, 'a:blip': blipRest};
61
+ }
62
+
63
+ // The focus point in the stored picture's own axes. DrawingML crops (a:srcRect) the picture before it flips and turns it
64
+ // (the EXIF orientation is written as the frame's rot/flipH/flipV), so a point the author placed on the displayed
65
+ // picture maps back through that orientation.
66
+ function storedFocus({x, y}, orientation) {
67
+ switch (orientation) {
68
+ case 2: return {x: 1 - x, y};
69
+ case 3: return {x: 1 - x, y: 1 - y};
70
+ case 4: return {x, y: 1 - y};
71
+ case 5: return {x: y, y: x};
72
+ case 6: return {x: y, y: 1 - x};
73
+ case 7: return {x: 1 - y, y: 1 - x};
74
+ case 8: return {x: 1 - y, y: x};
75
+ default: return {x, y};
76
+ }
77
+ }
78
+
79
+ const srcRectValue = value => Math.round(value * 100000) || 0;
80
+
81
+ /**
82
+ * The native frame and crop of a picture: `box` (inches) is core's picture frame, `fit` cover | contain | stretch,
83
+ * `focus` core's focus point, `metadata` the embedded raster (width, height, orientation). `fitImage` is core's shared
84
+ * fit math. A plain contain picture (`shrink`: a rectangle with no line or overlay) is framed by the picture itself, as
85
+ * 0.14 content images were; every other picture keeps the frame and pads with negative a:srcRect insets, so the mask,
86
+ * line and overlay apply to core's frame.
87
+ */
88
+ export function pictureFrame(metadata, {box, fit, focus = {x: .5, y: .5}, shrink = false}, fitImage) {
89
+ const orientation = metadata.orientation ?? 1;
90
+ const target = orientation >= 5 ? {x: box.x + (box.w - box.h) / 2, y: box.y + (box.h - box.w) / 2, w: box.h, h: box.w} : box;
91
+ const rotation = [0, 0, 0, 180, 0, 90, 90, 90, 270][orientation];
92
+ const transform = {rotation, flipH: orientation === 2 || orientation === 7, flipV: orientation === 4 || orientation === 5};
93
+ // A tiled picture fills its frame with the picture at its intrinsic size; nothing is cropped.
94
+ if (fit === 'tile') return {...target, crop: null, ...transform};
95
+ const placed = fitImage({x: target.x, y: target.y, width: target.w, height: target.h}, fit, metadata.width / metadata.height, storedFocus(focus, orientation));
96
+ if (shrink && fit === 'contain') return {x: placed.image.x, y: placed.image.y, w: placed.image.width, h: placed.image.height, crop: null, ...transform};
97
+ const crop = {l: srcRectValue(placed.crop.left), t: srcRectValue(placed.crop.top), r: srcRectValue(placed.crop.right), b: srcRectValue(placed.crop.bottom)};
98
+ return {...target, crop: Object.values(crop).some(Boolean) ? crop : null, ...transform};
99
+ }
100
+
101
+ /**
102
+ * Place each generated image picture at its frame, write its treatment, place its overlay shape, then bind the tagged
103
+ * ones to their manifests. Runs after PptxGenJS embedded the bytes (the frame needs the raster's own size).
104
+ * images: Map<objectName, {slide, role: 'block' | 'background', path, box (inches), fit, focus, shrink, effects, treatment}>;
105
+ * `treatment` (the authored fields) is stored in OPF_IMAGE_V1 when present.
106
+ * overlays: Map<objectName, {slide, owner, box (reference px), shape, hex, alpha, opacity, overlay}>; `owner` is the
107
+ * picture's objectName, or 'background' for the overlay of a native slide background fill; `overlay` (the authored value)
108
+ * is stored in OPF_IMAGE_OVERLAY_V1.
109
+ */
110
+ export function placeImages(entries, images, overlays, {metadataFor, fail, fitImage}) {
111
+ if (!images.size && !overlays.size) return;
112
+ const manifests = new Map(), overlayManifests = new Map(), placed = new Set(), shaped = new Set();
113
+ for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
114
+ const xml = decoder.decode(entries[part]);
115
+ let next = xml.replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
116
+ const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
117
+ const image = images.get(name);
118
+ if (!image) return picture;
119
+ const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
120
+ const metadata = metadataFor(part, embed);
121
+ if (!metadata) fail(image.path);
122
+ const frame = pictureFrame(metadata, image, fitImage);
123
+ const emu = value => Math.round(value * EMUS_PER_INCH);
124
+ const attributes = `${frame.rotation ? ` rot="${frame.rotation * 60000}"` : ''}${frame.flipH ? ' flipH="1"' : ''}${frame.flipV ? ' flipV="1"' : ''}`;
125
+ picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${attributes}><a:off x="${emu(frame.x)}" y="${emu(frame.y)}"/><a:ext cx="${emu(frame.w)}" cy="${emu(frame.h)}"/></a:xfrm>`);
126
+ picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
127
+ if (frame.crop) picture = picture.replace('<a:stretch>', `<a:srcRect l="${frame.crop.l}" t="${frame.crop.t}" r="${frame.crop.r}" b="${frame.crop.b}"/><a:stretch>`);
128
+ if (image.fit === 'tile') picture = picture.replace(/<a:stretch>[\s\S]*?<\/a:stretch>|<a:stretch\/>/, `<a:tile tx="0" ty="0" sx="${srcRectValue((metadata.dpiX ?? 96) / 96)}" sy="${srcRectValue((metadata.dpiY ?? 96) / 96)}" flip="none" algn="tl"/>`);
129
+ const effects = image.effects;
130
+ if (effects) {
131
+ picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, presetXml(effects.shape) + lineXml(effects.border));
132
+ const blip = blipEffects(effects);
133
+ if (blip) picture = picture.replace(/<a:blip\b([^>]*?)(?:\/>|>([\s\S]*?)<\/a:blip>)/, (_, attributes, inner = '') => `<a:blip${attributes}>${blip}${inner}</a:blip>`);
134
+ }
135
+ placed.add(name);
136
+ if (image.treatment !== undefined) {
137
+ const node = parser.parse(picture)['p:pic'];
138
+ manifests.set(name, {v: 1, role: image.role, slide: image.slide, path: image.path, treatment: image.treatment, properties: node['p:spPr'], blipFill: blipFillIdentity(node['p:blipFill'])});
139
+ }
140
+ return picture;
141
+ });
142
+ next = next.replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => {
143
+ const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
144
+ const overlay = overlays.get(name);
145
+ if (!overlay) return shape;
146
+ const emu = value => Math.round(value / 96 * EMUS_PER_INCH);
147
+ shape = shape.replace(/<p:spPr>[\s\S]*?<\/p:spPr>/, `<p:spPr><a:xfrm><a:off x="${emu(overlay.box.x)}" y="${emu(overlay.box.y)}"/><a:ext cx="${emu(overlay.box.width)}" cy="${emu(overlay.box.height)}"/></a:xfrm>${presetXml(overlay.shape)}<a:solidFill>${colorXml(overlay, overlay.opacity)}</a:solidFill><a:ln><a:noFill/></a:ln></p:spPr>`);
148
+ const node = parser.parse(shape)['p:sp'];
149
+ overlayManifests.set(name, {v: 1, slide: overlay.slide, owner: overlay.owner, overlay: overlay.overlay, properties: node['p:spPr']});
150
+ shaped.add(name);
151
+ return shape;
152
+ });
153
+ if (next !== xml) entries[part] = encoder.encode(next);
154
+ }
155
+ if (placed.size !== images.size) throw new Error('Missing generated image picture.');
156
+ if (shaped.size !== overlays.size) throw new Error('Missing generated image overlay.');
157
+ attachTextTags(entries, manifests, IMAGE_TAG, 'opfImage', 'image', {pictures: true});
158
+ attachTextTags(entries, overlayManifests, IMAGE_OVERLAY_TAG, 'opfImageOverlay', 'image overlay');
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Import
163
+
164
+ function readTags(container, relationships, entries) {
165
+ const tags = [];
166
+ let unreadable = false;
167
+ for (const link of array(container?.['p:tags'])) {
168
+ const rel = relationships.get(link['r:id']);
169
+ if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) { unreadable = true; continue; }
170
+ try { tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag'])); } catch { unreadable = true; }
171
+ }
172
+ return {tags, unreadable};
173
+ }
174
+
175
+ // The one tag of `name` on a shape, decoded, or null (absent); throws when it is ambiguous or sits beside another OPF tag.
176
+ function ownTag(container, relationships, entries, name) {
177
+ const {tags, unreadable} = readTags(container, relationships, entries);
178
+ const own = tags.filter(tag => tag.name?.toUpperCase() === name);
179
+ if (!own.length) return null;
180
+ if (unreadable || own.length !== 1 || tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== name)) throw Error('Ambiguous image identity.');
181
+ return decodeTextTag(own[0].val);
182
+ }
183
+
184
+ const pick = (value, keys) => Object.fromEntries(keys.filter(key => Object.hasOwn(value, key)).map(key => [key, value[key]]));
185
+
186
+ /**
187
+ * Recover image blocks and an image background from unchanged tagged pictures and overlays of one slide.
188
+ * - `blocks`: Map<picture index, fields> to merge into the ordinary image block read from that picture;
189
+ * - `background`: the slide's image background from its tagged back picture, with the picture's index in `consumed`;
190
+ * - `backgroundOverlay`: the authored overlay of a native slide background fill;
191
+ * - `consumedShapes`: the overlay shapes recovered (they are no content).
192
+ * readPicture(picture) returns the ordinary imported image payload; `valid(kind, value)` checks a recovered image block
193
+ * (`kind` 'block') or background (`kind` 'background') against the OPF format.
194
+ */
195
+ export function importImages({pictures, shapes, relationships, entries, slideIndex, readPicture, valid, report}) {
196
+ const slide = `slides.${slideIndex}`;
197
+ const blocks = new Map(), consumed = new Set(), consumedShapes = new Set();
198
+ let background, backgroundOverlay;
199
+ const invalid = (path, message) => report({code: 'invalid-image-provenance', path, message});
200
+ // Overlay shapes first: each names the picture (or the slide background) it belongs to.
201
+ const overlays = new Map();
202
+ for (const [index, shape] of shapes.entries()) {
203
+ let manifest;
204
+ try { manifest = ownTag(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries, IMAGE_OVERLAY_TAG); }
205
+ catch { invalid(slide, 'A tagged OPF image overlay is ambiguous; it was imported as an ordinary shape.'); continue; }
206
+ if (manifest === null) continue;
207
+ const name = shape['p:nvSpPr']?.['p:cNvPr']?.name;
208
+ const expected = manifest?.owner === 'background' ? backgroundOverlayName(slide) : typeof manifest?.owner === 'string' ? imageOverlayName(manifest.owner) : undefined;
209
+ if (manifest?.v !== 1 || manifest.slide !== slide || name !== expected || !object(manifest.overlay) || canonical(shape['p:spPr']) !== canonical(manifest.properties)) {
210
+ overlays.set(manifest?.owner, {index, edited: true});
211
+ continue;
212
+ }
213
+ if (overlays.has(manifest.owner)) { overlays.set(manifest.owner, {index, edited: true}); continue; }
214
+ overlays.set(manifest.owner, {index, overlay: manifest.overlay});
215
+ }
216
+ const takeOverlay = (owner, path) => {
217
+ const found = overlays.get(owner);
218
+ if (!found) return undefined;
219
+ if (found.edited) {
220
+ invalid(path, 'The tagged OPF image overlay is missing, edited or ambiguous, so the image is recovered without its overlay; the remaining native shape is imported as ordinary content.');
221
+ return undefined;
222
+ }
223
+ consumedShapes.add(found.index);
224
+ return found.overlay;
225
+ };
226
+ const edges = new Set();
227
+ for (const [index, picture] of pictures.entries()) {
228
+ let manifest;
229
+ try { manifest = ownTag(picture['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries, IMAGE_TAG); }
230
+ catch { invalid(slide, 'A tagged OPF image is ambiguous; it was imported as an ordinary picture.'); continue; }
231
+ if (manifest === null) continue;
232
+ const name = picture['p:nvPicPr']?.['p:cNvPr']?.name;
233
+ const role = manifest?.role;
234
+ const path = role === 'background' ? `${slide}.design.background` : slide;
235
+ try {
236
+ if (manifest?.v !== 1 || manifest.slide !== slide || !['block', 'background'].includes(role) || !object(manifest.treatment)) throw Error('Invalid image manifest.');
237
+ if (role === 'block' ? !/^OPF image \d+$/.test(name ?? '') : name !== backgroundImageName(slide)) throw Error('Image identity changed.');
238
+ if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(normalizeCrop(manifest.blipFill))) throw Error('Image geometry or effects changed.');
239
+ } catch {
240
+ invalid(path, `An edited, ambiguous or invalid tagged OPF ${role === 'background' ? 'background picture' : 'image'} was imported as an ordinary picture; its OPF ${role === 'background' ? 'background' : 'treatment and placement'} are not reconstructed.`);
241
+ continue;
242
+ }
243
+ if (role === 'background') {
244
+ const item = readPicture(picture);
245
+ const src = item?.payload?.image?.src;
246
+ const fields = pick(manifest.treatment, BACKGROUND_IMAGE_KEYS.filter(key => key !== 'overlay'));
247
+ const overlay = takeOverlay(name, `${path}.overlay`);
248
+ const value = {type: 'image', src, ...fields, ...(overlay ? {overlay} : {})};
249
+ if (typeof src !== 'string' || background || !valid('background', value)) { invalid(path, 'A tagged OPF background picture does not form a valid image background; it was imported as an ordinary picture.'); continue; }
250
+ background = value;
251
+ consumed.add(index);
252
+ continue;
253
+ }
254
+ const fields = pick(manifest.treatment, IMAGE_TREATMENT_KEYS.filter(key => key !== 'overlay'));
255
+ const overlay = takeOverlay(name, `${path}.overlay`);
256
+ if (overlay) fields.overlay = overlay;
257
+ // At most one placed block per edge: a copied tagged picture keeps its other fields and flows.
258
+ const edge = fields.placement?.edge;
259
+ if (edge !== undefined && edges.has(edge)) { delete fields.placement; invalid(path, `Two tagged OPF images are placed on the ${edge} edge; the second imports without its placement.`); }
260
+ else if (edge !== undefined) edges.add(edge);
261
+ const item = readPicture(picture);
262
+ const candidate = {...item?.payload, ...fields};
263
+ if (!item?.payload || !valid('block', candidate)) { invalid(path, 'A tagged OPF image does not form a valid image block; it was imported as an ordinary picture.'); if (edge !== undefined) edges.delete(edge); continue; }
264
+ blocks.set(index, fields);
265
+ }
266
+ // The overlay of a native slide background fill.
267
+ backgroundOverlay = takeOverlay('background', `${slide}.design.background.overlay`);
268
+ const backgroundOverlayShape = backgroundOverlay ? overlays.get('background').index : undefined;
269
+ for (const [owner, found] of overlays) if (!found.edited && !consumedShapes.has(found.index) && owner !== 'background') {
270
+ invalid(`${slide}`, 'A tagged OPF image overlay no longer has its picture; it was imported as an ordinary shape.');
271
+ }
272
+ return {blocks, background, backgroundOverlay, backgroundOverlayShape, consumed, consumedShapes};
273
+ }