@openpresentation/opf-pptx 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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';
@@ -46,18 +46,18 @@ import { webpToPng, svgToPng, readLocalFile } 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, 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,6 +231,11 @@ 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
@@ -236,10 +247,12 @@ export async function toPptx(input, options = {}) {
236
247
  context.imagePlaceholders = new Map();
237
248
  context.tableHeaders = new Map();
238
249
  context.tableCells = new Map();
239
- context.imagePlacements = new Map();
250
+ // FA-23: every exported image picture (image blocks, the alt-text form of an image background, timeline pictures) and
251
+ // every image overlay shape, by object name (src/image-provenance.js).
252
+ context.images = new Map();
253
+ context.imageOverlays = new Map();
240
254
  context.svgPictures = new Map();
241
255
  context.pictureText = new Map();
242
- context.slideImages = new Map();
243
256
  context.watermarks = new Map();
244
257
  context.textWatermarks = new Map();
245
258
  context.logos = new Map();
@@ -284,15 +297,19 @@ export async function toPptx(input, options = {}) {
284
297
  throw new OPFPptxError('invalid-chartex-mode', 'chartex must be auto, native or fallback.', {path: 'options.chartex'});
285
298
  }
286
299
  context.chartexMode = options.chartex ?? 'auto';
287
- Object.assign(context, exportTheme(presentation, context));
300
+ Object.assign(context, exportTheme(presentation, context, options));
288
301
  context.reportedReferences = new Set();
289
302
  // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
290
303
  context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
291
304
  mode: options.provenance ?? "full",
292
- isCatalogId: (kind, id) => !!(findById(normalizeRecords(presentation.catalogs?.[kind]), id) ?? findById(defaultCatalog(kind), id)),
305
+ // Core's provenance of each slide's resolved layout (resolveSlideContext().resolved.provenance.layout) with its record.
306
+ layoutOf: index => {
307
+ const {resolved} = coreContext(presentation, index, options);
308
+ return resolved.provenance?.layout && resolved.layout ? {...resolved.provenance.layout, record: resolved.layout} : undefined;
309
+ },
293
310
  report: diagnostic => options.onDiagnostic?.(diagnostic)
294
311
  });
295
- context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
312
+ context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic, {catalogs: options.catalogs});
296
313
  // Slide section labels become PowerPoint's native section list (src/sections.js).
297
314
  // A label is an XML attribute: the characters text runs reject are rejected here too.
298
315
  context.sections = presentation.slides.map((slide, index) => {
@@ -310,9 +327,10 @@ export async function toPptx(input, options = {}) {
310
327
  await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
311
328
  }
312
329
 
313
- // FF-08: pitchFamily per exported family, from each slide's resolved scheme.
330
+ // FF-08: pitchFamily per exported family, from each slide's resolved scheme, then the font schemes the document embeds
331
+ // and the registered catalogs its groups name (core catalogRecords).
314
332
  context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
315
- [...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
333
+ catalogRecords(presentation, "fontSchemes", {catalogs: options.catalogs}).map(entry => entry.record));
316
334
 
317
335
  let raw;
318
336
  try {
@@ -332,6 +350,9 @@ export async function toPptx(input, options = {}) {
332
350
  export async function fromPptx(input, options = {}) {
333
351
  // Opt-in raw shape signals (import-signals.js). Validated first so a bad option fails before any work.
334
352
  const signalLimits = normalizeSignalOptions(options.signals, (code, message, details) => new OPFPptxError(code, message, details));
353
+ // OPF 0.15 (FA-23): catalogs the host registered (core `Catalog[]`, never fetched). Theme and colour-scheme recovery and
354
+ // stored layout ids resolve against them; with none, a colour scheme imports inline and no theme id is recovered.
355
+ checkCatalogs('fromPptx', options.catalogs);
335
356
  const entries = readPptxZip(input);
336
357
  const presentationDoc = parseRequiredXml(entries, "ppt/presentation.xml");
337
358
  const presentationRoot = presentationDoc["p:presentation"];
@@ -365,12 +386,12 @@ export async function fromPptx(input, options = {}) {
365
386
  const theme = relatedPart(relatedPart(relatedPart(path, 'slideLayout'), 'slideMaster'), 'theme');
366
387
  return theme ? {path: theme, xml: decodeText(entries[theme])} : null;
367
388
  }),
368
- catalogs: bundledCatalogs
389
+ catalogs: options.catalogs
369
390
  });
370
391
  if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
371
392
  if (core.description) imported.description = core.description;
372
393
  if (core.author) imported.author = splitAuthors(core.author);
373
- const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
394
+ const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels, options.catalogs);
374
395
  const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
375
396
  if (Object.keys(design).length) imported.design = design;
376
397
 
@@ -399,7 +420,7 @@ export async function fromPptx(input, options = {}) {
399
420
  const mediaRegistry = Object.create(null);
400
421
  for (let index = 0; index < slidePaths.length; index += 1) {
401
422
  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]));
423
+ 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
424
  }
404
425
  if (datasets) imported.datasets = datasets;
405
426
  // Native sections (PowerPoint's own section list, `Default Section` = none)
@@ -430,8 +451,8 @@ export async function fromPptx(input, options = {}) {
430
451
  let restoredGroups = [];
431
452
  try {
432
453
  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),
454
+ // Stored layout ids also resolve in the host's registered catalogs (FA-23).
455
+ catalogs: options.catalogs,
435
456
  nativeSections: slideSections,
436
457
  slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
437
458
  // The stored language wins while the runs still carry its tag (FF-07).
@@ -632,13 +653,14 @@ function readCoreProperties(entries) {
632
653
  }
633
654
 
634
655
  // 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;
656
+ // else that of the record the reference resolves to (in the imported document or the host's registered catalogs), else the
657
+ // engine default's; heading and body families are never code. Only a package the exporter wrote (it carries OPF tag parts) is
658
+ // read this way; a deck from another tool keeps its runs' font families as they are.
659
+ function importedCodeFamily(imported, catalogs) {
660
+ const reference = imported.design?.fontScheme;
639
661
  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;
662
+ const found = id ? resolveReference(imported, "fontSchemes", id, {catalogs: catalogs ?? []}) : undefined;
663
+ return resolveFontFamilies({...(found ? found.record : ENGINE_DEFAULT_FONT_SCHEME), ...(isPlainObject(reference) ? reference : {})}).code;
642
664
  }
643
665
 
644
666
  function dimensionsFromPresentation(presentationRoot) {
@@ -662,20 +684,28 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
662
684
  const background = importBackground(slidePath, resolveCanvasDimensions(dimensions), {
663
685
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
664
686
  }, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
665
- if (background) slide.design = {background};
687
+ // FA-23: tagged image pictures and overlays (OPF_IMAGE_V1, OPF_IMAGE_OVERLAY_V1). An unchanged full-slide back picture is
688
+ // the slide's image background (its native fill is then only the canvas colour); an unchanged overlay of a native image
689
+ // background fill joins that background; an unchanged image block picture returns its treatment and placement.
690
+ const images = importImages({pictures: nativeContext.pictures, shapes: nativeContext.shapes, relationships, entries, slideIndex,
691
+ readPicture: picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
692
+ // The recorded crop, fit and focus are re-derived from the restored background.
693
+ if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`});
694
+ }),
695
+ valid: (kind, value) => isValidFormat({slides: [kind === 'background' ? {design: {background: value}} : {blocks: [value]}]}),
696
+ report: diagnostic => options.onDiagnostic?.(diagnostic)});
697
+ const imageBackground = images.background ?? (background?.type === 'image' && images.backgroundOverlay ? {...background, overlay: images.backgroundOverlay} : background);
698
+ if (images.backgroundOverlay && imageBackground === background) {
699
+ images.consumedShapes.delete(images.backgroundOverlayShape);
700
+ 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.'});
701
+ }
702
+ if (imageBackground) slide.design = {background: imageBackground};
666
703
  if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
667
704
  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;
705
+ nativeContext.imageBlocks = images.blocks;
706
+ nativeContext.imagePictures = images.consumed;
707
+ // An overlay of a background that was not recovered stays an ordinary shape.
708
+ nativeContext.imageShapes = images.consumedShapes;
679
709
  const watermarkPath = `slides.${slideIndex}.design.watermark`;
680
710
  // FA-13: a text watermark is a tagged native text box; it is not content and never imports as a text block.
681
711
  const textWatermark = importTextWatermark(nativeContext.shapes, nativeContext.paragraphs.map(shapeParagraphs => shapeParagraphs.map(paragraph => ({text: paragraph.text}))), relationships, entries, slideIndex,
@@ -833,10 +863,10 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
833
863
  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
864
  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
865
  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'
866
+ 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
867
  : 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
868
  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;
869
+ if (furniture.text.has(index) || nativeContext.imageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
840
870
  if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
841
871
  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
872
  const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
@@ -872,10 +902,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
872
902
  }
873
903
 
874
904
  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;
905
+ 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
906
  if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
877
- const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
907
+ // A tagged, unchanged image block brings back its own fields (fit, focus, treatment, placement); its crop is theirs.
908
+ const treatment = nativeContext.imageBlocks?.get(index);
909
+ const report = diagnostic => { if (!(treatment && diagnostic.code === 'unsupported-image-crop')) options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`}); };
878
910
  const item = importPicture(entries, picture, slidePath, relationships, report);
911
+ if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
879
912
  if (item) items.push({...item, sources: [`pic:${index}`]});
880
913
  }
881
914
 
@@ -1628,8 +1661,17 @@ function assertValidBoundary(presentation) {
1628
1661
  // 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
1662
  // layout, font families, alignment and content box as composeSlide's options, plus the theme, colour scheme and font scheme
1630
1663
  // records they came from. `diagnostics` names references that matched no record.
1664
+ // Core's slide context with the host's registered catalogs. Under strict export core throws OPFUnresolvedReferenceError for a
1665
+ // reference that resolves nowhere; the export fails with `unresolved-reference` naming the first one (its path and every diagnostic).
1631
1666
  function coreContext(presentation, index, options) {
1632
- return resolveCoreSlideContext(presentation, index, {fonts: options.textMeasurement ? {textMeasurement: options.textMeasurement} : undefined, date: options.date});
1667
+ try {
1668
+ return resolveCoreSlideContext(presentation, index, {fonts: options.textMeasurement ? {textMeasurement: options.textMeasurement} : undefined, date: options.date,
1669
+ ...(options.catalogs !== undefined ? {catalogs: options.catalogs} : {}), strictReferences: options.strictReferences === true});
1670
+ } catch (error) {
1671
+ if (error?.name !== "OPFUnresolvedReferenceError" || !Array.isArray(error.diagnostics)) throw error;
1672
+ const [first] = error.diagnostics;
1673
+ throw new OPFPptxError("unresolved-reference", `Strict export: ${error.message}`, {path: first?.path, diagnostics: error.diagnostics});
1674
+ }
1633
1675
  }
1634
1676
 
1635
1677
  // The families composition and the package use: core's, with the measurement provider's own resolution of the chosen family
@@ -1644,8 +1686,8 @@ function exportFonts(core, textMeasurement) {
1644
1686
  function resolvePresentationContext(presentation, options, core = coreContext({...presentation, slides: [{}]}, 0, {...options, textMeasurement: undefined})) {
1645
1687
  const design = presentation.design ?? {};
1646
1688
  // `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.
1689
+ // The theme and colour scheme records come from core, which falls back to the engine defaults (reporting unresolved-reference)
1690
+ // for a reference that resolves nowhere.
1649
1691
  const {theme, colorScheme} = core.resolved;
1650
1692
  const dimensions = {widthInches: core.options.width / 96, heightInches: core.options.height / 96};
1651
1693
  const variables = resolveVariableColors(presentation.variables);
@@ -1776,20 +1818,28 @@ function masterBackground(context) {
1776
1818
  }
1777
1819
 
1778
1820
  // 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) {
1821
+ // and, for a theme reference that resolves (embedded or in a registered catalog), that theme's name, which import
1822
+ // recovery matches against the registered catalogs (FF-24).
1823
+ function exportTheme(presentation, context, options) {
1781
1824
  const reference = presentation.design?.theme;
1782
- const id = referenceId(reference);
1783
- const theme = id ? context.theme : null;
1825
+ const theme = typeof reference === "string" ? resolveReference(presentation, "themes", reference, {catalogs: options.catalogs}) : undefined;
1784
1826
  const scheme = context.colorScheme;
1785
1827
  return {
1786
1828
  themeColors: themeSlotColors(scheme),
1787
1829
  schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
1788
- themeName: theme?.id === id ? theme.name ?? theme.id : undefined
1830
+ themeName: theme ? theme.record.name ?? theme.id : undefined
1789
1831
  };
1790
1832
  }
1791
1833
 
1792
- function importThemeDesign(entries, presentationRoot, presentationRels) {
1834
+ // FF-24 recovery against the host's registered catalogs (FA-23). Only the first registered catalog, the host default, can be
1835
+ // named by a bare id in a document that declares no catalogs, so only its records are matched: an exact match imports as
1836
+ // that bare id. Everything else (no catalog registered, a scheme from another registered catalog, no exact match) imports
1837
+ // as an inline colour scheme, and no theme id is recovered.
1838
+ function hostDefaultRecords(kind, catalogs) {
1839
+ return catalogRecords({}, kind, {catalogs: catalogs ?? []}).filter(entry => entry.group === "default").map(entry => ({...entry.record, id: entry.id}));
1840
+ }
1841
+
1842
+ function importThemeDesign(entries, presentationRoot, presentationRels, catalogs) {
1793
1843
  const diagnostics = [], design = {};
1794
1844
  const report = (code, path, message) => diagnostics.push({code, path, message});
1795
1845
  const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
@@ -1801,16 +1851,22 @@ function importThemeDesign(entries, presentationRoot, presentationRels) {
1801
1851
  return {design, diagnostics};
1802
1852
  }
1803
1853
  const {colors, unreadable} = readThemeSlotColors(clrScheme);
1804
- const records = defaultCatalog("colorSchemes");
1854
+ const records = hostDefaultRecords("colorSchemes", catalogs);
1805
1855
  if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
1806
1856
  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
1857
  const latin = font => scalarText(elements?.["a:fontScheme"]?.[font]?.["a:latin"]?.typeface);
1858
+ // A theme's own colour and font scheme references resolve in the theme's group first (core resolveReference).
1859
+ const themeReference = (kind, reference) => {
1860
+ const id = referenceId(reference);
1861
+ const found = id ? resolveReference({}, kind, id, {catalogs: catalogs ?? [], group: "default"}) : undefined;
1862
+ return found ? {...found.record, ...(isPlainObject(reference) ? reference : {})} : null;
1863
+ };
1808
1864
  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;
1865
+ themes: hostDefaultRecords("themes", catalogs),
1866
+ colorScheme: record => themeReference("colorSchemes", record.colorScheme),
1867
+ fontFamilies: record => {
1868
+ const scheme = themeReference("fontSchemes", record.fontScheme);
1869
+ return scheme ? resolveFontFamilies(scheme) : null;
1814
1870
  }
1815
1871
  });
1816
1872
  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.`);
@@ -1839,52 +1895,43 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1839
1895
  const slide = pptx.addSlide();
1840
1896
  const slideContext = exportSlideContext(presentation, slideIndex, context, options);
1841
1897
  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.
1898
+ // A slide with no layout, or one whose layout resolves nowhere, is composed with no layout record (automatic composition),
1899
+ // as in the preview; core reports the reference as unresolved-reference.
1844
1900
  // 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) };
1901
+ // the families after the measurement provider's resolution of the chosen family and the raster padding. Core formats socials
1902
+ // handles from its own SOCIAL_PLATFORMS vocabulary.
1903
+ const composeOptions = { ...slideContext.core.options, fontFamilies: slideContext.composeFonts, textRasterPadding:options.textRasterPadding };
1847
1904
  // 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).
1905
+ // each item (item.alignment), and each image item's own fit and treatment (item.image).
1849
1906
  const geometry = composeSlide(opfSlide, composeOptions);
1850
- slideContext.imageFill = geometry.design.imageFill ?? "fit";
1851
1907
  // The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
1852
1908
  if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
1853
1909
  slide.background = { color: slideContext.colors.background };
1854
- const backgroundDefinition = slideContext.backgroundDefinition;
1910
+ // An image background is core's geometry.backgroundImage (slide, deck, then theme background); every other kind is the
1911
+ // effective design or theme background definition.
1912
+ const backgroundDefinition = geometry.backgroundImage ? undefined : slideContext.backgroundDefinition;
1855
1913
  const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
1856
- const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1914
+ const backgroundFill = backgroundDefinition === undefined ? null : schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
1857
1915
  width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
1858
1916
  }, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext), reference => resolveBackgroundColorRef(reference, slideContext.colorScheme, slideContext.variables));
1859
1917
  if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
1860
1918
  if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
1861
1919
  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
1920
  }
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
1921
  slide.color = slideContext.textColor;
1881
1922
  if (opfSlide.hidden === true) slide.hidden = true;
1882
1923
 
1883
1924
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1884
1925
  // The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
1885
1926
  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);
1927
+ // Paint order, as in the preview (FA-22 contract): canvas colour, the background picture and its overlay, design.watermark,
1928
+ // the logo, then the items in core's order: the headings, the placed image blocks at their bands, then the flowed body.
1929
+ if (geometry.backgroundImage) await addBackgroundImage(slide, presentation, geometry.backgroundImage, slideIndex, slideContext, context, options);
1930
+ // RR-34: a captioned image's caption band follows its picture as tagged text boxes linked to the picture by name.
1931
+ const drawImage = async (item, itemContext) => {
1932
+ const name = await addImageItem(slide, presentation, item, slideIndex, itemContext, options);
1933
+ if (item.caption) addCaption(slide, item, name, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1934
+ };
1888
1935
  await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
1889
1936
  // Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
1890
1937
  if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
@@ -1932,12 +1979,14 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1932
1979
  });
1933
1980
  } else if (item.field === "text" && typeof item.value === "string") {
1934
1981
  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])});
1982
+ } else if (item.field === "image" && item.image) {
1983
+ await drawImage(item, itemContext);
1935
1984
  } else {
1936
1985
  // 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;
1986
+ const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
1938
1987
  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
1988
  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));
1989
+ 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
1990
  addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
1942
1991
  }
1943
1992
  }
@@ -2153,27 +2202,131 @@ function addListPayload(slide, items, region, context) {
2153
2202
  slide.addText(runs, textBoxOptions(region, context, 15));
2154
2203
  }
2155
2204
 
2205
+ // A content picture outside an image block (a timeline event image): one native picture contained in its region.
2156
2206
  async function addImagePayload(slide, presentation, asset, region, path, context, options) {
2157
- const outcome = { box: region, cover: context.imageFill === 'crop' };
2207
+ const name = await addImagePicture(slide, presentation, asset, region, path, context, options, {fit: 'contain'});
2208
+ if (!name) addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2209
+ return name;
2210
+ }
2211
+
2212
+ // One native image picture at `box` (inches), fitted at packaging (src/image-provenance.js placeImages). `treatment` is
2213
+ // what OPF_IMAGE_V1 stores (the authored block fields); without it the picture is untagged. Returns the picture's object
2214
+ // name, or undefined when the source could not be embedded (the caller draws the unavailable-image panel).
2215
+ async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName} = {}) {
2216
+ const outcome = { box, cover: fit === 'cover' };
2158
2217
  const resolved = await resolveImage(asset, presentation, options, path, outcome);
2159
- if (!resolved) {
2160
- addImagePlaceholder(slide, presentation, asset, region, path, context, options);
2161
- return;
2218
+ if (!resolved) return undefined;
2219
+ const name = objectName ?? imageName(1 + [...context.images.keys()].filter(key => /^OPF image \d+$/.test(key)).length);
2220
+ // PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
2221
+ // 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture, and a tiled picture fill
2222
+ // cannot carry an SVG: with any of them the SVG stays its PNG raster, so the effect applies as in the preview.
2223
+ if (outcome.svg) {
2224
+ const treated = [effects?.recolor?.type === 'duotone' && 'duotone recolor', effects?.shape && effects.shape.preset !== 'rect' && 'shape', fit === 'tile' && 'tile fit'].filter(Boolean);
2225
+ 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.` });
2226
+ else context.svgPictures.set(name, outcome.svg);
2162
2227
  }
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)
2228
+ const text = alt !== undefined ? alt : assetAlt(asset, presentation);
2229
+ context.images.set(name, { slide: context.slidePath, role, path, box, fit, focus, shrink, effects, ...(treatment !== undefined ? {treatment} : {}) });
2230
+ context.pictureText.set(name, pictureText(text, asset, presentation));
2231
+ slide.addImage({ ...resolved, objectName: name, x: box.x, y: box.y, w: box.w, h: box.h, altText: text });
2232
+ return name;
2233
+ }
2234
+
2235
+ // The native effects of an image treatment, resolved in the slide's colour context: core's mask (item.image.shape), the
2236
+ // border line, opacity and recolor.
2237
+ function imageEffects(image, context) {
2238
+ const paint = (entry, fallback) => {
2239
+ const raw = exportColor(entry, context, fallback).replace(/^#/, '');
2240
+ return { hex: normalizeHex(raw, fallback), alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
2241
+ };
2242
+ return {
2243
+ shape: image.shape ?? null,
2244
+ border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
2245
+ opacity: image.opacity ?? null,
2246
+ recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
2247
+ : image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
2248
+ paint
2249
+ };
2250
+ }
2251
+
2252
+ // An overlay (core ComposedOverlay) as one native shape directly above its picture: the frame's shape, or an edge band.
2253
+ function addImageOverlay(slide, overlay, authored, name, owner, context, effects) {
2254
+ const {hex, alpha} = effects.paint(overlay.color, context.colors.text);
2255
+ context.imageOverlays.set(name, { slide: context.slidePath, owner, box: overlay.box, shape: overlay.shape, hex, alpha, opacity: overlay.opacity, overlay: authored });
2256
+ 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 } });
2257
+ }
2258
+
2259
+ // The authored value at an OPF path the composition names (`slides.N.blocks.I`, `slides.N.design.background`,
2260
+ // `design.background`); `theme` is the resolved theme record's background.
2261
+ function authoredAt(presentation, path, slideContext) {
2262
+ if (path === 'theme') return slideContext.core?.resolved?.theme?.background;
2263
+ let value = presentation;
2264
+ for (const key of path.split('.')) value = value?.[/^\d+$/.test(key) ? Number(key) : key];
2265
+ return value;
2266
+ }
2267
+
2268
+ // An image block (core ComposedItem.image): the picture at core's frame with its fit, focus and treatment, then its
2269
+ // overlay directly above. An unchanged picture and overlay import back as the same block (OPF_IMAGE_V1).
2270
+ async function addImageItem(slide, presentation, item, slideIndex, context, options) {
2271
+ const image = item.image;
2272
+ const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
2273
+ const block = isPlainObject(item.payload) ? item.payload : {};
2274
+ const treatment = Object.fromEntries(IMAGE_TREATMENT_KEYS.filter(key => key !== 'overlay' && block[key] !== undefined).map(key => [key, structuredClone(block[key])]));
2275
+ const effects = imageEffects(image, context);
2276
+ const treated = Boolean(effects.border || effects.opacity !== null || effects.recolor || image.overlay || (effects.shape && effects.shape.preset !== 'rect'));
2277
+ const tagged = Object.keys(treatment).length > 0 || block.overlay !== undefined;
2278
+ // A plain contained picture in the flow is framed by the picture itself (0.14 content images); a treated or placed one
2279
+ // keeps core's frame (0.14 slide images), so its mask, line and overlay apply to that frame.
2280
+ const name = await addImagePicture(slide, presentation, item.value, box, item.path, { ...context, slidePath: `slides.${slideIndex}` }, options, {
2281
+ fit: image.fit, focus: image.focus, effects: treated ? effects : null, shrink: !treated && !image.placement, treatment: tagged ? treatment : undefined
2175
2282
  });
2176
- return objectName;
2283
+ if (!name) {
2284
+ addImagePlaceholder(slide, presentation, item.value, box, item.path, context, options);
2285
+ return `OPF image placeholder ${context.imagePlaceholders.size}`;
2286
+ }
2287
+ if (image.overlay) addImageOverlay(slide, image.overlay, structuredClone(block.overlay), imageOverlayName(name), name, { ...context, slidePath: `slides.${slideIndex}` }, effects);
2288
+ return name;
2289
+ }
2290
+
2291
+ // An image background (core SlideComposition.backgroundImage). With alt text it is a full-slide picture at the back of
2292
+ // the slide over the canvas colour, carrying the alt text as its descr; without alt it is the native slide background
2293
+ // fill (written at packaging). Its overlay is one shape directly above the picture.
2294
+ async function addBackgroundImage(slide, presentation, background, slideIndex, slideContext, context, options) {
2295
+ const slidePath = `slides.${slideIndex}`, part = `ppt/slides/slide${slideIndex + 1}.xml`;
2296
+ const box = { x: 0, y: 0, w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches };
2297
+ const authored = authoredAt(presentation, background.path, slideContext);
2298
+ const effects = imageEffects({ opacity: background.opacity, recolor: background.recolor }, slideContext);
2299
+ let owner = 'background', overlayName = backgroundOverlayName(slidePath);
2300
+ // Only the background's own alt text makes it meaningful (FA-22): a referenced asset's alt does not count.
2301
+ const alt = background.alt;
2302
+ if (typeof alt === 'string' && alt) {
2303
+ const treatment = isPlainObject(authored) ? Object.fromEntries(BACKGROUND_IMAGE_KEYS.filter(key => key !== 'overlay' && authored[key] !== undefined).map(key => [key, structuredClone(authored[key])])) : {};
2304
+ const name = await addImagePicture(slide, presentation, background.src, box, background.path, { ...slideContext, slidePath }, options, {
2305
+ fit: background.fit, focus: background.focus, effects: background.opacity === undefined && !background.recolor ? null : effects, shrink: false,
2306
+ treatment, alt, role: 'background', objectName: backgroundImageName(slidePath)
2307
+ });
2308
+ if (!name) {
2309
+ options.onDiagnostic?.({code: 'unresolved-asset', path: background.path, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
2310
+ return;
2311
+ }
2312
+ owner = name;
2313
+ overlayName = imageOverlayName(name);
2314
+ } else {
2315
+ // An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
2316
+ const outcome = { box, cover: background.fit === 'cover' };
2317
+ const resolved = await resolveImage(background.src, presentation, options, background.path, outcome);
2318
+ if (!resolved) {
2319
+ if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: background.path, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
2320
+ return;
2321
+ }
2322
+ // PptxGenJS embeds the raster and its relationship; packaging replaces its stretched fill with the fitted native fill.
2323
+ slide.background = { ...resolved };
2324
+ context.backgroundFills.set(part, { image: {
2325
+ fit: background.fit, focus: background.focus, opacity: background.opacity ?? 1, recolor: effects.recolor, path: background.path,
2326
+ width: box.w * 96, height: box.h * 96, report: options.onDiagnostic
2327
+ } });
2328
+ }
2329
+ 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
2330
  }
2178
2331
 
2179
2332
  // design.watermark: one native picture per slide, added after the slide image
@@ -2243,54 +2396,6 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
2243
2396
  slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
2244
2397
  }
2245
2398
 
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
2399
  // FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
2295
2400
  // max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
2296
2401
  // 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`).
@@ -2669,7 +2774,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
2669
2774
  for(const [index,part]of layout.parts.entries()){
2670
2775
  if(part.type==='image'){
2671
2776
  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);
2777
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,context,options);
2673
2778
  // A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
2674
2779
  if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
2675
2780
  else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
@@ -3481,39 +3586,6 @@ function referenceId(reference) {
3481
3586
  return null;
3482
3587
  }
3483
3588
 
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
3589
  // A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
3518
3590
  // as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
3519
3591
  function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
@@ -3650,7 +3722,7 @@ async function normalizePptxZip(raw, context) {
3650
3722
  const relationships = parseRelationships(entries, part);
3651
3723
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
3652
3724
  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);
3725
+ const placement = context.images.get(name) ?? context.quotePhotos.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
3654
3726
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3655
3727
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
3656
3728
  }
@@ -3679,11 +3751,17 @@ async function normalizePptxZip(raw, context) {
3679
3751
  }
3680
3752
  imageMetadata.set(part, metadata);
3681
3753
  }
3682
- // Slide images need the embedded dimensions; document provenance (FF-32)
3754
+ // Image pictures need the embedded dimensions (core's fitImage crops and pads them); document provenance (FF-32)
3683
3755
  // 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
- });
3756
+ try {
3757
+ placeImages(entries, context.images, context.imageOverlays, {
3758
+ metadataFor: (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), fitImage,
3759
+ 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 }); }
3760
+ });
3761
+ } catch (error) {
3762
+ if (error instanceof OPFPptxError) throw error;
3763
+ throw new OPFPptxError("packaging-failed", `Image pictures could not be placed: ${errorMessage(error)}`);
3764
+ }
3687
3765
  tagTextWatermarks(entries, context.textWatermarks);
3688
3766
  placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
3689
3767
  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 +3920,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3842
3920
  const metadata = imageMetadata.get(backgroundRelationships.get(id)?.path);
3843
3921
  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
3922
  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>`;
3923
+ return `<p:bg><p:bgPr>${nativeImageBackgroundFill(id, metadata, {...fill.image, fitImage})}<a:effectLst/></p:bgPr></p:bg>`;
3846
3924
  });
3847
3925
  }
3848
3926
  // PptxGenJS table IDs can collide with other objects on the same slide.
@@ -3928,7 +4006,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3928
4006
  }
3929
4007
  const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
3930
4008
  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]);
4009
+ // Image pictures were placed with the slide's other pictures (placeImages); quote photos are fitted here.
4010
+ const placement = context.quotePhotos.get(picture.match(/name="(OPF quote photo \d+)"/)?.[1]);
3932
4011
  if (!placement) return picture;
3933
4012
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
3934
4013
  const dimensions = imageMetadata.get(relationships.get(id)?.path);