@openpresentation/opf-pptx 0.20.3 → 0.22.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.
@@ -32,6 +32,19 @@ export const IMAGE_TREATMENT_KEYS = Object.freeze(['fit', 'focus', 'shape', 'cor
32
32
  /** An image background's own fields besides its source. */
33
33
  export const BACKGROUND_IMAGE_KEYS = Object.freeze(['alt', 'fit', 'focus', 'opacity', 'recolor', 'overlay']);
34
34
 
35
+ /**
36
+ * RR-94: the authored `src` an image block records so a re-import can give it back instead of a data: URI: a relative path
37
+ * (no scheme, not absolute, no `..` segment) or an http(s) URL, as authored. Anything else (a data: URI, an `asset:` reference,
38
+ * a file: URL, an absolute or parent-relative path, which could name any file of the machine that re-exports it) is not recorded.
39
+ */
40
+ export function recordableImageSource(value) {
41
+ const src = typeof value === 'string' ? value : object(value) && typeof value.src === 'string' ? value.src : undefined;
42
+ if (src === undefined || src.length === 0 || src.length > 2048 || /[\u0000-\u001f\u007f]/.test(src)) return undefined;
43
+ if (/^https?:\/\/[^/\s]+/i.test(src)) return src;
44
+ if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(src) || /^[\\/]/.test(src) || src.split(/[\\/]/).includes('..')) return undefined;
45
+ return src;
46
+ }
47
+
35
48
  export const imageName = number => `OPF image ${number}`;
36
49
  export const imageOverlayName = pictureName => `${pictureName} overlay`;
37
50
  export const backgroundImageName = slide => `OPF background ${slide}`;
@@ -78,6 +91,37 @@ function storedFocus({x, y}, orientation) {
78
91
 
79
92
  const srcRectValue = value => Math.round(value * 100000) || 0;
80
93
 
94
+ /** Whether native dimensions can represent the raster's unchanged aspect. */
95
+ export function samePictureAspect(cx, cy, width, height) {
96
+ if (![cx, cy, width, height].every(value => Number.isSafeInteger(value) && value > 0)) return false;
97
+ // Native writers may round an aspect-preserving frame directly to EMUs.
98
+ if (Math.round(cx * height / width) === cy || Math.round(cy * width / height) === cx) return true;
99
+ // pictureFrame passes inches to core fitImage, whose roundBox rounds each
100
+ // dimension to 1e-6 input units, before placeImages rounds to whole EMUs.
101
+ // Invert that actual serialization, rather than allowing a visual tolerance.
102
+ const microinches = extent => {
103
+ // 914400 / 1e6 = 1143 / 1250. A rounded EMU k covers
104
+ // [1250*k-625, 1250*k+625) / 1143 integer microinches.
105
+ const scaled = BigInt(extent) * 1250n;
106
+ const lower = (scaled - 625n + 1142n) / 1143n;
107
+ const upper = (scaled + 625n + 1142n) / 1143n;
108
+ const result = [];
109
+ // Include adjacent integers for a floating-point half-EMU tie, then keep
110
+ // only dimensions that reproduce the writer's actual numeric operation.
111
+ for (let value = lower - 1n; value <= upper; value++) {
112
+ const numeric = Number(value);
113
+ if (numeric > 0 && Number.isSafeInteger(numeric) && Math.round(numeric / 1e6 * EMUS_PER_INCH) === extent) result.push(value);
114
+ }
115
+ return result;
116
+ };
117
+ const w = BigInt(width), h = BigInt(height);
118
+ // q=round(x*1e6) represents [q-.5,q+.5). These two half-open
119
+ // intervals must overlap at the raster's exact rational aspect. Strict
120
+ // overlap excludes a boundary where only one rounding cell contains it.
121
+ return microinches(cx).some(x => microinches(cy).some(y =>
122
+ (2n * x - 1n) * h < (2n * y + 1n) * w && (2n * y - 1n) * w < (2n * x + 1n) * h));
123
+ }
124
+
81
125
  /**
82
126
  * The native frame and crop of a picture: `box` (inches) is core's picture frame, `fit` cover | contain | stretch,
83
127
  * `focus` core's focus point, `metadata` the embedded raster (width, height, orientation). `fitImage` is core's shared
@@ -107,7 +151,7 @@ export function pictureFrame(metadata, {box, fit, focus = {x: .5, y: .5}, shrink
107
151
  * picture's objectName, or 'background' for the overlay of a native slide background fill; `overlay` (the authored value)
108
152
  * is stored in OPF_IMAGE_OVERLAY_V1.
109
153
  */
110
- export function placeImages(entries, images, overlays, {metadataFor, fail, fitImage}) {
154
+ export function placeImages(entries, images, overlays, {metadataFor, fail, fitImage, mediaKeyFor}) {
111
155
  if (!images.size && !overlays.size) return;
112
156
  const manifests = new Map(), overlayManifests = new Map(), placed = new Set(), shaped = new Set();
113
157
  for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
@@ -135,7 +179,9 @@ export function placeImages(entries, images, overlays, {metadataFor, fail, fitIm
135
179
  placed.add(name);
136
180
  if (image.treatment !== undefined) {
137
181
  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'])});
182
+ const media = mediaKeyFor ? mediaKeyFor(part, embed) : undefined;
183
+ 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']),
184
+ ...(media !== undefined ? {media} : {}), ...(media !== undefined && image.src !== undefined ? {src: image.src} : {})});
139
185
  }
140
186
  return picture;
141
187
  });
@@ -192,9 +238,9 @@ const pick = (value, keys) => Object.fromEntries(keys.filter(key => Object.hasOw
192
238
  * readPicture(picture) returns the ordinary imported image payload; `valid(kind, value)` checks a recovered image block
193
239
  * (`kind` 'block') or background (`kind` 'background') against the OPF format.
194
240
  */
195
- export function importImages({pictures, shapes, relationships, entries, slideIndex, readPicture, valid, report}) {
241
+ export function importImages({pictures, shapes, relationships, entries, slideIndex, readPicture, valid, report, mediaKeyOf = () => undefined}) {
196
242
  const slide = `slides.${slideIndex}`;
197
- const blocks = new Map(), consumed = new Set(), consumedShapes = new Set();
243
+ const blocks = new Map(), sources = new Map(), consumed = new Set(), consumedShapes = new Set();
198
244
  let background, backgroundOverlay;
199
245
  const invalid = (path, message) => report({code: 'invalid-image-provenance', path, message});
200
246
  // Overlay shapes first: each names the picture (or the slide background) it belongs to.
@@ -223,6 +269,10 @@ export function importImages({pictures, shapes, relationships, entries, slideInd
223
269
  consumedShapes.add(found.index);
224
270
  return found.overlay;
225
271
  };
272
+ const restoredSource = (manifest, picture) => {
273
+ const key = typeof manifest.media === 'string' ? mediaKeyOf(picture) : undefined;
274
+ return key !== undefined && key === manifest.media && recordableImageSource(manifest.src) === manifest.src ? manifest.src : undefined;
275
+ };
226
276
  const edges = new Set();
227
277
  for (const [index, picture] of pictures.entries()) {
228
278
  let manifest;
@@ -231,18 +281,24 @@ export function importImages({pictures, shapes, relationships, entries, slideInd
231
281
  if (manifest === null) continue;
232
282
  const name = picture['p:nvPicPr']?.['p:cNvPr']?.name;
233
283
  const role = manifest?.role;
284
+ // RR-94: a plain block (no treatment) carries a tag only for its source and its crop to stay quiet. Once its bytes are another
285
+ // picture's (Change Picture), or it was moved or resized, the tag has nothing to give back: it reads as the ordinary picture.
286
+ const plain = role === 'block' && object(manifest?.treatment) && Object.keys(manifest.treatment).length === 0;
287
+ if (plain && typeof manifest.media === 'string' && mediaKeyOf(picture) !== manifest.media) continue;
234
288
  const path = role === 'background' ? `${slide}.design.background` : slide;
235
289
  try {
236
290
  if (manifest?.v !== 1 || manifest.slide !== slide || !['block', 'background'].includes(role) || !object(manifest.treatment)) throw Error('Invalid image manifest.');
237
291
  if (role === 'block' ? !/^OPF image \d+$/.test(name ?? '') : name !== backgroundImageName(slide)) throw Error('Image identity changed.');
238
292
  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
293
  } catch {
294
+ if (plain) continue;
240
295
  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
296
  continue;
242
297
  }
243
298
  if (role === 'background') {
244
299
  const item = readPicture(picture);
245
- const src = item?.payload?.image?.src;
300
+ // RR-94: an unchanged picture (its bytes still hash as exported) gives back the authored relative path or URL.
301
+ const src = restoredSource(manifest, picture) ?? item?.payload?.image?.src;
246
302
  const fields = pick(manifest.treatment, BACKGROUND_IMAGE_KEYS.filter(key => key !== 'overlay'));
247
303
  const overlay = takeOverlay(name, `${path}.overlay`);
248
304
  const value = {type: 'image', src, ...fields, ...(overlay ? {overlay} : {})};
@@ -259,9 +315,11 @@ export function importImages({pictures, shapes, relationships, entries, slideInd
259
315
  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
316
  else if (edge !== undefined) edges.add(edge);
261
317
  const item = readPicture(picture);
262
- const candidate = {...item?.payload, ...fields};
318
+ const source = restoredSource(manifest, picture);
319
+ const candidate = {...item?.payload, ...fields, ...(source !== undefined && object(item?.payload?.image) ? {image: {...item.payload.image, src: source}} : {})};
263
320
  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
321
  blocks.set(index, fields);
322
+ if (source !== undefined && object(item.payload.image)) sources.set(index, source);
265
323
  }
266
324
  // The overlay of a native slide background fill.
267
325
  backgroundOverlay = takeOverlay('background', `${slide}.design.background.overlay`);
@@ -269,7 +327,7 @@ export function importImages({pictures, shapes, relationships, entries, slideInd
269
327
  for (const [owner, found] of overlays) if (!found.edited && !consumedShapes.has(found.index) && owner !== 'background') {
270
328
  invalid(`${slide}`, 'A tagged OPF image overlay no longer has its picture; it was imported as an ordinary shape.');
271
329
  }
272
- return {blocks, background, backgroundOverlay, backgroundOverlayShape, consumed, consumedShapes};
330
+ return {blocks, sources, background, backgroundOverlay, backgroundOverlayShape, consumed, consumedShapes};
273
331
  }
274
332
 
275
333
  // opf-pptx#221: the "Image unavailable" placeholder of an image block whose source could not be embedded (a remote URL is never
package/dist/index.d.ts CHANGED
@@ -34,6 +34,13 @@ export interface ImageResolverContext {
34
34
  }
35
35
 
36
36
  export interface MediaProvenanceDiagnostic { code: "media-provenance-omitted"; path: string; message: string }
37
+ /** An opt-in native body could not represent this payload; current measured shapes were retained. */
38
+ export interface NativeBodyFallbackDiagnostic {
39
+ code: "native-body-fallback";
40
+ path: string;
41
+ reason: "complex-composition" | "multi-column" | "unsupported-list" | "annotated-text" | "unsupported-text" | "overflow";
42
+ message: string;
43
+ }
37
44
  /** The chart data cannot be plotted, so a placeholder frame stands in for the chart. */
38
45
  export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"; path: string; message: string; reason: "dataset-unknown" | "no-rows" | "no-columns" | "single-column-not-numeric" }
39
46
  /** Content with no PowerPoint export (an empty table, an unsupported payload) is replaced by a plain-language placeholder frame. */
@@ -81,6 +88,27 @@ export interface VariableExampleUsedDiagnostic {
81
88
  }
82
89
 
83
90
  export interface ToPptxOptions {
91
+ /**
92
+ * How a footer or header text with words around `{{slide.number}}` (`{{slide.number}} / {{deck.slideCount}}`, `Page {{slide.number}}`)
93
+ * writes its number. "static" (default) writes the number as fixed text in an ordinary shape, so PowerPoint and Keynote both show
94
+ * `2 / 6`; the total is fixed either way (PowerPoint has no slide-count field). "live" keeps PowerPoint's slide-number field (in the
95
+ * Slide Number placeholder for the first footer one) with the words as fixed runs: Keynote replaces the whole paragraph that holds a
96
+ * slide-number field with the number alone, so it shows only `2`. A text that is only `{{slide.number}}` keeps its live field and
97
+ * placeholder in both modes. A fixed number does not follow slides moved or deleted in PowerPoint; the authored text returns on import (RR-91).
98
+ */
99
+ slideNumberFields?: "static" | "live";
100
+ /**
101
+ * Prototype: "native" exports each supported paragraph or flat-bullet body (since 0.22.0 also a one-sequence numbered list) as
102
+ * one native body with native wrapping and no autofit: the first body of a slide is the Body placeholder, a further body region (since 0.22.0) is an
103
+ * ordinary native text box at its region. It retains selected fonts, effective alignment and region anchor,
104
+ * but native applications can choose different wraps and baselines from the measured preview. Unsupported groups,
105
+ * shared body regions, nested/description/restarted-numbered/multicolumn lists, annotations and complex text retain
106
+ * measured shapes with native-body-fallback diagnostics. Native paragraph separators normalize to LF on import.
107
+ * A plain-string title that wraps over several lines (no hard break, no overflow, no rich runs) is also one native text box
108
+ * with native wrap and no autofit, bound to the Title placeholder (since 0.22.0); an unwrapped title binds as before.
109
+ * Omitted or "measured" preserves the current measured-line export. No native application qualification is implied (Keynote 15.1.1 was checked for Title/Body controls and reflow, PowerPoint was not).
110
+ */
111
+ bodyText?: "measured" | "native";
84
112
  /** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
85
113
  imageFormat?: "compatible" | "preserve";
86
114
  /**
@@ -108,6 +136,14 @@ export interface ToPptxOptions {
108
136
  * also written as PowerPoint's native section list whatever this option says.
109
137
  */
110
138
  provenance?: "full" | "references-only" | false;
139
+ /**
140
+ * Literal footer text: "native" (default) keeps the first eligible one-line footer text bound to PowerPoint's Footer
141
+ * placeholder. "shape" omits only that slide-local ftr binding, retaining its measured editable text shape, locks,
142
+ * formatting, geometry, paint order and provenance. It stays on the slide, including when repeated. Live slide-number
143
+ * and date fields/placeholders, master/layout footer placeholders and flags remain unchanged. The literal shape then
144
+ * has no native Footer-dialog ownership; this does not guarantee viewer parity or restore tags another application strips.
145
+ */
146
+ footerText?: "native" | "shape";
111
147
  /**
112
148
  * Values for the deck's template variables, keyed by variable id (core `resolveVariables`). A deck that uses content
113
149
  * variables, or is marked `template: true`, is resolved to a concrete deck first; the PPTX holds the resolved text.
@@ -126,7 +162,7 @@ export interface ToPptxOptions {
126
162
  /** Match preview/pagination clearance around supplied vector text outlines; default 1. */
127
163
  textRasterPadding?: number;
128
164
  /** 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. */
129
- onDiagnostic?: (diagnostic: LayoutDiagnostic | UnresolvedReferenceDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ChartValueNotNumericDiagnostic | ChartMappingAdaptedDiagnostic | DataProvenanceOmittedDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
165
+ onDiagnostic?: (diagnostic: LayoutDiagnostic | UnresolvedReferenceDiagnostic | MediaProvenanceDiagnostic | NativeBodyFallbackDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ChartValueNotNumericDiagnostic | ChartMappingAdaptedDiagnostic | DataProvenanceOmittedDiagnostic | ContentPlaceholderDiagnostic | UnresolvedAssetDiagnostic | SvgSanitizedDiagnostic | SvgImageRasterizedDiagnostic | VariableExampleUsedDiagnostic) => void;
130
166
  baseDir?: string;
131
167
  compressionLevel?: number;
132
168
  imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;