@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/README.md +25 -23
- package/dist/background-import.js +33 -17
- package/dist/background.js +28 -25
- package/dist/chart-types.js +7 -22
- package/dist/document-provenance.js +195 -128
- package/dist/image-provenance.js +273 -0
- package/dist/index.d.ts +32 -17
- package/dist/index.js +268 -189
- package/dist/script-fonts.js +34 -60
- package/dist/theme-colors.js +15 -10
- package/dist/typeface-inventory.js +1 -1
- package/package.json +4 -4
- package/dist/slide-image-provenance.js +0 -177
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
//
|
|
434
|
-
|
|
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
|
|
636
|
-
//
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
641
|
-
return resolveFontFamilies({...
|
|
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
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1648
|
-
//
|
|
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
|
|
1780
|
-
|
|
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
|
|
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
|
|
1830
|
+
themeName: theme ? theme.record.name ?? theme.id : undefined
|
|
1789
1831
|
};
|
|
1790
1832
|
}
|
|
1791
1833
|
|
|
1792
|
-
|
|
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 =
|
|
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:
|
|
1810
|
-
|
|
1811
|
-
fontFamilies:
|
|
1812
|
-
const
|
|
1813
|
-
return
|
|
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
|
|
1843
|
-
// as in the preview; core reports the
|
|
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
|
|
1846
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
1887
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
2161
|
-
|
|
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
|
|
2164
|
-
context.
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
3685
|
-
|
|
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
|
-
|
|
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);
|