@openpresentation/opf-pptx 0.14.0 → 0.16.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.
package/dist/index.js CHANGED
@@ -7,10 +7,10 @@ import {giveNotesMastersOwnThemes, giveSlidesScriptMasters} from './master-theme
7
7
  import {legacyVendorOutput} from './vendor-compat.js';
8
8
  import {readChartCategoryHeading,writeChartCategoryHeading,writeChartWorkbookFormats,repairChartWorkbookRanges} from './chart-workbook.js';
9
9
  import {applyChartNumberFormats,chartNumber,excelCode,formattedColumns,inlineChartData,inlineTableData,isDatasetRef,numericRanges,resolveChartData} from './chart-data.js';
10
- import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord} from './data-provenance.js';
10
+ import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord,tableCellEvidence,tableEnvironmentEvidence} from './data-provenance.js';
11
11
  import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,comboFromNative,NATIVE_CHART_ELEMENTS} from './chart-types.js';
12
12
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
13
- import {applyChartAlt,readFrameAlt} from './chart-alt.js';
13
+ import {applyFrameAlt,readFrameAlt} from './frame-alt.js';
14
14
  import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
15
15
  import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
16
16
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
@@ -33,7 +33,7 @@ import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType}
33
33
  import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
34
34
  import {importImageOrientation} from './image-import.js';
35
35
  import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
36
- import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
36
+ import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
37
37
  import {dedupeMedia} from './media-dedupe.js';
38
38
  import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
39
39
  import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
@@ -42,22 +42,22 @@ import {importBackground} from './background-import.js';
42
42
  import {chartHighlightPlan, applyChartHighlight} from './chart-highlight.js';
43
43
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
44
44
  import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
45
- import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
45
+ import { webpToPng, svgToPng, readLocalFile, localFileReadable } from '#image-fallback';
46
46
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
47
47
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
48
48
  import {
49
- chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
50
- DEFAULT_FONT_SCHEME, defaultSlideBackground, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
51
- resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
49
+ catalogRecords, chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
50
+ ENGINE_DEFAULT_FONT_SCHEME, defaultSlideBackground, fitImage, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
51
+ resolveReference, resolveScriptFonts, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
52
52
  } from "@openpresentation/opf/composition";
53
53
  import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
54
54
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
55
55
  import { unzipSync, zipSync } from "fflate";
56
56
  import { XMLParser } from "fast-xml-parser";
57
57
  import {
58
- catalogs as bundledCatalogs,
59
58
  hasContentVariables,
60
59
  isTemplate,
60
+ OPFCatalogsOptionError,
61
61
  resolveSlideContext as resolveCoreSlideContext,
62
62
  resolveVariables,
63
63
  validate
@@ -218,6 +218,12 @@ const CHART_COLORS = [
218
218
  "64748B"
219
219
  ];
220
220
 
221
+ // The `catalogs` option is core's registered catalogs (`Catalog[]`). A value that is no array is core's
222
+ // OPFCatalogsOptionError (code `invalid-catalogs`) up front; core checks each entry where it resolves a reference.
223
+ function checkCatalogs(entry, catalogs) {
224
+ if (catalogs !== undefined && !Array.isArray(catalogs)) throw new OPFCatalogsOptionError(entry, `got ${catalogs === null ? 'null' : typeof catalogs}`);
225
+ }
226
+
221
227
  export async function toPptx(input, options = {}) {
222
228
  if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
223
229
  throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
@@ -225,21 +231,34 @@ export async function toPptx(input, options = {}) {
225
231
  if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
226
232
  throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
227
233
  }
234
+ // OPF 0.15 (FA-23): the host's registered catalogs (core `Catalog[]`, matched by `source`, never fetched) reach every core
235
+ // call that resolves a reference. Strict export (`strictReferences`, default `strictAssets`) fails on a reference that
236
+ // resolves nowhere instead of composing automatically or drawing with the engine default.
237
+ checkCatalogs('toPptx', options.catalogs);
238
+ options = {...options, strictReferences: options.strictReferences ?? options.strictAssets === true};
228
239
  const presentation = resolveTemplateInput(parseInput(input), options);
229
240
  assertValidBoundary(presentation);
230
241
  // `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
231
242
  // chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
232
243
  options = {...options, textMeasurement: chosenFamilyMeasurement(options.fonts?.textMeasurement), svgRasters: new Map()};
244
+ // The renderer remains optional for custom measurement providers. Its public script planner supplies the same
245
+ // shaping/measurement segmentation as preview when installed; the selected native family wrapper stays outside it.
246
+ if (options.textMeasurement) {
247
+ try { options.createScriptFonts = (await import("@openpresentation/opf-render/fonts")).createScriptFonts; }
248
+ catch (error) { if (error?.code !== "ERR_MODULE_NOT_FOUND") throw error; }
249
+ }
233
250
 
234
251
  const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
235
252
  context.listMarkers = new Map();
236
253
  context.imagePlaceholders = new Map();
237
254
  context.tableHeaders = new Map();
238
255
  context.tableCells = new Map();
239
- context.imagePlacements = new Map();
256
+ // FA-23: every exported image picture (image blocks, the alt-text form of an image background, timeline pictures) and
257
+ // every image overlay shape, by object name (src/image-provenance.js).
258
+ context.images = new Map();
259
+ context.imageOverlays = new Map();
240
260
  context.svgPictures = new Map();
241
261
  context.pictureText = new Map();
242
- context.slideImages = new Map();
243
262
  context.watermarks = new Map();
244
263
  context.textWatermarks = new Map();
245
264
  context.logos = new Map();
@@ -267,7 +286,7 @@ export async function toPptx(input, options = {}) {
267
286
  context.metricTags = new Map();
268
287
  context.metricDescriptions = new Map();
269
288
  context.chartHeadings = new Map();
270
- context.chartAlts = new Map();
289
+ context.frameAlts = new Map();
271
290
  context.chartFonts = new Map();
272
291
  context.chartPalettes = new Map();
273
292
  context.chartex = new Map();
@@ -284,15 +303,19 @@ export async function toPptx(input, options = {}) {
284
303
  throw new OPFPptxError('invalid-chartex-mode', 'chartex must be auto, native or fallback.', {path: 'options.chartex'});
285
304
  }
286
305
  context.chartexMode = options.chartex ?? 'auto';
287
- Object.assign(context, exportTheme(presentation, context));
306
+ Object.assign(context, exportTheme(presentation, context, options));
288
307
  context.reportedReferences = new Set();
289
308
  // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
290
309
  context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
291
310
  mode: options.provenance ?? "full",
292
- isCatalogId: (kind, id) => !!(findById(normalizeRecords(presentation.catalogs?.[kind]), id) ?? findById(defaultCatalog(kind), id)),
311
+ // Core's provenance of each slide's resolved layout (resolveSlideContext().resolved.provenance.layout) with its record.
312
+ layoutOf: index => {
313
+ const {resolved} = coreContext(presentation, index, options);
314
+ return resolved.provenance?.layout && resolved.layout ? {...resolved.provenance.layout, record: resolved.layout} : undefined;
315
+ },
293
316
  report: diagnostic => options.onDiagnostic?.(diagnostic)
294
317
  });
295
- context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
318
+ context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic, {catalogs: options.catalogs});
296
319
  // Slide section labels become PowerPoint's native section list (src/sections.js).
297
320
  // A label is an XML attribute: the characters text runs reject are rejected here too.
298
321
  context.sections = presentation.slides.map((slide, index) => {
@@ -310,9 +333,10 @@ export async function toPptx(input, options = {}) {
310
333
  await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
311
334
  }
312
335
 
313
- // FF-08: pitchFamily per exported family, from each slide's resolved scheme.
336
+ // FF-08: pitchFamily per exported family, from each slide's resolved scheme, then the font schemes the document embeds
337
+ // and the registered catalogs its groups name (core catalogRecords).
314
338
  context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
315
- [...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
339
+ catalogRecords(presentation, "fontSchemes", {catalogs: options.catalogs}).map(entry => entry.record));
316
340
 
317
341
  let raw;
318
342
  try {
@@ -332,6 +356,9 @@ export async function toPptx(input, options = {}) {
332
356
  export async function fromPptx(input, options = {}) {
333
357
  // Opt-in raw shape signals (import-signals.js). Validated first so a bad option fails before any work.
334
358
  const signalLimits = normalizeSignalOptions(options.signals, (code, message, details) => new OPFPptxError(code, message, details));
359
+ // OPF 0.15 (FA-23): catalogs the host registered (core `Catalog[]`, never fetched). Theme and colour-scheme recovery and
360
+ // stored layout ids resolve against them; with none, a colour scheme imports inline and no theme id is recovered.
361
+ checkCatalogs('fromPptx', options.catalogs);
335
362
  const entries = readPptxZip(input);
336
363
  const presentationDoc = parseRequiredXml(entries, "ppt/presentation.xml");
337
364
  const presentationRoot = presentationDoc["p:presentation"];
@@ -365,12 +392,12 @@ export async function fromPptx(input, options = {}) {
365
392
  const theme = relatedPart(relatedPart(relatedPart(path, 'slideLayout'), 'slideMaster'), 'theme');
366
393
  return theme ? {path: theme, xml: decodeText(entries[theme])} : null;
367
394
  }),
368
- catalogs: bundledCatalogs
395
+ catalogs: options.catalogs
369
396
  });
370
397
  if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
371
398
  if (core.description) imported.description = core.description;
372
399
  if (core.author) imported.author = splitAuthors(core.author);
373
- const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
400
+ const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels, options.catalogs);
374
401
  const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
375
402
  if (Object.keys(design).length) imported.design = design;
376
403
 
@@ -399,7 +426,7 @@ export async function fromPptx(input, options = {}) {
399
426
  const mediaRegistry = Object.create(null);
400
427
  for (let index = 0; index < slidePaths.length; index += 1) {
401
428
  furnitureContexts[index].mediaRegistry = mediaRegistry;
402
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), deckLang: observedLanguage.lang, codeFamily: Object.keys(entries).some(path => /^ppt\/tags\/opf/i.test(path)) ? importedCodeFamily(imported.design) : undefined, dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
429
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), deckLang: observedLanguage.lang, codeFamily: Object.keys(entries).some(path => /^ppt\/tags\/opf/i.test(path)) ? importedCodeFamily(imported, options.catalogs) : undefined, dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
403
430
  }
404
431
  if (datasets) imported.datasets = datasets;
405
432
  // Native sections (PowerPoint's own section list, `Default Section` = none)
@@ -430,9 +457,11 @@ export async function fromPptx(input, options = {}) {
430
457
  let restoredGroups = [];
431
458
  try {
432
459
  const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true, speakerConflict: furniture.speakerConflict === true,
433
- // Host catalogs format stored socials exactly as export did (FF-34).
434
- socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
460
+ // Stored layout ids also resolve in the host's registered catalogs (FA-23).
461
+ catalogs: options.catalogs,
435
462
  nativeSections: slideSections,
463
+ // An importer `schema` option names the document's $schema, so an absent source $schema does not remove it (RR-59).
464
+ schemaOption: options.schema !== undefined,
436
465
  slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
437
466
  // The stored language wins while the runs still carry its tag (FF-07).
438
467
  restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
@@ -632,13 +661,14 @@ function readCoreProperties(entries) {
632
661
  }
633
662
 
634
663
  // FA-13: the code family of the imported design's font scheme (a run in it is an inline code run). The scheme's own `code` role,
635
- // else the bundled record's, else the shared fallback; heading and body families are never code. Only a package the exporter
636
- // wrote (it carries OPF tag parts) is read this way; a deck from another tool keeps its runs' font families as they are.
637
- function importedCodeFamily(design) {
638
- const reference = design?.fontScheme;
664
+ // else that of the record the reference resolves to (in the imported document or the host's registered catalogs), else the
665
+ // engine default's; heading and body families are never code. Only a package the exporter wrote (it carries OPF tag parts) is
666
+ // read this way; a deck from another tool keeps its runs' font families as they are.
667
+ function importedCodeFamily(imported, catalogs) {
668
+ const reference = imported.design?.fontScheme;
639
669
  const id = referenceId(reference);
640
- const base = (id && findById(defaultCatalog("fontSchemes"), id)) || findById(defaultCatalog("fontSchemes"), DEFAULT_FONT_SCHEME);
641
- return resolveFontFamilies({...base, ...(isPlainObject(reference) ? reference : {})}).code;
670
+ const found = id ? resolveReference(imported, "fontSchemes", id, {catalogs: catalogs ?? []}) : undefined;
671
+ return resolveFontFamilies({...(found ? found.record : ENGINE_DEFAULT_FONT_SCHEME), ...(isPlainObject(reference) ? reference : {})}).code;
642
672
  }
643
673
 
644
674
  function dimensionsFromPresentation(presentationRoot) {
@@ -662,20 +692,28 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
662
692
  const background = importBackground(slidePath, resolveCanvasDimensions(dimensions), {
663
693
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
664
694
  }, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
665
- if (background) slide.design = {background};
695
+ // FA-23: tagged image pictures and overlays (OPF_IMAGE_V1, OPF_IMAGE_OVERLAY_V1). An unchanged full-slide back picture is
696
+ // the slide's image background (its native fill is then only the canvas colour); an unchanged overlay of a native image
697
+ // background fill joins that background; an unchanged image block picture returns its treatment and placement.
698
+ const images = importImages({pictures: nativeContext.pictures, shapes: nativeContext.shapes, relationships, entries, slideIndex,
699
+ readPicture: picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
700
+ // The recorded crop, fit and focus are re-derived from the restored background.
701
+ if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`});
702
+ }),
703
+ valid: (kind, value) => isValidFormat({slides: [kind === 'background' ? {design: {background: value}} : {blocks: [value]}]}),
704
+ report: diagnostic => options.onDiagnostic?.(diagnostic)});
705
+ const imageBackground = images.background ?? (background?.type === 'image' && images.backgroundOverlay ? {...background, overlay: images.backgroundOverlay} : background);
706
+ if (images.backgroundOverlay && imageBackground === background) {
707
+ images.consumedShapes.delete(images.backgroundOverlayShape);
708
+ options.onDiagnostic?.({code: 'invalid-image-provenance', path: `slides.${slideIndex}.design.background.overlay`, message: 'The tagged OPF background overlay no longer sits on an image background; it was imported as an ordinary shape.'});
709
+ }
710
+ if (imageBackground) slide.design = {background: imageBackground};
666
711
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
667
712
  if (furniture.section !== undefined) slide.section = furniture.section;
668
- const slideImagePath = `slides.${slideIndex}.design.slideImage`;
669
- const slideImage = importSlideImage(nativeContext.pictures, nativeContext.shapes, relationships, entries, slideIndex,
670
- picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
671
- // The recorded crop/fit is re-derived from design.slideImage.
672
- if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: slideImagePath});
673
- }),
674
- diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
675
- if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
676
- if (slideImage.image) slide.image = slideImage.image;
677
- nativeContext.slideImagePictures = slideImage.consumed;
678
- nativeContext.slideImageShapes = slideImage.consumedShapes;
713
+ nativeContext.imageBlocks = images.blocks;
714
+ nativeContext.imagePictures = images.consumed;
715
+ // An overlay of a background that was not recovered stays an ordinary shape.
716
+ nativeContext.imageShapes = images.consumedShapes;
679
717
  const watermarkPath = `slides.${slideIndex}.design.watermark`;
680
718
  // FA-13: a text watermark is a tagged native text box; it is not content and never imports as a text block.
681
719
  const textWatermark = importTextWatermark(nativeContext.shapes, nativeContext.paragraphs.map(shapeParagraphs => shapeParagraphs.map(paragraph => ({text: paragraph.text}))), relationships, entries, slideIndex,
@@ -833,10 +871,10 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
833
871
  for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
834
872
  for (const item of media.items) items.push({kind:'media',sourceText:item.payload.type==='text',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
835
873
  for (const [index,shape] of shapes.entries()) {
836
- const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.watermarkShapes?.has(index) ? 'watermark' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
874
+ const role = furniture.text.has(index) ? 'furniture' : nativeContext.imageShapes?.has(index) ? 'image-overlay' : nativeContext.watermarkShapes?.has(index) ? 'watermark' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
837
875
  : code.consumed.has(shape) ? 'code' : metric.consumed.has(shape) ? 'metric' : media.consumed.has(shape) ? 'media' : timelines.consumed.has(shape) ? 'timeline' : quotes.consumed.has(shape) ? 'quote' : undefined;
838
876
  if (role) roles.set(shapeKeys.get(shape), role);
839
- if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
877
+ if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
840
878
  if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
841
879
  if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
842
880
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
@@ -872,10 +910,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
872
910
  }
873
911
 
874
912
  for (const [index, picture] of nativeContext.pictures.entries()) {
875
- const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : quotes.consumedPictures.has(picture) ? 'quote' : undefined;
913
+ const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.imagePictures?.has(index) ? 'background' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : quotes.consumedPictures.has(picture) ? 'quote' : undefined;
876
914
  if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
877
- const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
915
+ // A tagged, unchanged image block brings back its own fields (fit, focus, treatment, placement); its crop is theirs.
916
+ const treatment = nativeContext.imageBlocks?.get(index);
917
+ const report = diagnostic => { if (!(treatment && diagnostic.code === 'unsupported-image-crop')) options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`}); };
878
918
  const item = importPicture(entries, picture, slidePath, relationships, report);
919
+ if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
879
920
  if (item) items.push({...item, sources: [`pic:${index}`]});
880
921
  }
881
922
 
@@ -935,13 +976,17 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
935
976
  };
936
977
  if (table) {
937
978
  const stored = importedTable && record('table');
979
+ const restoredTable = stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {evidence: tableCellEvidence(table, relationships), environment: tableEnvironmentEvidence(entries), datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable;
980
+ // FA-27: the frame's descr (or PowerPoint's decorative marker) is the table's text alternative.
981
+ const alt = restoredTable && typeof restoredTable === 'object' ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
938
982
  return {
939
983
  kind: "table",
940
984
  bounds,
941
985
  name,
942
986
  payload: {
943
987
  type: "table",
944
- table: stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable
988
+ // RR-59: an authored table restored from provenance takes its alt from the frame too, so an alt removed in PowerPoint stays removed.
989
+ table: restoredTable && typeof restoredTable === 'object' ? (({alt: _recorded, ...rest}) => alt === undefined ? rest : {...rest, alt})(restoredTable) : restoredTable
945
990
  }
946
991
  };
947
992
  }
@@ -1628,8 +1673,17 @@ function assertValidBoundary(presentation) {
1628
1673
  // RR-55: what a slide (or, for the deck level, an empty slide) resolves to in core, the one chain every engine shares: canvas,
1629
1674
  // layout, font families, alignment and content box as composeSlide's options, plus the theme, colour scheme and font scheme
1630
1675
  // records they came from. `diagnostics` names references that matched no record.
1676
+ // Core's slide context with the host's registered catalogs. Under strict export core throws OPFUnresolvedReferenceError for a
1677
+ // reference that resolves nowhere; the export fails with `unresolved-reference` naming the first one (its path and every diagnostic).
1631
1678
  function coreContext(presentation, index, options) {
1632
- return resolveCoreSlideContext(presentation, index, {fonts: options.textMeasurement ? {textMeasurement: options.textMeasurement} : undefined, date: options.date});
1679
+ try {
1680
+ return resolveCoreSlideContext(presentation, index, {fonts: options.textMeasurement ? {textMeasurement: options.textMeasurement} : undefined, date: options.date,
1681
+ ...(options.catalogs !== undefined ? {catalogs: options.catalogs} : {}), strictReferences: options.strictReferences === true});
1682
+ } catch (error) {
1683
+ if (error?.name !== "OPFUnresolvedReferenceError" || !Array.isArray(error.diagnostics)) throw error;
1684
+ const [first] = error.diagnostics;
1685
+ throw new OPFPptxError("unresolved-reference", `Strict export: ${error.message}`, {path: first?.path, diagnostics: error.diagnostics});
1686
+ }
1633
1687
  }
1634
1688
 
1635
1689
  // The families composition and the package use: core's, with the measurement provider's own resolution of the chosen family
@@ -1644,8 +1698,8 @@ function exportFonts(core, textMeasurement) {
1644
1698
  function resolvePresentationContext(presentation, options, core = coreContext({...presentation, slides: [{}]}, 0, {...options, textMeasurement: undefined})) {
1645
1699
  const design = presentation.design ?? {};
1646
1700
  // `core` defaults to the deck level: the resolution of a slide that sets nothing of its own.
1647
- // The theme and colour scheme records come from core, which falls back to the default records (reporting unresolved-theme and
1648
- // unresolved-color-scheme) for a reference that matches no record.
1701
+ // The theme and colour scheme records come from core, which falls back to the engine defaults (reporting unresolved-reference)
1702
+ // for a reference that resolves nowhere.
1649
1703
  const {theme, colorScheme} = core.resolved;
1650
1704
  const dimensions = {widthInches: core.options.width / 96, heightInches: core.options.height / 96};
1651
1705
  const variables = resolveVariableColors(presentation.variables);
@@ -1776,20 +1830,28 @@ function masterBackground(context) {
1776
1830
  }
1777
1831
 
1778
1832
  // The theme part carries the deck-level scheme (slide overrides stay literal)
1779
- // and, for an explicit catalog theme, that theme's name.
1780
- function exportTheme(presentation, context) {
1833
+ // and, for a theme reference that resolves (embedded or in a registered catalog), that theme's name, which import
1834
+ // recovery matches against the registered catalogs (FF-24).
1835
+ function exportTheme(presentation, context, options) {
1781
1836
  const reference = presentation.design?.theme;
1782
- const id = referenceId(reference);
1783
- const theme = id ? context.theme : null;
1837
+ const theme = typeof reference === "string" ? resolveReference(presentation, "themes", reference, {catalogs: options.catalogs}) : undefined;
1784
1838
  const scheme = context.colorScheme;
1785
1839
  return {
1786
1840
  themeColors: themeSlotColors(scheme),
1787
1841
  schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
1788
- themeName: theme?.id === id ? theme.name ?? theme.id : undefined
1842
+ themeName: theme ? theme.record.name ?? theme.id : undefined
1789
1843
  };
1790
1844
  }
1791
1845
 
1792
- function importThemeDesign(entries, presentationRoot, presentationRels) {
1846
+ // FF-24 recovery against the host's registered catalogs (FA-23). Only the first registered catalog, the host default, can be
1847
+ // named by a bare id in a document that declares no catalogs, so only its records are matched: an exact match imports as
1848
+ // that bare id. Everything else (no catalog registered, a scheme from another registered catalog, no exact match) imports
1849
+ // as an inline colour scheme, and no theme id is recovered.
1850
+ function hostDefaultRecords(kind, catalogs) {
1851
+ return catalogRecords({}, kind, {catalogs: catalogs ?? []}).filter(entry => entry.group === "default").map(entry => ({...entry.record, id: entry.id}));
1852
+ }
1853
+
1854
+ function importThemeDesign(entries, presentationRoot, presentationRels, catalogs) {
1793
1855
  const diagnostics = [], design = {};
1794
1856
  const report = (code, path, message) => diagnostics.push({code, path, message});
1795
1857
  const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
@@ -1801,16 +1863,22 @@ function importThemeDesign(entries, presentationRoot, presentationRels) {
1801
1863
  return {design, diagnostics};
1802
1864
  }
1803
1865
  const {colors, unreadable} = readThemeSlotColors(clrScheme);
1804
- const records = defaultCatalog("colorSchemes");
1866
+ const records = hostDefaultRecords("colorSchemes", catalogs);
1805
1867
  if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
1806
1868
  if (unreadable.length) report("unsupported-theme-colors", "design.colorScheme", `Theme color slot(s) ${unreadable.join(", ")} do not resolve to an opaque sRGB color and were not imported.`);
1807
1869
  const latin = font => scalarText(elements?.["a:fontScheme"]?.[font]?.["a:latin"]?.typeface);
1870
+ // A theme's own colour and font scheme references resolve in the theme's group first (core resolveReference).
1871
+ const themeReference = (kind, reference) => {
1872
+ const id = referenceId(reference);
1873
+ const found = id ? resolveReference({}, kind, id, {catalogs: catalogs ?? [], group: "default"}) : undefined;
1874
+ return found ? {...found.record, ...(isPlainObject(reference) ? reference : {})} : null;
1875
+ };
1808
1876
  const recoveredTheme = recoverTheme({themeName: scalarText(theme?.name), colors, majorFont: latin("a:majorFont"), minorFont: latin("a:minorFont")}, {
1809
- themes: defaultCatalog("themes"),
1810
- colorSchemes: records,
1811
- fontFamilies: id => {
1812
- const record = findById(defaultCatalog("fontSchemes"), id);
1813
- return record ? resolveFontFamilies(record) : null;
1877
+ themes: hostDefaultRecords("themes", catalogs),
1878
+ colorScheme: record => themeReference("colorSchemes", record.colorScheme),
1879
+ fontFamilies: record => {
1880
+ const scheme = themeReference("fontSchemes", record.fontScheme);
1881
+ return scheme ? resolveFontFamilies(scheme) : null;
1814
1882
  }
1815
1883
  });
1816
1884
  if (recoveredTheme.unverified) report("theme-unverified", "design.theme", `The native theme has the name of OPF catalog theme '${recoveredTheme.unverified}', but neither its color scheme nor its heading/body fonts match that theme, so design.theme was not set.`);
@@ -1836,55 +1904,47 @@ function configurePresentation(pptx, presentation, context) {
1836
1904
  }
1837
1905
 
1838
1906
  async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
1907
+ options = {...options, textMeasurement: slideTextMeasurement(presentation, slideIndex, options)};
1839
1908
  const slide = pptx.addSlide();
1840
1909
  const slideContext = exportSlideContext(presentation, slideIndex, context, options);
1841
1910
  const { widthInches, heightInches } = slideContext.dimensions;
1842
- // A slide with no layout, or one that names a layout no catalog defines, is composed with no layout record (automatic composition),
1843
- // as in the preview; core reports the unknown id as unresolved-layout.
1911
+ // A slide with no layout, or one whose layout resolves nowhere, is composed with no layout record (automatic composition),
1912
+ // as in the preview; core reports the reference as unresolved-reference.
1844
1913
  // Core's resolved options (canvas, layout, families, alignment, content box, darkBackground), with the exporter's own additions:
1845
- // the families after the measurement provider's resolution of the chosen family, the raster padding and the host's social platform records.
1846
- const composeOptions = { ...slideContext.core.options, fontFamilies: slideContext.composeFonts, textRasterPadding:options.textRasterPadding, socialPlatforms: socialPlatformRecords(presentation, options) };
1914
+ // the families after the measurement provider's resolution of the chosen family and the raster padding. Core formats socials
1915
+ // handles from its own SOCIAL_PLATFORMS vocabulary.
1916
+ const composeOptions = { ...slideContext.core.options, fontFamilies: slideContext.composeFonts, textRasterPadding:options.textRasterPadding };
1847
1917
  // Core resolves every shared design key once (slide design, deck design, then the layout record's design): the text alignment of
1848
- // each item (item.alignment), and the imageFill that backgrounds and picture placements use (SlideComposition.design).
1918
+ // each item (item.alignment), and each image item's own fit and treatment (item.image).
1849
1919
  const geometry = composeSlide(opfSlide, composeOptions);
1850
- slideContext.imageFill = geometry.design.imageFill ?? "fit";
1851
1920
  // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1852
1921
  if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1853
1922
  slide.background = { color: slideContext.colors.background };
1854
- const backgroundDefinition = slideContext.backgroundDefinition;
1923
+ // An image background is core's geometry.backgroundImage (slide, deck, then theme background); every other kind is the
1924
+ // effective design or theme background definition.
1925
+ const backgroundDefinition = geometry.backgroundImage ? undefined : slideContext.backgroundDefinition;
1855
1926
  const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
1856
- const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1927
+ const backgroundFill = backgroundDefinition === undefined ? null : schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1857
1928
  width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
1858
1929
  }, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext), reference => resolveBackgroundColorRef(reference, slideContext.colorScheme, slideContext.variables));
1859
1930
  if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
1860
1931
  if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
1861
1932
  options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
1862
1933
  }
1863
- if (backgroundDefinition?.type === 'image') {
1864
- const imagePath = `${backgroundPath}.image`;
1865
- // An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
1866
- const outcome = { box: { w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches }, cover: (backgroundDefinition.image?.fit ?? 'cover') !== 'contain' };
1867
- const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
1868
- if (resolved) {
1869
- // PptxGenJS embeds the raster and its relationship; packaging replaces
1870
- // its stretched fill with the fitted native picture fill.
1871
- slide.background = { ...resolved };
1872
- context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, { image: {
1873
- fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
1874
- width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
1875
- } });
1876
- } else if (!outcome.reported) {
1877
- options.onDiagnostic?.({code: 'unresolved-asset', path: imagePath, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
1878
- }
1879
- }
1880
1934
  slide.color = slideContext.textColor;
1881
1935
  if (opfSlide.hidden === true) slide.hidden = true;
1882
1936
 
1883
1937
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1884
1938
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
1885
1939
  recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
1886
- // The preview paints background, slide image (and its overlay), then design.watermark, then content.
1887
- if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
1940
+ // Paint order, as in the preview (FA-22 contract): canvas colour, the background picture and its overlay, design.watermark,
1941
+ // the logo, then the items in core's order: the headings, the placed image blocks at their bands, then the flowed body.
1942
+ if (geometry.backgroundImage) await addBackgroundImage(slide, presentation, geometry.backgroundImage, slideIndex, slideContext, context, options);
1943
+ // RR-34: a captioned image's caption band follows its picture as tagged text boxes linked to the picture by name.
1944
+ const drawImage = async (item, itemContext) => {
1945
+ const name = await addImageItem(slide, presentation, item, slideIndex, itemContext, options);
1946
+ if (item.caption) addCaption(slide, item, name, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1947
+ };
1888
1948
  await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
1889
1949
  // Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
1890
1950
  if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
@@ -1932,12 +1992,14 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1932
1992
  });
1933
1993
  } else if (item.field === "text" && typeof item.value === "string") {
1934
1994
  slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:physicalAlignment(item.alignment, item.text.directions?.[0])});
1995
+ } else if (item.field === "image" && item.image) {
1996
+ await drawImage(item, itemContext);
1935
1997
  } else {
1936
1998
  // RR-34: a captioned item draws its media in item.box; the caption band follows as tagged text boxes linked to the media shape by name.
1937
- const mediaNames = item.caption ? new Set([...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
1999
+ const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
1938
2000
  await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: item.alignment ?? "left", direction: geometry.direction }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
1939
2001
  if (item.caption) {
1940
- const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
2002
+ const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
1941
2003
  addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1942
2004
  }
1943
2005
  }
@@ -2153,29 +2215,137 @@ function addListPayload(slide, items, region, context) {
2153
2215
  slide.addText(runs, textBoxOptions(region, context, 15));
2154
2216
  }
2155
2217
 
2218
+ // A content picture outside an image block (a timeline event image): one native picture contained in its region.
2156
2219
  async function addImagePayload(slide, presentation, asset, region, path, context, options) {
2157
- const outcome = { box: region, cover: context.imageFill === 'crop' };
2220
+ const name = await addImagePicture(slide, presentation, asset, region, path, context, options, {fit: 'contain'});
2221
+ if (!name) addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2222
+ return name;
2223
+ }
2224
+
2225
+ // One native image picture at `box` (inches), fitted at packaging (src/image-provenance.js placeImages). `treatment` is
2226
+ // what OPF_IMAGE_V1 stores (the authored block fields); without it the picture is untagged. Returns the picture's object
2227
+ // name, or undefined when the source could not be embedded (the caller draws the unavailable-image panel).
2228
+ async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName} = {}) {
2229
+ const outcome = { box, cover: fit === 'cover' };
2158
2230
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
2159
2231
  if (!resolved) {
2160
- addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2161
- return;
2232
+ // RR-59 (AUTO-26): a content picture never vanishes silently. A background names its own fallback (the slide colour).
2233
+ if (!outcome.reported) options.onDiagnostic?.(role === 'background' ? backgroundUnresolved(path) : {code: "unresolved-asset", path, reason: "unresolved-source", message: "The image needs an embedded raster, a declared asset or a host imageResolver; the image exports as the unavailable-image placeholder. Remote images are never fetched."});
2234
+ return undefined;
2162
2235
  }
2163
- const objectName = `OPF image ${context.imagePlacements.size + 1}`;
2164
- context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
2165
- if (outcome.svg) context.svgPictures.set(objectName, outcome.svg);
2166
- context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
2167
- slide.addImage({
2168
- ...resolved,
2169
- objectName,
2170
- x: region.x,
2171
- y: region.y,
2172
- w: region.w,
2173
- h: region.h,
2174
- altText: assetAlt(asset, presentation)
2236
+ const name = objectName ?? imageName(1 + [...context.images.keys()].filter(key => /^OPF image \d+$/.test(key)).length);
2237
+ // PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
2238
+ // 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture, and a tiled picture fill
2239
+ // cannot carry an SVG: with any of them the SVG stays its PNG raster, so the effect applies as in the preview.
2240
+ if (outcome.svg) {
2241
+ const treated = [effects?.recolor?.type === 'duotone' && 'duotone recolor', effects?.shape && effects.shape.preset !== 'rect' && 'shape', fit === 'tile' && 'tile fit'].filter(Boolean);
2242
+ if (treated.length) options.onDiagnostic?.({ code: 'svg-image-rasterized', path, message: `An SVG image with ${treated.join(', ')} exports as its PNG raster, so the effect applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.` });
2243
+ else context.svgPictures.set(name, outcome.svg);
2244
+ }
2245
+ const text = alt !== undefined ? alt : assetAlt(asset, presentation);
2246
+ context.images.set(name, { slide: context.slidePath, role, path, box, fit, focus, shrink, effects, ...(treatment !== undefined ? {treatment} : {}) });
2247
+ context.pictureText.set(name, pictureText(text, asset, presentation));
2248
+ slide.addImage({ ...resolved, objectName: name, x: box.x, y: box.y, w: box.w, h: box.h, altText: text });
2249
+ return name;
2250
+ }
2251
+
2252
+ // The native effects of an image treatment, resolved in the slide's colour context: core's mask (item.image.shape), the
2253
+ // border line, opacity and recolor.
2254
+ function imageEffects(image, context) {
2255
+ const paint = (entry, fallback) => {
2256
+ const raw = exportColor(entry, context, fallback).replace(/^#/, '');
2257
+ return { hex: normalizeHex(raw, fallback), alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
2258
+ };
2259
+ return {
2260
+ shape: image.shape ?? null,
2261
+ border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
2262
+ opacity: image.opacity ?? null,
2263
+ recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
2264
+ : image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
2265
+ paint
2266
+ };
2267
+ }
2268
+
2269
+ // An overlay (core ComposedOverlay) as one native shape directly above its picture: the frame's shape, or an edge band.
2270
+ function addImageOverlay(slide, overlay, authored, name, owner, context, effects) {
2271
+ const {hex, alpha} = effects.paint(overlay.color, context.colors.text);
2272
+ context.imageOverlays.set(name, { slide: context.slidePath, owner, box: overlay.box, shape: overlay.shape, hex, alpha, opacity: overlay.opacity, overlay: authored });
2273
+ slide.addShape('rect', { x: overlay.box.x / 96, y: overlay.box.y / 96, w: overlay.box.width / 96, h: overlay.box.height / 96, objectName: name, fill: { color: hex } });
2274
+ }
2275
+
2276
+ // The authored value at an OPF path the composition names (`slides.N.blocks.I`, `slides.N.design.background`,
2277
+ // `design.background`); `theme` is the resolved theme record's background.
2278
+ function authoredAt(presentation, path, slideContext) {
2279
+ if (path === 'theme') return slideContext.core?.resolved?.theme?.background;
2280
+ let value = presentation;
2281
+ for (const key of path.split('.')) value = value?.[/^\d+$/.test(key) ? Number(key) : key];
2282
+ return value;
2283
+ }
2284
+
2285
+ // An image block (core ComposedItem.image): the picture at core's frame with its fit, focus and treatment, then its
2286
+ // overlay directly above. An unchanged picture and overlay import back as the same block (OPF_IMAGE_V1).
2287
+ async function addImageItem(slide, presentation, item, slideIndex, context, options) {
2288
+ const image = item.image;
2289
+ const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
2290
+ const block = isPlainObject(item.payload) ? item.payload : {};
2291
+ const treatment = Object.fromEntries(IMAGE_TREATMENT_KEYS.filter(key => key !== 'overlay' && block[key] !== undefined).map(key => [key, structuredClone(block[key])]));
2292
+ const effects = imageEffects(image, context);
2293
+ const treated = Boolean(effects.border || effects.opacity !== null || effects.recolor || image.overlay || (effects.shape && effects.shape.preset !== 'rect'));
2294
+ const tagged = Object.keys(treatment).length > 0 || block.overlay !== undefined;
2295
+ // A plain contained picture in the flow is framed by the picture itself (0.14 content images); a treated or placed one
2296
+ // keeps core's frame (0.14 slide images), so its mask, line and overlay apply to that frame.
2297
+ const name = await addImagePicture(slide, presentation, item.value, box, item.path, { ...context, slidePath: `slides.${slideIndex}` }, options, {
2298
+ fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment: tagged ? treatment : undefined
2175
2299
  });
2176
- return objectName;
2300
+ if (!name) {
2301
+ addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
2302
+ return `OPF image placeholder ${context.imagePlaceholders.size}`;
2303
+ }
2304
+ if (image.overlay) addImageOverlay(slide, image.overlay, structuredClone(block.overlay), imageOverlayName(name), name, { ...context, slidePath: `slides.${slideIndex}` }, effects);
2305
+ return name;
2306
+ }
2307
+
2308
+ // An image background (core SlideComposition.backgroundImage). With alt text it is a full-slide picture at the back of
2309
+ // the slide over the canvas colour, carrying the alt text as its descr; without alt it is the native slide background
2310
+ // fill (written at packaging). Its overlay is one shape directly above the picture.
2311
+ async function addBackgroundImage(slide, presentation, background, slideIndex, slideContext, context, options) {
2312
+ const slidePath = `slides.${slideIndex}`, part = `ppt/slides/slide${slideIndex + 1}.xml`;
2313
+ const box = { x: 0, y: 0, w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches };
2314
+ const authored = authoredAt(presentation, background.path, slideContext);
2315
+ const effects = imageEffects({ opacity: background.opacity, recolor: background.recolor }, slideContext);
2316
+ let owner = 'background', overlayName = backgroundOverlayName(slidePath);
2317
+ // Only the background's own alt text makes it meaningful (FA-22): a referenced asset's alt does not count.
2318
+ const alt = background.alt;
2319
+ if (typeof alt === 'string' && alt) {
2320
+ const treatment = isPlainObject(authored) ? Object.fromEntries(BACKGROUND_IMAGE_KEYS.filter(key => key !== 'overlay' && authored[key] !== undefined).map(key => [key, structuredClone(authored[key])])) : {};
2321
+ const name = await addImagePicture(slide, presentation, background.src, box, background.path, { ...slideContext, slidePath }, options, {
2322
+ fit: background.fit, focus: background.focus, effects: background.opacity === undefined && !background.recolor ? null : effects, shrink: false,
2323
+ treatment, alt, role: 'background', objectName: backgroundImageName(slidePath)
2324
+ });
2325
+ // addImagePicture reported the unresolved source once (opf-pptx#210).
2326
+ if (!name) return;
2327
+ owner = name;
2328
+ overlayName = imageOverlayName(name);
2329
+ } else {
2330
+ // An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
2331
+ const outcome = { box, cover: background.fit === 'cover' };
2332
+ const resolved = await resolveImage(background.src, presentation, options, background.path, outcome);
2333
+ if (!resolved) {
2334
+ if (!outcome.reported) options.onDiagnostic?.(backgroundUnresolved(background.path));
2335
+ return;
2336
+ }
2337
+ // PptxGenJS embeds the raster and its relationship; packaging replaces its stretched fill with the fitted native fill.
2338
+ slide.background = { ...resolved };
2339
+ context.backgroundFills.set(part, { image: {
2340
+ fit: background.fit, focus: background.focus, opacity: background.opacity ?? 1, recolor: effects.recolor, path: background.path,
2341
+ width: box.w * 96, height: box.h * 96, report: options.onDiagnostic
2342
+ } });
2343
+ }
2344
+ if (background.overlay) addImageOverlay(slide, background.overlay, structuredClone(isPlainObject(authored) ? authored.overlay : undefined) ?? {color: background.overlay.color, opacity: background.overlay.opacity}, overlayName, owner, { ...slideContext, slidePath }, effects);
2177
2345
  }
2178
2346
 
2347
+ const backgroundUnresolved = path => ({code: 'unresolved-asset', path, reason: 'unresolved-source', message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
2348
+
2179
2349
  // design.watermark: one native picture per slide, added after the slide image
2180
2350
  // (and its overlay) and before all content, the preview's paint order. The
2181
2351
  // slide's own design.watermark replaces the deck's, and false suppresses it.
@@ -2243,54 +2413,6 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
2243
2413
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
2244
2414
  }
2245
2415
 
2246
- // design.slideImage: one native picture at the shared frame, beneath content.
2247
- // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
2248
- async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
2249
- const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
2250
- const outcome = { box, cover: image.fill === 'crop' };
2251
- const resolved = await resolveImage(image.value, presentation, options, image.sourcePath, outcome);
2252
- if (!resolved) {
2253
- addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
2254
- return;
2255
- }
2256
- const objectName = slideImageName(`slides.${slideIndex}`);
2257
- const configured = image.path === 'design.slideImage' ? presentation.design?.slideImage : presentation.slides[slideIndex].design?.slideImage;
2258
- const { src: _source, ...treatment } = isPlainObject(configured) && 'position' in configured ? configured : {};
2259
- const paint = (entry, fallback) => {
2260
- const hex = normalizeHex(exportColor(entry, context, fallback), fallback);
2261
- const raw = exportColor(entry, context, fallback).replace(/^#/, '');
2262
- return { hex, alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
2263
- };
2264
- // Native effects are resolved here, in the slide's color context.
2265
- const effects = {
2266
- shape: image.shape ?? null,
2267
- border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
2268
- opacity: image.opacity ?? null,
2269
- recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
2270
- : image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
2271
- overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
2272
- };
2273
- // FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
2274
- // manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
2275
- context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
2276
- // PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
2277
- // 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture: with either the SVG stays its
2278
- // PNG raster, so the effect applies as in the preview.
2279
- if (outcome.svg) {
2280
- const treated = [effects.recolor?.type === 'duotone' && 'duotone recolor', effects.shape && effects.shape.preset !== 'rect' && 'shape'].filter(Boolean);
2281
- if (treated.length) options.onDiagnostic?.({ code: 'svg-image-rasterized', path: image.sourcePath, message: `An SVG slide image with ${treated.join(', ')} exports as its PNG raster, so the effect applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.` });
2282
- else context.svgPictures.set(objectName, outcome.svg);
2283
- }
2284
- const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
2285
- context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
2286
- slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
2287
- // The overlay scrim is a separate native shape directly above the picture.
2288
- if (effects.overlay) {
2289
- const overlay = effects.overlay.box;
2290
- slide.addShape('rect', { x: overlay.x / 96, y: overlay.y / 96, w: overlay.width / 96, h: overlay.height / 96, objectName: slideImageOverlayName(`slides.${slideIndex}`), fill: { color: effects.overlay.hex } });
2291
- }
2292
- }
2293
-
2294
2416
  // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
2295
2417
  // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
2296
2418
  // the readability floor: 12 pt on a 13.33 x 7.5 in slide. The export writes that size, in hundredths of a point (DrawingML `sz`).
@@ -2348,7 +2470,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2348
2470
  recordPayloadData(context, objectName, 'chart', presentation, path);
2349
2471
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
2350
2472
  // FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
2351
- if (typeof chart.alt === 'string') context.chartAlts.set(objectName, chart.alt);
2473
+ if (typeof chart.alt === 'string') context.frameAlts.set(objectName, chart.alt);
2352
2474
  const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
2353
2475
  const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
2354
2476
  // FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
@@ -2535,6 +2657,8 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
2535
2657
  const objectName = `OPF table ${context.tableHeaders.size + 1}`;
2536
2658
  context.tableHeaders.set(objectName, hasHeaders);
2537
2659
  recordPayloadData(context, objectName, 'table', presentation, path, layout);
2660
+ // FA-27: the table's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker).
2661
+ if (typeof authoredTable?.alt === 'string') context.frameAlts.set(objectName, authoredTable.alt);
2538
2662
  if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color:"accent5",width:1/scale}});
2539
2663
  slide.addTable(rows, {
2540
2664
  objectName,
@@ -2669,7 +2793,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2669
2793
  for(const [index,part]of layout.parts.entries()){
2670
2794
  if(part.type==='image'){
2671
2795
  const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
2672
- const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
2796
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,context,options);
2673
2797
  // A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
2674
2798
  if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
2675
2799
  else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
@@ -2837,6 +2961,8 @@ async function addQuotePhoto(slide, presentation, photo, group, context, options
2837
2961
  const outcome = { box, cover: true };
2838
2962
  const resolved = await resolveImage(photo.value, presentation, options, photo.path, outcome);
2839
2963
  if (!resolved) {
2964
+ // opf-pptx#210: the headshot placeholder reports its cause like every other picture, so strict export (--fail-on warning) gates it.
2965
+ if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: photo.path, reason: 'unresolved-source', message: 'The quote photo needs an embedded raster, a declared asset or a host imageResolver; the photo exports as the unavailable-image placeholder. Remote images are never fetched.'});
2840
2966
  addImagePlaceholder(slide, presentation, photo.value, box, photo.path, context, options);
2841
2967
  return;
2842
2968
  }
@@ -3016,6 +3142,35 @@ function sameTypeface(requested, resolved) {
3016
3142
  // A legacy four-style family such as "Roboto Medium" is the chosen typeface.
3017
3143
  return got === want || got.startsWith(`${want} `) && isFaceStyleSuffix(got.slice(want.length + 1));
3018
3144
  }
3145
+ // Use each slide's core script profile, including a rich run's own language, for every fitting path.
3146
+ // Latin roles follow that slide's scheme; explicit run families remain under the planner's strict glyph policy.
3147
+ function slideTextMeasurement(presentation, index, options) {
3148
+ const measurement = options.fonts?.textMeasurement;
3149
+ if (!measurement || !options.createScriptFonts) return options.textMeasurement;
3150
+ const core = coreContext(presentation, index, {...options, textMeasurement: undefined});
3151
+ const languages = new Map();
3152
+ const planner = language => {
3153
+ const key = language ?? '';
3154
+ if (!languages.has(key)) {
3155
+ const resolved = resolveScriptFonts(presentation, {slideIndex: index, ...(options.catalogs !== undefined ? {catalogs: options.catalogs} : {}), ...(language ? {language} : {})});
3156
+ const slots = role => {
3157
+ const latin = core.options.fontFamilies[role];
3158
+ const slot = name => ['latin', 'schemeFamily'].includes(resolved.sources?.[name]) ? latin : resolved[role]?.[name] ?? latin;
3159
+ return {latin, eastAsian: slot('eastAsian'), complexScript: slot('complexScript')};
3160
+ };
3161
+ languages.set(key, options.createScriptFonts({...resolved, heading: slots('heading'), body: slots('body'), serif: core.resolved.fontScheme.type === 'serif'}, measurement).textMeasurement);
3162
+ }
3163
+ return languages.get(key);
3164
+ };
3165
+ const base = planner(), pick = style => planner(style?.lang) ?? base;
3166
+ const adapted = {...base, measure: (text, size, style) => pick(style).measure(text, size, style)};
3167
+ // The planner's measurement object spreads its provider; inherited class methods must keep their receiver.
3168
+ if (typeof measurement.resolveStyle === 'function') adapted.resolveStyle = style => measurement.resolveStyle(style);
3169
+ if (typeof measurement.resolveFont === 'function') adapted.resolveFont = style => measurement.resolveFont(style);
3170
+ if (typeof base.outlineBounds === 'function') adapted.outlineBounds = (text, size, style) => pick(style).outlineBounds(text, size, style);
3171
+ return chosenFamilyMeasurement(adapted);
3172
+ }
3173
+
3019
3174
  function chosenFamilyMeasurement(measurement) {
3020
3175
  if (!measurement || typeof measurement.resolveStyle !== 'function') return measurement;
3021
3176
  const resolveFont = typeof measurement.resolveFont === 'function' ? style => measurement.resolveFont(style) : undefined;
@@ -3307,6 +3462,14 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
3307
3462
  }
3308
3463
  return resolveSvgImage(file, options, path, outcome);
3309
3464
  }
3465
+ // opf-pptx#210: a local raster PptxGenJS cannot read used to fail the whole export (pptxgen-failed) on every picture path.
3466
+ // It is unresolved like any other source: the placeholder (or the path's own fallback) and one diagnostic.
3467
+ if (resolved?.path && !(await localFileReadable(resolved.path))) {
3468
+ if (options.strictAssets) throw new OPFPptxError("missing-asset", "The local image file could not be read.", { path, src: resolved.path, reason: "file-unreadable" });
3469
+ options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "file-unreadable", message: "The local image file could not be read; the image exports as the unavailable-image placeholder (or the picture's fallback)." });
3470
+ outcome.reported = true;
3471
+ return null;
3472
+ }
3310
3473
  if (!resolved?.data) return resolved;
3311
3474
  const bytes = dataUriBytes(resolved.data);
3312
3475
  const raster = bytes && rasterMetadata(bytes);
@@ -3352,7 +3515,7 @@ async function resolveSvgImage(source, options, path, outcome) {
3352
3515
  } catch (error) {
3353
3516
  const reason = error?.code === "svg-rasterizer-unavailable" ? "svg-rasterizer-unavailable" : "svg-render-failed";
3354
3517
  return unresolved(reason, reason, reason === "svg-rasterizer-unavailable"
3355
- ? "An SVG image needs a rasterizer for its PNG fallback: install the optional peer @openpresentation/opf-render, or pass options.svgRasterizer (or an imageResolver that returns a raster)."
3518
+ ? `An SVG image needs a rasterizer for its PNG fallback: install the optional peer @openpresentation/opf-render${typeof error.install === "string" ? ` and its PNG converter (\`${error.install}\`)` : ""}, or pass options.svgRasterizer (or an imageResolver that returns a raster).`
3356
3519
  : `The SVG image could not be rasterized for its PNG fallback (${errorMessage(error)}).`);
3357
3520
  }
3358
3521
  options.svgRasters.set(cacheKey, png);
@@ -3481,39 +3644,6 @@ function referenceId(reference) {
3481
3644
  return null;
3482
3645
  }
3483
3646
 
3484
- const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
3485
-
3486
- // Same order as opf-render's socialPlatformRecords so preview and export format
3487
- // socials identically. Core applies inline document records first; then the
3488
- // document catalog source (host-supplied options.catalogSources, or the bundled
3489
- // catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
3490
- // bundled catalog (the engine default source also resolves to it).
3491
- function socialPlatformRecords(presentation, options) {
3492
- const kind = "socialPlatforms", declared = presentation.catalogs?.[kind]?.source;
3493
- // `source` is one source or an ordered search path (an array): records of each source in order, first match wins.
3494
- const sourceRecords = (Array.isArray(declared) ? declared : [declared]).filter(source => typeof source === "string").flatMap(source => {
3495
- const bySource = options.catalogSources?.[source];
3496
- return bySource ? normalizeRecords(bySource)
3497
- : source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/") ? defaultCatalog(kind) : [];
3498
- });
3499
- return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
3500
- }
3501
-
3502
- function defaultCatalog(kind) {
3503
- return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
3504
- }
3505
-
3506
- function normalizeRecords(catalog) {
3507
- if (!catalog) return [];
3508
- if (Array.isArray(catalog)) return catalog;
3509
- if (Array.isArray(catalog.records)) return catalog.records;
3510
- return [];
3511
- }
3512
-
3513
- function findById(records, id) {
3514
- return records.find((record) => record?.id === id) ?? null;
3515
- }
3516
-
3517
3647
  // A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
3518
3648
  // as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
3519
3649
  function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
@@ -3631,7 +3761,7 @@ async function normalizePptxZip(raw, context) {
3631
3761
  }
3632
3762
  }
3633
3763
  applyChartFonts(entries,context.chartFonts,parseRelationships);
3634
- if (context.chartAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyChartAlt(xml, context.chartAlts); if (next !== xml) entries[part] = encodeText(next); }
3764
+ if (context.frameAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyFrameAlt(xml, context.frameAlts); if (next !== xml) entries[part] = encodeText(next); }
3635
3765
  // Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
3636
3766
  try {
3637
3767
  attachChartexParts(entries, context.chartex, parseRelationships);
@@ -3650,7 +3780,7 @@ async function normalizePptxZip(raw, context) {
3650
3780
  const relationships = parseRelationships(entries, part);
3651
3781
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
3652
3782
  const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
3653
- const placement = context.imagePlacements.get(name) ?? context.quotePhotos.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
3783
+ const placement = context.images.get(name) ?? context.quotePhotos.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
3654
3784
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3655
3785
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
3656
3786
  }
@@ -3679,11 +3809,17 @@ async function normalizePptxZip(raw, context) {
3679
3809
  }
3680
3810
  imageMetadata.set(part, metadata);
3681
3811
  }
3682
- // Slide images need the embedded dimensions; document provenance (FF-32)
3812
+ // Image pictures need the embedded dimensions (core's fitImage crops and pads them); document provenance (FF-32)
3683
3813
  // later records the placed frames as slide geometry evidence.
3684
- placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
3685
- throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
3686
- });
3814
+ try {
3815
+ placeImages(entries, context.images, context.imageOverlays, {
3816
+ metadataFor: (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), fitImage,
3817
+ fail: path => { throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path }); }
3818
+ });
3819
+ } catch (error) {
3820
+ if (error instanceof OPFPptxError) throw error;
3821
+ throw new OPFPptxError("packaging-failed", `Image pictures could not be placed: ${errorMessage(error)}`);
3822
+ }
3687
3823
  tagTextWatermarks(entries, context.textWatermarks);
3688
3824
  placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
3689
3825
  throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
@@ -3842,7 +3978,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3842
3978
  const metadata = imageMetadata.get(backgroundRelationships.get(id)?.path);
3843
3979
  if (!id || !metadata) throw new OPFPptxError("unsupported-image-dimensions", "Background image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path: fill.image.path });
3844
3980
  if ((metadata.orientation ?? 1) !== 1) fill.image.report?.({code: 'unsupported-background-image-orientation', path: fill.image.path, message: 'A slide background picture fill cannot rotate or mirror its image; the JPEG EXIF orientation is not applied in the native background.'});
3845
- return `<p:bg><p:bgPr>${nativeImageBackgroundFill(id, metadata, fill.image)}<a:effectLst/></p:bgPr></p:bg>`;
3981
+ return `<p:bg><p:bgPr>${nativeImageBackgroundFill(id, metadata, {...fill.image, fitImage})}<a:effectLst/></p:bgPr></p:bg>`;
3846
3982
  });
3847
3983
  }
3848
3984
  // PptxGenJS table IDs can collide with other objects on the same slide.
@@ -3928,7 +4064,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3928
4064
  }
3929
4065
  const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
3930
4066
  if (text) picture = writePictureText(picture, text);
3931
- const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]) ?? context.quotePhotos.get(picture.match(/name="(OPF quote photo \d+)"/)?.[1]);
4067
+ // Image pictures were placed with the slide's other pictures (placeImages); quote photos are fitted here.
4068
+ const placement = context.quotePhotos.get(picture.match(/name="(OPF quote photo \d+)"/)?.[1]);
3932
4069
  if (!placement) return picture;
3933
4070
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3934
4071
  const dimensions = imageMetadata.get(relationships.get(id)?.path);