@openpresentation/opf-pptx 0.14.0 → 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.
@@ -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
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Finding, FontSchemeDiagnostic, Fonts, LayoutDiagnostic, SlideContextReferenceDiagnostic } from "@openpresentation/opf";
1
+ import type { Catalog, Finding, Fonts, LayoutDiagnostic, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
2
2
  export declare const packageName = "@openpresentation/opf-pptx";
3
3
 
4
4
  export declare const releaseLane: Readonly<{
@@ -50,7 +50,7 @@ export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; pat
50
50
  export interface UnresolvedAssetDiagnostic { code: "unresolved-asset"; path: string; message: string; reason?: "unsupported-format" | "svg-malformed" | "svg-no-size" | "svg-too-large" | "svg-unsafe" | "svg-rasterizer-unavailable" | "svg-render-failed" | "svg-unreadable" }
51
51
  /** An SVG picture had scripts, `foreignObject`, event handlers, references outside the file, `@import` rules or a DOCTYPE; they were removed from the embedded SVG (on import too). Nothing in an SVG is run or fetched. */
52
52
  export interface SvgSanitizedDiagnostic { code: "svg-sanitized"; path: string; message: string }
53
- /** An SVG slide image with a duotone recolor or a non-rectangular shape exports as its PNG raster, not as a native SVG picture, so the effect applies as in the preview (PowerPoint applies opacity, grayscale and a border to an SVG picture, and those stay native). */
53
+ /** An SVG image block with a duotone recolor or a non-rectangular shape (or an SVG image background with the tile fit) exports as its PNG raster, not as a native SVG picture, so the effect applies as in the preview (PowerPoint applies opacity, grayscale and a border to an SVG picture, and those stay native). */
54
54
  export interface SvgImageRasterizedDiagnostic { code: "svg-image-rasterized"; path: string; message: string }
55
55
  /**
56
56
  * The chart data was reshaped to export a native chart: a single value column was plotted against row numbers; a one-series construct
@@ -119,8 +119,8 @@ export interface ToPptxOptions {
119
119
  fonts?: Fonts;
120
120
  /** Match preview/pagination clearance around supplied vector text outlines; default 1. */
121
121
  textRasterPadding?: number;
122
- /** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, plus `unresolved-font-scheme` (once per reference path) when a font-scheme id matches no record and the default `aptos` scheme is used as the base, and, from core's slide context, `unresolved-theme`, `unresolved-color-scheme` and `unresolved-layout` (once per reference path) when an id matches no record: the default theme (`minimal`) and colour scheme (`cool-horizon`) are used, and a slide whose layout is unknown, like one with no layout, is composed with no layout record. None of them refuses the export. */
123
- onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | SlideContextReferenceDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ChartValueNotNumericDiagnostic | ChartMappingAdaptedDiagnostic | DataProvenanceOmittedDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
122
+ /** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, and, from core's slide context, `unresolved-reference` (once per reference path) when a layout, theme, colour-scheme or font-scheme reference resolves nowhere (not embedded in the document's `catalogs` and not in a registered catalog): core's engine defaults are drawn instead, and a slide whose layout resolves nowhere, like one with no layout, composes automatically. None of them refuses the export unless `strictReferences` is set. */
123
+ onDiagnostic?: (diagnostic: LayoutDiagnostic | UnresolvedReferenceDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ChartValueNotNumericDiagnostic | ChartMappingAdaptedDiagnostic | DataProvenanceOmittedDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
124
124
  baseDir?: string;
125
125
  compressionLevel?: number;
126
126
  imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
@@ -134,6 +134,13 @@ export interface ToPptxOptions {
134
134
  svgRasterizer?: (svg: string, size: { width: number; height: number; scale: number; text: boolean }) => Uint8Array | Promise<Uint8Array>;
135
135
  seed?: number;
136
136
  strictAssets?: boolean;
137
+ /**
138
+ * Strict reference policy (OPF 0.15). When true, a layout, theme, colour-scheme or font-scheme reference that resolves
139
+ * nowhere throws `OPFPptxError` with code `unresolved-reference` (its `path` is the first reference's, its `diagnostics`
140
+ * core's `unresolved-reference` diagnostics) instead of composing automatically or drawing with the engine default.
141
+ * Default: the value of `strictAssets`.
142
+ */
143
+ strictReferences?: boolean;
137
144
  timestamp?: string;
138
145
  /**
139
146
  * ZIP calendar timestamps, including embedded workbooks, use UTC fields at
@@ -150,21 +157,28 @@ export interface ToPptxOptions {
150
157
  * PowerPoint updates on open. Without it a current date is reported as unresolved content.
151
158
  */
152
159
  date?: string;
153
- /** Host-supplied catalog records, as in opf-render. Currently consulted for socialPlatforms (generated socials furniture). */
154
- catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
155
- /** Records for presentation `catalogs.<kind>.source` URLs (a single source or each entry of an ordered search path), as in opf-render. Currently consulted for socialPlatforms. */
156
- catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
160
+ /**
161
+ * The catalogs the host registered (core `Catalog[]`, as for opf-render and core's `resolveSlideContext`): each is matched
162
+ * against a document group's `catalogs.<group>.source`, and the first is the host default that bare ids use when the document
163
+ * omits `catalogs.default`. Never fetched. Every reference (slide layouts, themes, colour and font schemes) resolves in the
164
+ * document's embedded records first, then here; omitted or `[]`, only embedded records resolve. Register the gallery snapshot
165
+ * explicitly with `catalogs: [defaultCatalog]` from `@openpresentation/opf/catalog`.
166
+ */
167
+ catalogs?: readonly Catalog[];
157
168
  }
158
169
 
159
170
  export interface FromPptxOptions {
160
- /** 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 slide layout id that resolves to no inline or bundled record reports `unresolved-layout-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). */
171
+ /** 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). */
161
172
  onDiagnostic?: (diagnostic: {code: string; path: string; message: string}) => void;
162
173
  fallbackName?: string;
163
174
  schema?: string;
164
- /** The export's host catalog records (as in ToPptxOptions). Currently used to recognize unedited socials lines, so authored handles return. */
165
- catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
166
- /** The export's records for presentation `catalogs.<kind>.source` URLs (as in ToPptxOptions). Currently used for socialPlatforms. */
167
- catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
175
+ /**
176
+ * The catalogs the host registered (core `Catalog[]`, as in ToPptxOptions; never fetched). Theme and colour-scheme recovery
177
+ * (FF-24) matches the package's theme against the first one, the host default: an exact colour-scheme match imports as its
178
+ * bare id and a corroborated theme name as `design.theme`. Without catalogs the colour scheme imports as an inline object and
179
+ * no theme id is recovered. A stored slide layout reference also resolves here.
180
+ */
181
+ catalogs?: readonly Catalog[];
168
182
  /**
169
183
  * Opt in to raw per-shape signals (see PptxSignals). `true` uses the default limits; an object lowers or raises them.
170
184
  * With it, `fromPptx` resolves to `{presentation, signals}`; without it (the default, also `false`/`null`) it resolves
@@ -178,8 +192,8 @@ export declare class OPFPptxError extends Error {
178
192
  readonly details: Record<string, unknown>;
179
193
  /** The `error` findings of the `format` check that refused the input or the imported presentation (`invalid-opf`, `invalid-import-opf`), or the variable errors (`unfilled-variables`, `invalid-variables`). */
180
194
  readonly findings?: Finding[];
181
- /** The layout diagnostics behind a `layout-overflow` error. */
182
- readonly diagnostics?: LayoutDiagnostic[];
195
+ /** The layout diagnostics behind a `layout-overflow` error, or core's `unresolved-reference` diagnostics behind a strict-export `unresolved-reference` error. */
196
+ readonly diagnostics?: LayoutDiagnostic[] | UnresolvedReferenceDiagnostic[];
183
197
  readonly path?: string;
184
198
  constructor(code: string, message: string, details?: Record<string, unknown>);
185
199
  }
@@ -390,7 +404,8 @@ export interface PptxOutlineSignal extends PptxColor {
390
404
  export interface PptxOpfLink {
391
405
  /**
392
406
  * "block" fed a block (`path`, `blockType`); "title", "subtitle" and "tag" fed that slide field. Other roles name a shape the
393
- * importer consumed without a block of its own: "furniture" (footer, date, number, section), "slide-image", "watermark", "logo",
407
+ * importer consumed without a block of its own: "furniture" (footer, date, number, section), "background" (the picture of an image background),
408
+ * "image-overlay" (the overlay shape of an image background or image block), "watermark", "logo",
394
409
  * and the members of an OPF-tagged group ("code", "metric", "quote", "timeline", "media", "card-frame").
395
410
  */
396
411
  role: string;
@@ -489,7 +504,7 @@ export type ThemeScriptSelection = Partial<Record<"major" | "minor", Partial<Rec
489
504
  export type TypefaceViolationReason =
490
505
  | "foreign-typeface"
491
506
  | "foreign-theme-reference"
492
- | "unresolved-theme-reference"
507
+ | "theme-reference-unresolved"
493
508
  | "empty-theme-reference"
494
509
  | "empty-typeface"
495
510
  | "foreign-script-supplement"