@openpresentation/opf-pptx 0.13.2 → 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 +57 -37
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background-import.js +33 -17
- package/dist/background.js +30 -28
- package/dist/body-text-import.js +16 -4
- package/dist/chart-alt.js +54 -0
- package/dist/chart-data.js +29 -54
- package/dist/chart-highlight.js +113 -0
- package/dist/chart-options.js +46 -24
- package/dist/chart-types.js +61 -75
- package/dist/code-provenance.js +20 -4
- package/dist/color-ref.js +12 -6
- package/dist/data-provenance.js +18 -14
- package/dist/document-provenance.js +208 -129
- package/dist/format-check.js +10 -0
- package/dist/furniture-provenance.js +30 -3
- package/dist/heading-provenance.js +10 -3
- package/dist/image-fallback-node.js +4 -1
- package/dist/image-provenance.js +273 -0
- package/dist/index.d.ts +57 -35
- package/dist/index.js +724 -399
- package/dist/media-provenance.js +3 -3
- package/dist/metric-provenance.js +3 -3
- package/dist/native-text-style.js +2 -0
- package/dist/package-fonts.js +2 -2
- package/dist/quote-provenance.js +70 -26
- package/dist/rich-heading.js +119 -0
- package/dist/run-colors.js +3 -0
- package/dist/script-fonts.js +102 -101
- package/dist/theme-colors.js +16 -11
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +8 -8
- package/dist/watermark-provenance.js +65 -3
- package/package.json +4 -4
- package/dist/slide-image-provenance.js +0 -177
package/dist/index.js
CHANGED
|
@@ -8,8 +8,9 @@ import {legacyVendorOutput} from './vendor-compat.js';
|
|
|
8
8
|
import {readChartCategoryHeading,writeChartCategoryHeading,writeChartWorkbookFormats,repairChartWorkbookRanges} from './chart-workbook.js';
|
|
9
9
|
import {applyChartNumberFormats,chartNumber,excelCode,formattedColumns,inlineChartData,inlineTableData,isDatasetRef,numericRanges,resolveChartData} from './chart-data.js';
|
|
10
10
|
import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord} from './data-provenance.js';
|
|
11
|
-
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
11
|
+
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,comboFromNative,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
12
12
|
import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
|
|
13
|
+
import {applyChartAlt,readFrameAlt} from './chart-alt.js';
|
|
13
14
|
import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
|
|
14
15
|
import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
15
16
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
@@ -19,9 +20,10 @@ import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTex
|
|
|
19
20
|
import {addCaption, addFootnotes} from './annotation-export.js';
|
|
20
21
|
import {attachAnnotationTags, captionValue, importAnnotations, restoreCitations} from './annotation-provenance.js';
|
|
21
22
|
import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
23
|
+
import {headingValue,joinRichLines} from './rich-heading.js';
|
|
22
24
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
23
25
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
24
|
-
import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
|
|
26
|
+
import {attachQuoteTags,quoteManifest,importQuoteGroups,quotePhotoName} from './quote-provenance.js';
|
|
25
27
|
import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
|
|
26
28
|
import {restoreRunColors} from './run-colors.js';
|
|
27
29
|
import {joinWrappedText} from './content-topology.js';
|
|
@@ -31,35 +33,38 @@ import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType}
|
|
|
31
33
|
import {attachNativePlaceholders, defaultPlaceholderGeometry, nativeDateText, nativeFurnitureParts, writeNativeMasters} from './native-furniture.js';
|
|
32
34
|
import {importImageOrientation} from './image-import.js';
|
|
33
35
|
import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
|
|
34
|
-
import {
|
|
36
|
+
import {placeImages, importImages, imageName, imageOverlayName, backgroundImageName, backgroundOverlayName, IMAGE_TREATMENT_KEYS, BACKGROUND_IMAGE_KEYS} from './image-provenance.js';
|
|
35
37
|
import {dedupeMedia} from './media-dedupe.js';
|
|
36
|
-
import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
|
|
38
|
+
import {placeWatermarks, importWatermark, importTextWatermark, tagTextWatermarks, watermarkName, watermarkTextName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
|
|
37
39
|
import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
|
|
38
40
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
39
41
|
import {importBackground} from './background-import.js';
|
|
42
|
+
import {chartHighlightPlan, applyChartHighlight} from './chart-highlight.js';
|
|
40
43
|
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
41
|
-
import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
|
|
44
|
+
import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
|
|
42
45
|
import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
|
|
43
46
|
import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
|
|
44
47
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
45
|
-
import {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
+
import {
|
|
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
|
+
} from "@openpresentation/opf/composition";
|
|
48
53
|
import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
|
|
49
54
|
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
50
55
|
import { unzipSync, zipSync } from "fflate";
|
|
51
56
|
import { XMLParser } from "fast-xml-parser";
|
|
52
57
|
import {
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
hasContentVariables,
|
|
59
|
+
isTemplate,
|
|
60
|
+
OPFCatalogsOptionError,
|
|
61
|
+
resolveSlideContext as resolveCoreSlideContext,
|
|
62
|
+
resolveVariables,
|
|
63
|
+
validate
|
|
55
64
|
} from "@openpresentation/opf";
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// Optional core exports are read from the namespace so an older published core still loads;
|
|
59
|
-
// resolveVariables ships with core RR-32.
|
|
60
|
-
import * as opfCore from "@openpresentation/opf";
|
|
65
|
+
import {checkFormat, isValidFormat} from "./format-check.js";
|
|
61
66
|
|
|
62
|
-
export {
|
|
67
|
+
export {checkTypefaces, inventoryTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
|
|
63
68
|
export {DEFAULT_SIGNAL_LIMITS, SIGNALS_VERSION, isMonospaceFamily} from './import-signals.js';
|
|
64
69
|
|
|
65
70
|
export const packageName = "@openpresentation/opf-pptx";
|
|
@@ -87,7 +92,8 @@ export class OPFPptxError extends Error {
|
|
|
87
92
|
this.name = "OPFPptxError";
|
|
88
93
|
this.code = code;
|
|
89
94
|
this.details = details;
|
|
90
|
-
if (details.
|
|
95
|
+
if (details.findings) this.findings = details.findings;
|
|
96
|
+
if (details.diagnostics) this.diagnostics = details.diagnostics;
|
|
91
97
|
if (details.path) this.path = details.path;
|
|
92
98
|
}
|
|
93
99
|
}
|
|
@@ -191,16 +197,12 @@ const DIMENSION_PRESETS = Object.freeze({
|
|
|
191
197
|
"4:3": Object.freeze({ widthInches: 10, heightInches: 7.5 }),
|
|
192
198
|
"16:10": Object.freeze({ widthInches: 10, heightInches: 6.25 }),
|
|
193
199
|
letter: Object.freeze({ widthInches: 11, heightInches: 8.5 }),
|
|
200
|
+
"1:1": Object.freeze({ widthInches: 7.5, heightInches: 7.5 }),
|
|
201
|
+
"4:5": Object.freeze({ widthInches: 7.5, heightInches: 9.375 }),
|
|
202
|
+
"9:16": Object.freeze({ widthInches: 7.5, heightInches: 40 / 3 }),
|
|
194
203
|
a4: Object.freeze({ widthInches: 11.69, heightInches: 8.27 })
|
|
195
204
|
});
|
|
196
205
|
|
|
197
|
-
const DEFAULTS = Object.freeze({
|
|
198
|
-
theme: "minimal",
|
|
199
|
-
colorScheme: "cool-horizon",
|
|
200
|
-
// Shared last-resort font scheme (core DEFAULT_FONT_SCHEME): every engine uses aptos.
|
|
201
|
-
fontScheme: "aptos"
|
|
202
|
-
});
|
|
203
|
-
|
|
204
206
|
const CHART_COLORS = [
|
|
205
207
|
"2874A6",
|
|
206
208
|
"1B4F72",
|
|
@@ -216,6 +218,12 @@ const CHART_COLORS = [
|
|
|
216
218
|
"64748B"
|
|
217
219
|
];
|
|
218
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
|
+
|
|
219
227
|
export async function toPptx(input, options = {}) {
|
|
220
228
|
if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
|
|
221
229
|
throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
|
|
@@ -223,20 +231,30 @@ export async function toPptx(input, options = {}) {
|
|
|
223
231
|
if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
|
|
224
232
|
throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
|
|
225
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};
|
|
226
239
|
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
227
240
|
assertValidBoundary(presentation);
|
|
228
|
-
options
|
|
241
|
+
// `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
|
|
242
|
+
// chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
|
|
243
|
+
options = {...options, textMeasurement: chosenFamilyMeasurement(options.fonts?.textMeasurement), svgRasters: new Map()};
|
|
229
244
|
|
|
230
245
|
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
231
246
|
context.listMarkers = new Map();
|
|
232
247
|
context.imagePlaceholders = new Map();
|
|
233
248
|
context.tableHeaders = new Map();
|
|
234
249
|
context.tableCells = new Map();
|
|
235
|
-
|
|
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();
|
|
236
254
|
context.svgPictures = new Map();
|
|
237
255
|
context.pictureText = new Map();
|
|
238
|
-
context.slideImages = new Map();
|
|
239
256
|
context.watermarks = new Map();
|
|
257
|
+
context.textWatermarks = new Map();
|
|
240
258
|
context.logos = new Map();
|
|
241
259
|
context.logoPlaceholderTags = new Map();
|
|
242
260
|
context.bulletImages = new Map();
|
|
@@ -249,6 +267,7 @@ export async function toPptx(input, options = {}) {
|
|
|
249
267
|
context.plainTextTags = new Map();
|
|
250
268
|
context.timelineTags = new Map();
|
|
251
269
|
context.quoteTags = new Map();
|
|
270
|
+
context.quotePhotos = new Map();
|
|
252
271
|
context.furnitureTags = new Map();
|
|
253
272
|
context.furnitureLogoTags = new Map();
|
|
254
273
|
context.furnitureFields = new Map();
|
|
@@ -261,6 +280,7 @@ export async function toPptx(input, options = {}) {
|
|
|
261
280
|
context.metricTags = new Map();
|
|
262
281
|
context.metricDescriptions = new Map();
|
|
263
282
|
context.chartHeadings = new Map();
|
|
283
|
+
context.chartAlts = new Map();
|
|
264
284
|
context.chartFonts = new Map();
|
|
265
285
|
context.chartPalettes = new Map();
|
|
266
286
|
context.chartex = new Map();
|
|
@@ -277,15 +297,19 @@ export async function toPptx(input, options = {}) {
|
|
|
277
297
|
throw new OPFPptxError('invalid-chartex-mode', 'chartex must be auto, native or fallback.', {path: 'options.chartex'});
|
|
278
298
|
}
|
|
279
299
|
context.chartexMode = options.chartex ?? 'auto';
|
|
280
|
-
Object.assign(context, exportTheme(presentation, context));
|
|
281
|
-
context.
|
|
300
|
+
Object.assign(context, exportTheme(presentation, context, options));
|
|
301
|
+
context.reportedReferences = new Set();
|
|
282
302
|
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
283
303
|
context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
|
|
284
304
|
mode: options.provenance ?? "full",
|
|
285
|
-
|
|
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
|
+
},
|
|
286
310
|
report: diagnostic => options.onDiagnostic?.(diagnostic)
|
|
287
311
|
});
|
|
288
|
-
context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
|
|
312
|
+
context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic, {catalogs: options.catalogs});
|
|
289
313
|
// Slide section labels become PowerPoint's native section list (src/sections.js).
|
|
290
314
|
// A label is an XML attribute: the characters text runs reject are rejected here too.
|
|
291
315
|
context.sections = presentation.slides.map((slide, index) => {
|
|
@@ -297,15 +321,16 @@ export async function toPptx(input, options = {}) {
|
|
|
297
321
|
context.masterBackground = masterBackground(context);
|
|
298
322
|
context.linkSentinels = linkSentinels(presentation);
|
|
299
323
|
const pptx = new PptxGenJS();
|
|
300
|
-
configurePresentation(pptx, presentation, {...context,fonts:
|
|
324
|
+
configurePresentation(pptx, presentation, {...context,fonts:exportSlideContext(presentation,0,context,options).fonts});
|
|
301
325
|
|
|
302
326
|
for (let index = 0; index < presentation.slides.length; index += 1) {
|
|
303
327
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
304
328
|
}
|
|
305
329
|
|
|
306
|
-
// FF-08: pitchFamily per exported family, from each slide's resolved scheme
|
|
307
|
-
|
|
308
|
-
|
|
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).
|
|
332
|
+
context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
|
|
333
|
+
catalogRecords(presentation, "fontSchemes", {catalogs: options.catalogs}).map(entry => entry.record));
|
|
309
334
|
|
|
310
335
|
let raw;
|
|
311
336
|
try {
|
|
@@ -325,6 +350,9 @@ export async function toPptx(input, options = {}) {
|
|
|
325
350
|
export async function fromPptx(input, options = {}) {
|
|
326
351
|
// Opt-in raw shape signals (import-signals.js). Validated first so a bad option fails before any work.
|
|
327
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);
|
|
328
356
|
const entries = readPptxZip(input);
|
|
329
357
|
const presentationDoc = parseRequiredXml(entries, "ppt/presentation.xml");
|
|
330
358
|
const presentationRoot = presentationDoc["p:presentation"];
|
|
@@ -358,12 +386,12 @@ export async function fromPptx(input, options = {}) {
|
|
|
358
386
|
const theme = relatedPart(relatedPart(relatedPart(path, 'slideLayout'), 'slideMaster'), 'theme');
|
|
359
387
|
return theme ? {path: theme, xml: decodeText(entries[theme])} : null;
|
|
360
388
|
}),
|
|
361
|
-
catalogs:
|
|
389
|
+
catalogs: options.catalogs
|
|
362
390
|
});
|
|
363
391
|
if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
|
|
364
392
|
if (core.description) imported.description = core.description;
|
|
365
393
|
if (core.author) imported.author = splitAuthors(core.author);
|
|
366
|
-
const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
|
|
394
|
+
const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels, options.catalogs);
|
|
367
395
|
const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
|
|
368
396
|
if (Object.keys(design).length) imported.design = design;
|
|
369
397
|
|
|
@@ -384,6 +412,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
384
412
|
const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
|
|
385
413
|
if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
|
|
386
414
|
if (furniture.organization) imported.organization = furniture.organization;
|
|
415
|
+
if (furniture.speaker) imported.speaker = furniture.speaker;
|
|
387
416
|
|
|
388
417
|
// RR-54: the datasets recorded at export (OPF_DATASETS_V1). Chart and table frames restore their dataset references against
|
|
389
418
|
// them, so the document carries them before document provenance validates the restored document.
|
|
@@ -391,7 +420,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
391
420
|
const mediaRegistry = Object.create(null);
|
|
392
421
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
393
422
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
394
|
-
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), 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]));
|
|
395
424
|
}
|
|
396
425
|
if (datasets) imported.datasets = datasets;
|
|
397
426
|
// Native sections (PowerPoint's own section list, `Default Section` = none)
|
|
@@ -421,14 +450,14 @@ export async function fromPptx(input, options = {}) {
|
|
|
421
450
|
let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
|
|
422
451
|
let restoredGroups = [];
|
|
423
452
|
try {
|
|
424
|
-
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
|
|
425
|
-
//
|
|
426
|
-
|
|
453
|
+
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true, speakerConflict: furniture.speakerConflict === true,
|
|
454
|
+
// Stored layout ids also resolve in the host's registered catalogs (FA-23).
|
|
455
|
+
catalogs: options.catalogs,
|
|
427
456
|
nativeSections: slideSections,
|
|
428
457
|
slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
|
|
429
458
|
// The stored language wins while the runs still carry its tag (FF-07).
|
|
430
459
|
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
431
|
-
imported = applyDocumentProvenance(imported, restored,
|
|
460
|
+
imported = applyDocumentProvenance(imported, restored, checkFormat, report);
|
|
432
461
|
slideProvenance = restored.slides;
|
|
433
462
|
restoredGroups = restored.groups;
|
|
434
463
|
} catch (error) {
|
|
@@ -443,11 +472,11 @@ export async function fromPptx(input, options = {}) {
|
|
|
443
472
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
444
473
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
445
474
|
|
|
446
|
-
const
|
|
447
|
-
if (!
|
|
475
|
+
const validation = checkFormat(imported);
|
|
476
|
+
if (!validation.valid) {
|
|
448
477
|
throw new OPFPptxError("invalid-import-opf", "Imported PPTX did not produce valid OPF.", {
|
|
449
|
-
|
|
450
|
-
|
|
478
|
+
findings: validation.findings.filter(finding => finding.severity === "error"),
|
|
479
|
+
report: validation
|
|
451
480
|
});
|
|
452
481
|
}
|
|
453
482
|
|
|
@@ -458,7 +487,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
458
487
|
const archive = {part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]};
|
|
459
488
|
const signals = extractSignals({archive, presentationRoot, slidePaths, themeFacts: themeFactsFor(slidePaths[0], archive), limits: signalLimits,
|
|
460
489
|
recorded: furnitureContexts.map(context => context.signalSources), slideProvenance, contentPaths});
|
|
461
|
-
return {
|
|
490
|
+
return {presentation: imported, signals};
|
|
462
491
|
}
|
|
463
492
|
|
|
464
493
|
// RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
|
|
@@ -623,6 +652,17 @@ function readCoreProperties(entries) {
|
|
|
623
652
|
};
|
|
624
653
|
}
|
|
625
654
|
|
|
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,
|
|
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;
|
|
661
|
+
const id = referenceId(reference);
|
|
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;
|
|
664
|
+
}
|
|
665
|
+
|
|
626
666
|
function dimensionsFromPresentation(presentationRoot) {
|
|
627
667
|
const size = presentationRoot["p:sldSz"];
|
|
628
668
|
const width = emuToInches(size?.cx);
|
|
@@ -644,21 +684,34 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
644
684
|
const background = importBackground(slidePath, resolveCanvasDimensions(dimensions), {
|
|
645
685
|
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
646
686
|
}, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
|
|
647
|
-
|
|
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};
|
|
648
703
|
if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
|
|
649
704
|
if (furniture.section !== undefined) slide.section = furniture.section;
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: slideImagePath});
|
|
655
|
-
}),
|
|
656
|
-
diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
|
|
657
|
-
if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
|
|
658
|
-
if (slideImage.image) slide.image = slideImage.image;
|
|
659
|
-
nativeContext.slideImagePictures = slideImage.consumed;
|
|
660
|
-
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;
|
|
661
709
|
const watermarkPath = `slides.${slideIndex}.design.watermark`;
|
|
710
|
+
// FA-13: a text watermark is a tagged native text box; it is not content and never imports as a text block.
|
|
711
|
+
const textWatermark = importTextWatermark(nativeContext.shapes, nativeContext.paragraphs.map(shapeParagraphs => shapeParagraphs.map(paragraph => ({text: paragraph.text}))), relationships, entries, slideIndex,
|
|
712
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
|
|
713
|
+
if (textWatermark.design) slide.design = {...slide.design, ...textWatermark.design};
|
|
714
|
+
nativeContext.watermarkShapes = textWatermark.consumed;
|
|
662
715
|
const watermark = importWatermark(nativeContext.pictures, relationships, entries, slideIndex,
|
|
663
716
|
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
664
717
|
// The recorded fit is re-derived from design.watermark.
|
|
@@ -684,12 +737,22 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
684
737
|
// fill an absent role by geometry; explicit native placeholders still apply.
|
|
685
738
|
const inferHeadings = !items.some(item => item.heading||item.sourceText);
|
|
686
739
|
const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
|
|
740
|
+
// FA-10: a heading is its plain text, or TextRun[] when its native runs are formatted differently from one another (the way
|
|
741
|
+
// body text reads current native runs). A tagged line group brings its own value; one native shape (a title placeholder, or the
|
|
742
|
+
// shape the importer took for the title) is read through the native body reader.
|
|
743
|
+
const headingText = item => {
|
|
744
|
+
const key = item.sources?.length === 1 ? /^sp:(\d+)$/.exec(item.sources[0]) : null;
|
|
745
|
+
const parts = item.richText === undefined && key ? joinRichLines([{index: Number(key[1])}], nativeContext.readBody, () => '') : undefined;
|
|
746
|
+
const value = item.richText ?? (parts ? headingValue(parts) : undefined);
|
|
747
|
+
// Only when it reads as the same words as the plain path: the native text is authoritative.
|
|
748
|
+
return Array.isArray(value) && value.map(run => typeof run === 'string' ? run : run.text).join('') === item.text ? value : item.text;
|
|
749
|
+
};
|
|
687
750
|
const tagItem=heading('tag');
|
|
688
|
-
if(tagItem)slide.tag=tagItem
|
|
751
|
+
if(tagItem)slide.tag=headingText(tagItem);
|
|
689
752
|
const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
|
|
690
|
-
if (titleItem) slide.title = titleItem
|
|
753
|
+
if (titleItem) slide.title = headingText(titleItem);
|
|
691
754
|
const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
|
|
692
|
-
if (subtitleItem) slide.subtitle = subtitleItem
|
|
755
|
+
if (subtitleItem) slide.subtitle = headingText(subtitleItem);
|
|
693
756
|
|
|
694
757
|
// Heading inference keeps its existing scalar/native metrics. Only remaining
|
|
695
758
|
// ordinary body shapes gain current rich values; tagged recovery is separate.
|
|
@@ -760,16 +823,21 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
760
823
|
nativeContext.signalRoles = roles;
|
|
761
824
|
const readBody = nativeBodyReader(slidePath, {
|
|
762
825
|
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
763
|
-
}, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
|
|
826
|
+
}, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}), {deckLang: options.deckLang, codeFamily: options.codeFamily});
|
|
764
827
|
const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
|
|
765
828
|
const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
|
|
766
829
|
const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
767
830
|
const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
|
|
768
831
|
nativeContext.mediaAssets = media.assets;
|
|
769
|
-
|
|
832
|
+
nativeContext.readBody=readBody;
|
|
833
|
+
const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}),readBody);
|
|
770
834
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
771
835
|
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
772
|
-
const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`})
|
|
836
|
+
const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}),nativeContext.pictures,
|
|
837
|
+
picture=>importPicture(entries,picture,slidePath,relationships,diagnostic=>{
|
|
838
|
+
// The recorded crop is re-derived from the circular frame of the quote photo.
|
|
839
|
+
if(diagnostic.code!=='unsupported-image-crop')options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote.photo`});
|
|
840
|
+
}),readBody);
|
|
773
841
|
// RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
|
|
774
842
|
const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
|
|
775
843
|
nativeContext.annotations=annotations;
|
|
@@ -778,7 +846,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
778
846
|
if(item){
|
|
779
847
|
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
780
848
|
if(bounds.length){const x=Math.min(...bounds.map(b=>b.x)),y=Math.min(...bounds.map(b=>b.y));item.bounds={x,y,w:Math.max(...bounds.map(b=>b.x+b.w))-x,h:Math.max(...bounds.map(b=>b.y+b.h))-y};}
|
|
781
|
-
items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes)});
|
|
849
|
+
items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes),...(group.richText!==undefined?{richText:group.richText}:{})});
|
|
782
850
|
}
|
|
783
851
|
}
|
|
784
852
|
for(const group of timelines.items){
|
|
@@ -787,18 +855,18 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
787
855
|
items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
|
|
788
856
|
}
|
|
789
857
|
for(const group of quotes.items){
|
|
790
|
-
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
858
|
+
const bounds=[...group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])),...group.pictures.map(picture=>shapeBounds(picture['p:spPr']?.['a:xfrm']))].filter(Boolean);
|
|
791
859
|
let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
|
|
792
|
-
items.push({kind:'quote',bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
|
|
860
|
+
items.push({kind:'quote',bounds:union,payload:group.payload,sources:[...sourcesOf(group.shapes),...group.pictures.map(picture=>`pic:${nativeContext.pictures.indexOf(picture)}`)]});
|
|
793
861
|
}
|
|
794
862
|
for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload,sources:sourcesOf([item.shape])});
|
|
795
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])});
|
|
796
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])});
|
|
797
865
|
for (const [index,shape] of shapes.entries()) {
|
|
798
|
-
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'
|
|
799
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;
|
|
800
868
|
if (role) roles.set(shapeKeys.get(shape), role);
|
|
801
|
-
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;
|
|
802
870
|
if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
|
|
803
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;
|
|
804
872
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
@@ -834,10 +902,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
834
902
|
}
|
|
835
903
|
|
|
836
904
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
837
|
-
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;
|
|
838
906
|
if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
|
|
839
|
-
|
|
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}`}); };
|
|
840
910
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
911
|
+
if (item?.payload && treatment) item.payload = {...item.payload, ...structuredClone(treatment)};
|
|
841
912
|
if (item) items.push({...item, sources: [`pic:${index}`]});
|
|
842
913
|
}
|
|
843
914
|
|
|
@@ -916,15 +987,18 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
916
987
|
: chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
|
|
917
988
|
const stored = imported && record('chart');
|
|
918
989
|
const part = relationships.get(chartRelId)?.path;
|
|
919
|
-
const
|
|
990
|
+
const restored = stored && part && entries[part]
|
|
920
991
|
? restore('chart', imported, () => restoreChartData(imported, stored, chartEvidence(decodeText(entries[part])), {datasets: data.datasets, report: diagnostic => data.report?.('chart', diagnostic)}))
|
|
921
992
|
: imported;
|
|
993
|
+
const chart = restored?.type === 'combo' ? elideComboDefault(restored, data.datasets) : restored;
|
|
994
|
+
// FA-09: the frame's descr (or PowerPoint's decorative marker) is the chart's text alternative.
|
|
995
|
+
const alt = chart ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
|
|
922
996
|
return {
|
|
923
997
|
kind: "chart",
|
|
924
998
|
bounds,
|
|
925
999
|
name,
|
|
926
1000
|
payload: chart
|
|
927
|
-
? { type: "chart", chart }
|
|
1001
|
+
? { type: "chart", chart: alt === undefined ? chart : { ...chart, alt } }
|
|
928
1002
|
: { type: "text", text: `PowerPoint chart: ${name || chartRelId}` }
|
|
929
1003
|
};
|
|
930
1004
|
}
|
|
@@ -1211,9 +1285,12 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
|
|
|
1211
1285
|
const plotArea = doc["c:chartSpace"]?.["c:chart"]?.["c:plotArea"];
|
|
1212
1286
|
if (!plotArea) return null;
|
|
1213
1287
|
|
|
1214
|
-
|
|
1288
|
+
// FA-15: a clustered column group with one or two line groups is a combo chart; its series are read in c:order.
|
|
1289
|
+
const combo = comboFromNative(plotArea);
|
|
1290
|
+
const comboSeries = combo ? comboSeriesOf(combo) : undefined;
|
|
1291
|
+
const chartNode = combo ? {node: combo.bar, type: 'combo'} : firstChartNode(plotArea);
|
|
1215
1292
|
if (!chartNode) return null;
|
|
1216
|
-
const series = asArray(chartNode.node["c:ser"]);
|
|
1293
|
+
const series = comboSeries ? comboSeries.map(entry => entry.ser) : asArray(chartNode.node["c:ser"]);
|
|
1217
1294
|
if (series.length === 0) return null;
|
|
1218
1295
|
if (series.length > MAX_CHART_CACHE_POINTS) {
|
|
1219
1296
|
throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 100,000-series import limit; reduce its series before importing.', {path: relationship.path});
|
|
@@ -1226,7 +1303,8 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
|
|
|
1226
1303
|
const xCode = chartNode.type === 'scatter' ? cacheFormatCode(series[0]?.["c:xVal"]) : undefined;
|
|
1227
1304
|
// RR-35: axis titles, legend position and data labels read back into the chart's option fields.
|
|
1228
1305
|
const withOptions = chart => {
|
|
1229
|
-
const {options, notes} = chartOptionsFromClassic(doc["c:chartSpace"], {chartNode: chartNode.node, target: chartTargetFor(chartNode.type), seriesCount: series.length, circular: chartNode.type === 'pie' || chartNode.type === 'doughnut', scatter: chartNode.type === 'scatter', seriesFormats: valueCodes
|
|
1306
|
+
const {options, notes} = chartOptionsFromClassic(doc["c:chartSpace"], {chartNode: chartNode.node, target: chartTargetFor(chartNode.type), seriesCount: series.length, circular: chartNode.type === 'pie' || chartNode.type === 'doughnut', scatter: chartNode.type === 'scatter', seriesFormats: valueCodes,
|
|
1307
|
+
...(combo ? {combo: {lineNode: combo.lines[0]?.node, secondaryValueAxis: secondaryValueAxisOf(plotArea, combo)}} : {})});
|
|
1230
1308
|
for (const note of notes) report?.(note);
|
|
1231
1309
|
if (budget.rejected?.length) report?.({code: 'chart-value-not-numeric', option: 'data', message: `${budget.rejected.length} cached chart ${budget.rejected.length === 1 ? 'value is' : 'values are'} not a number (first at ${budget.rejected[0]}) and ${budget.rejected.length === 1 ? 'imports as a gap' : 'import as gaps'}, never as a guessed value.`});
|
|
1232
1310
|
return chart && Object.keys(options).length ? {...chart, ...options} : chart;
|
|
@@ -1257,10 +1335,57 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
|
|
|
1257
1335
|
data: {
|
|
1258
1336
|
columns: formattedColumns([readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names], [undefined, ...valueCodes], report),
|
|
1259
1337
|
rows
|
|
1260
|
-
}
|
|
1338
|
+
},
|
|
1339
|
+
...(comboSeries ? comboFields(comboSeries, names, report) : {})
|
|
1261
1340
|
});
|
|
1262
1341
|
}
|
|
1263
1342
|
|
|
1343
|
+
// FA-15: the series of a combo plot area with their role and axis, in c:order (document order breaks ties).
|
|
1344
|
+
function comboSeriesOf(combo) {
|
|
1345
|
+
const entries = [
|
|
1346
|
+
...asArray(combo.bar["c:ser"]).map(ser => ({ser, role: 'bar', secondary: false})),
|
|
1347
|
+
...combo.lines.flatMap(group => asArray(group.node["c:ser"]).map(ser => ({ser, role: 'line', secondary: group.secondary}))),
|
|
1348
|
+
];
|
|
1349
|
+
const order = (entry, index) => {
|
|
1350
|
+
const value = Number(entry.ser?.["c:order"]?.val);
|
|
1351
|
+
return Number.isFinite(value) ? value : index;
|
|
1352
|
+
};
|
|
1353
|
+
return entries.map((entry, index) => ({...entry, key: order(entry, index), index})).sort((a, b) => a.key - b.key || a.index - b.index);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// The c:valAx a secondary line group plots against (its axis that the column group does not use), or undefined.
|
|
1357
|
+
function secondaryValueAxisOf(plotArea, combo) {
|
|
1358
|
+
const group = combo.lines.find(entry => entry.secondary);
|
|
1359
|
+
if (!group) return undefined;
|
|
1360
|
+
const ids = new Set(asArray(group.node["c:axId"]).map(axis => String(axis?.val)));
|
|
1361
|
+
return asArray(plotArea["c:valAx"]).find(axis => ids.has(String(axis?.["c:axId"]?.val)));
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// `line` and `secondaryAxis` of an imported combo chart by series name (elideComboDefault drops a `line` that is the default).
|
|
1365
|
+
// Combo lines always draw markers in OPF; a native line without markers is reported, never silently changed.
|
|
1366
|
+
function comboFields(comboSeries, names, report) {
|
|
1367
|
+
const line = names.filter((_, index) => comboSeries[index].role === 'line');
|
|
1368
|
+
const secondaryAxis = names.filter((_, index) => comboSeries[index].secondary);
|
|
1369
|
+
const unmarked = names.filter((_, index) => comboSeries[index].role === 'line' && comboSeries[index].ser?.["c:marker"]?.["c:symbol"]?.val === 'none');
|
|
1370
|
+
if (unmarked.length) report?.({option: 'line', message: `The combo chart's line ${unmarked.length === 1 ? 'series' : 'series'} ${unmarked.map(name => `'${name}'`).join(', ')} ${unmarked.length === 1 ? 'has' : 'have'} no markers; OPF combo lines always draw markers, so ${unmarked.length === 1 ? 'it imports' : 'they import'} with markers.`});
|
|
1371
|
+
if (new Set(line).size !== line.length || new Set(names).size !== names.length) {
|
|
1372
|
+
report?.({option: 'line', message: 'Two series of the combo chart share a name, so which of them is a line cannot be said by name; the chart imports with its default line (the last series).'});
|
|
1373
|
+
return {};
|
|
1374
|
+
}
|
|
1375
|
+
return {...(line.length ? {line} : {}), ...(secondaryAxis.length ? {secondaryAxis} : {})};
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// An imported combo chart writes `line` only when it differs from the default (the last plotted series), judged on the final
|
|
1379
|
+
// data (a restored data record can order the columns differently from the native series).
|
|
1380
|
+
function elideComboDefault(chart, datasets) {
|
|
1381
|
+
if (!Array.isArray(chart.line)) return chart;
|
|
1382
|
+
const {line, ...rest} = chart;
|
|
1383
|
+
const resolved = resolveChartData(rest, {datasets: datasets ?? {}});
|
|
1384
|
+
if (!resolved.ok || !Array.isArray(resolved.combo)) return chart;
|
|
1385
|
+
const defaults = resolved.columns.slice(1).filter((_, index) => resolved.combo[index]?.role === 'line');
|
|
1386
|
+
return defaults.length === line.length && defaults.every(name => line.includes(name)) ? rest : chart;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1264
1389
|
// The format code of a numeric cache (c:numRef/c:numCache or c:numLit), or undefined.
|
|
1265
1390
|
function cacheFormatCode(node) {
|
|
1266
1391
|
const cache = node?.["c:numRef"]?.["c:numCache"] ?? node?.["c:numLit"];
|
|
@@ -1505,60 +1630,72 @@ function parseInput(input) {
|
|
|
1505
1630
|
// required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
|
|
1506
1631
|
// deck, not the template form, so fromPptx returns the filled deck.
|
|
1507
1632
|
function resolveTemplateInput(presentation, options) {
|
|
1508
|
-
if (
|
|
1633
|
+
if (!isPlainObject(presentation)) return presentation;
|
|
1509
1634
|
const values = options.variables;
|
|
1510
1635
|
if (values !== undefined && !isPlainObject(values)) {
|
|
1511
1636
|
throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
|
|
1512
1637
|
}
|
|
1513
|
-
const template =
|
|
1514
|
-
if (!template && !
|
|
1515
|
-
const result =
|
|
1638
|
+
const template = isTemplate(presentation);
|
|
1639
|
+
if (!template && !hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
1640
|
+
const result = resolveVariables(presentation, values ?? {}, {examples: template});
|
|
1516
1641
|
const errors = result.diagnostics.filter(entry => entry.severity === "error");
|
|
1517
1642
|
if (errors.length) {
|
|
1518
|
-
throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {
|
|
1643
|
+
throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {findings: errors, path: errors[0].path});
|
|
1519
1644
|
}
|
|
1520
1645
|
for (const entry of result.diagnostics) {
|
|
1521
|
-
if (entry.code === "variable-example-used") options.onDiagnostic?.({code:
|
|
1646
|
+
if (entry.code === "variable-example-used" || entry.code === "variable-builtin-missing") options.onDiagnostic?.({code: entry.code, path: entry.path, message: entry.message, id: entry.id});
|
|
1522
1647
|
}
|
|
1523
1648
|
return result.presentation;
|
|
1524
1649
|
}
|
|
1525
1650
|
|
|
1526
1651
|
function assertValidBoundary(presentation) {
|
|
1527
|
-
const
|
|
1528
|
-
if (!
|
|
1652
|
+
const report = checkFormat(presentation);
|
|
1653
|
+
if (!report.valid) {
|
|
1529
1654
|
throw new OPFPptxError("invalid-opf", "OPF validation failed.", {
|
|
1530
|
-
|
|
1531
|
-
|
|
1655
|
+
findings: report.findings.filter(finding => finding.severity === "error"),
|
|
1656
|
+
report
|
|
1532
1657
|
});
|
|
1533
1658
|
}
|
|
1534
1659
|
}
|
|
1535
1660
|
|
|
1536
|
-
|
|
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,
|
|
1662
|
+
// layout, font families, alignment and content box as composeSlide's options, plus the theme, colour scheme and font scheme
|
|
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).
|
|
1666
|
+
function coreContext(presentation, index, options) {
|
|
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
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// The families composition and the package use: core's, with the measurement provider's own resolution of the chosen family
|
|
1678
|
+
// (FF-31) applied to the three roles the exporter writes.
|
|
1679
|
+
function exportFonts(core, textMeasurement) {
|
|
1680
|
+
const {fontFamilies} = core.options, scheme = core.resolved.fontScheme;
|
|
1681
|
+
const fonts = {id: scheme.id, ...fontFamilies, scheme: {type: scheme.type, major: scheme.major, minor: scheme.minor}};
|
|
1682
|
+
for (const role of ["heading", "body", "code"]) fonts[role] = resolveTextStyle({fontFamily: fonts[role], fontWeight: role === "heading" ? 700 : 400}, textMeasurement).fontFamily;
|
|
1683
|
+
return fonts;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
function resolvePresentationContext(presentation, options, core = coreContext({...presentation, slides: [{}]}, 0, {...options, textMeasurement: undefined})) {
|
|
1537
1687
|
const design = presentation.design ?? {};
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
DEFAULTS.colorScheme
|
|
1544
|
-
);
|
|
1545
|
-
const fontScheme = resolveDesignRecord(
|
|
1546
|
-
presentation,
|
|
1547
|
-
"fontSchemes",
|
|
1548
|
-
design.fontScheme ?? theme?.fontScheme,
|
|
1549
|
-
DEFAULTS.fontScheme
|
|
1550
|
-
);
|
|
1551
|
-
// Shared rule (core resolveFontSchemeReference): an unresolved id falls back to the
|
|
1552
|
-
// DEFAULTS.fontScheme record as the base; resolveSlideContext reports it once per path.
|
|
1553
|
-
const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
|
|
1554
|
-
const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
|
|
1555
|
-
const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
|
|
1688
|
+
// `core` defaults to the deck level: the resolution of a slide that sets nothing of its own.
|
|
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.
|
|
1691
|
+
const {theme, colorScheme} = core.resolved;
|
|
1692
|
+
const dimensions = {widthInches: core.options.width / 96, heightInches: core.options.height / 96};
|
|
1556
1693
|
const variables = resolveVariableColors(presentation.variables);
|
|
1557
1694
|
const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
|
|
1558
|
-
const fonts =
|
|
1559
|
-
for
|
|
1560
|
-
const
|
|
1561
|
-
const
|
|
1695
|
+
const fonts = exportFonts(core, options.textMeasurement);
|
|
1696
|
+
// One resolution for every color role (core resolveColorRoles), shared with the SVG preview and the audit.
|
|
1697
|
+
const roles = resolveColorRoles(colorScheme, {background: `#${background}`});
|
|
1698
|
+
const textColor = normalizeHex(roles.text);
|
|
1562
1699
|
|
|
1563
1700
|
return {
|
|
1564
1701
|
seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
|
|
@@ -1568,16 +1705,19 @@ function resolvePresentationContext(presentation, options) {
|
|
|
1568
1705
|
compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
|
|
1569
1706
|
layoutName: "OPF_CANVAS",
|
|
1570
1707
|
dimensions,
|
|
1708
|
+
theme,
|
|
1571
1709
|
colorScheme,
|
|
1572
1710
|
backgroundDefinition: design.background ?? theme?.background,
|
|
1573
1711
|
fonts,
|
|
1574
|
-
|
|
1712
|
+
// The color roles a ColorRef names (primary, secondary, accent, background, surface, text, textSecondary, hyperlink).
|
|
1713
|
+
roles,
|
|
1575
1714
|
colors: {
|
|
1576
1715
|
background,
|
|
1577
1716
|
text: textColor,
|
|
1578
|
-
mutedText: normalizeHex(
|
|
1579
|
-
accent
|
|
1580
|
-
|
|
1717
|
+
mutedText: normalizeHex(roles.textSecondary),
|
|
1718
|
+
// The primary color, which the exporter draws accent fills (table headers, tag, media badge) in. The `accent` ColorRef role is roles.accent.
|
|
1719
|
+
accent: normalizeHex(roles.primary),
|
|
1720
|
+
surface: normalizeHex(roles.surface),
|
|
1581
1721
|
border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
|
|
1582
1722
|
},
|
|
1583
1723
|
variables
|
|
@@ -1618,11 +1758,31 @@ function contrastRatio(first, second) {
|
|
|
1618
1758
|
const a = relativeLuminance(first), b = relativeLuminance(second);
|
|
1619
1759
|
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
1620
1760
|
}
|
|
1761
|
+
// The tag's color as a literal (the one tagColor writes, resolved).
|
|
1762
|
+
function tagHex(context) {
|
|
1763
|
+
return contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST ? context.colors.text : context.colors.accent;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
// The defaults a rich heading or quote body is exported with on every run (weight, color, size, family). The tag records them so that
|
|
1767
|
+
// import can tell the heading's own look from run formatting the author chose.
|
|
1768
|
+
function richBase(fit, style, color) {
|
|
1769
|
+
const font = nativeFontOptions(style);
|
|
1770
|
+
return {bold: font.bold === true, color: `#${normalizeHex(color)}`, fontSize: Math.round(fit.fontSize * .75 * 100) / 100, fontFamily: font.fontFace};
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1621
1773
|
function tagColor(context) {
|
|
1622
1774
|
if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
|
|
1623
1775
|
return nativeColor('primary', context.colors.accent, context);
|
|
1624
1776
|
}
|
|
1625
1777
|
|
|
1778
|
+
// A link run with no color of its own is written in the theme hyperlink color (a:schemeClr hlink where the deck theme
|
|
1779
|
+
// holds that exact color, else the literal), underlined by PowerPoint, as the preview draws it. A run color is kept.
|
|
1780
|
+
function linkColor(run, color, context) {
|
|
1781
|
+
const link = typeof run?.link === 'string' && /^(https?:|mailto:|tel:)/i.test(run.link);
|
|
1782
|
+
if (!link || !(run.color === undefined || run.color === null || run.color === '')) return color;
|
|
1783
|
+
return nativeColor('hyperlink', normalizeHex(context.roles.hyperlink), context);
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1626
1786
|
// FF-24c: two literals no document color uses stand in for hlink and folHlink
|
|
1627
1787
|
// (see nativeColor). When the document uses every candidate, those colors stay literal.
|
|
1628
1788
|
const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
|
|
@@ -1658,20 +1818,28 @@ function masterBackground(context) {
|
|
|
1658
1818
|
}
|
|
1659
1819
|
|
|
1660
1820
|
// The theme part carries the deck-level scheme (slide overrides stay literal)
|
|
1661
|
-
// and, for
|
|
1662
|
-
|
|
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) {
|
|
1663
1824
|
const reference = presentation.design?.theme;
|
|
1664
|
-
const
|
|
1665
|
-
const theme = id ? resolveDesignRecord(presentation, "themes", reference, DEFAULTS.theme) : null;
|
|
1825
|
+
const theme = typeof reference === "string" ? resolveReference(presentation, "themes", reference, {catalogs: options.catalogs}) : undefined;
|
|
1666
1826
|
const scheme = context.colorScheme;
|
|
1667
1827
|
return {
|
|
1668
1828
|
themeColors: themeSlotColors(scheme),
|
|
1669
1829
|
schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
|
|
1670
|
-
themeName: theme
|
|
1830
|
+
themeName: theme ? theme.record.name ?? theme.id : undefined
|
|
1671
1831
|
};
|
|
1672
1832
|
}
|
|
1673
1833
|
|
|
1674
|
-
|
|
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) {
|
|
1675
1843
|
const diagnostics = [], design = {};
|
|
1676
1844
|
const report = (code, path, message) => diagnostics.push({code, path, message});
|
|
1677
1845
|
const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
|
|
@@ -1683,16 +1851,22 @@ function importThemeDesign(entries, presentationRoot, presentationRels) {
|
|
|
1683
1851
|
return {design, diagnostics};
|
|
1684
1852
|
}
|
|
1685
1853
|
const {colors, unreadable} = readThemeSlotColors(clrScheme);
|
|
1686
|
-
const records =
|
|
1854
|
+
const records = hostDefaultRecords("colorSchemes", catalogs);
|
|
1687
1855
|
if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
|
|
1688
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.`);
|
|
1689
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
|
+
};
|
|
1690
1864
|
const recoveredTheme = recoverTheme({themeName: scalarText(theme?.name), colors, majorFont: latin("a:majorFont"), minorFont: latin("a:minorFont")}, {
|
|
1691
|
-
themes:
|
|
1692
|
-
|
|
1693
|
-
fontFamilies:
|
|
1694
|
-
const
|
|
1695
|
-
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;
|
|
1696
1870
|
}
|
|
1697
1871
|
});
|
|
1698
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.`);
|
|
@@ -1719,59 +1893,45 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
1719
1893
|
|
|
1720
1894
|
async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
|
|
1721
1895
|
const slide = pptx.addSlide();
|
|
1722
|
-
const slideContext =
|
|
1896
|
+
const slideContext = exportSlideContext(presentation, slideIndex, context, options);
|
|
1897
|
+
const { widthInches, heightInches } = slideContext.dimensions;
|
|
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.
|
|
1900
|
+
// Core's resolved options (canvas, layout, families, alignment, content box, darkBackground), with the exporter's own additions:
|
|
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 };
|
|
1904
|
+
// Core resolves every shared design key once (slide design, deck design, then the layout record's design): the text alignment of
|
|
1905
|
+
// each item (item.alignment), and each image item's own fit and treatment (item.image).
|
|
1906
|
+
const geometry = composeSlide(opfSlide, composeOptions);
|
|
1907
|
+
// The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
|
|
1908
|
+
if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
|
|
1723
1909
|
slide.background = { color: slideContext.colors.background };
|
|
1724
|
-
|
|
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;
|
|
1725
1913
|
const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
|
|
1726
|
-
const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
|
|
1914
|
+
const backgroundFill = backgroundDefinition === undefined ? null : schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
|
|
1727
1915
|
width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
|
|
1728
1916
|
}, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext), reference => resolveBackgroundColorRef(reference, slideContext.colorScheme, slideContext.variables));
|
|
1729
1917
|
if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
|
|
1730
1918
|
if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
|
|
1731
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.`});
|
|
1732
1920
|
}
|
|
1733
|
-
if (backgroundDefinition?.type === 'image') {
|
|
1734
|
-
const imagePath = `${backgroundPath}.image`;
|
|
1735
|
-
// An SVG background is its PNG raster at the slide's size: a slide background picture fill carries no SVG.
|
|
1736
|
-
const outcome = { box: { w: slideContext.dimensions.widthInches, h: slideContext.dimensions.heightInches }, cover: (backgroundDefinition.image?.fit ?? 'cover') !== 'contain' };
|
|
1737
|
-
const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
|
|
1738
|
-
if (resolved) {
|
|
1739
|
-
// PptxGenJS embeds the raster and its relationship; packaging replaces
|
|
1740
|
-
// its stretched fill with the fitted native picture fill.
|
|
1741
|
-
slide.background = { ...resolved };
|
|
1742
|
-
context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, { image: {
|
|
1743
|
-
fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
|
|
1744
|
-
width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
|
|
1745
|
-
} });
|
|
1746
|
-
} else if (!outcome.reported) {
|
|
1747
|
-
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.'});
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
1921
|
slide.color = slideContext.textColor;
|
|
1751
1922
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
1752
1923
|
|
|
1753
|
-
const { widthInches, heightInches } = slideContext.dimensions;
|
|
1754
|
-
const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
|
|
1755
|
-
if (opfSlide.layout && layout.id !== opfSlide.layout) throw new OPFPptxError("catalog-resolution-failed", `Layout '${opfSlide.layout}' needs an inline or bundled catalog record.`, { path: `slides.${slideIndex}.layout` });
|
|
1756
|
-
// design.titleAlignment/contentAlignment are Design properties (slide design
|
|
1757
|
-
// overrides the deck). Core only returns an accepted `placement.alignment`
|
|
1758
|
-
// with outline measurement, so the same effective values must also drive
|
|
1759
|
-
// text without placement, exactly as the renderer's preview does.
|
|
1760
|
-
const titleAlignment=opfSlide.design?.titleAlignment??presentation.design?.titleAlignment;
|
|
1761
|
-
const contentAlignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment;
|
|
1762
|
-
const fieldAlignment=field=>field==='title'?titleAlignment:contentAlignment;
|
|
1763
|
-
// Core composition resolves one alignment per composed item for every engine
|
|
1764
|
-
// (FF-29); the design fallback keeps cores published before item.alignment working.
|
|
1765
|
-
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1766
|
-
const composeOptions = { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, darkBackground: isDarkHex(slideContext.colors.background), textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) };
|
|
1767
|
-
const geometry = composeSlide(opfSlide, composeOptions);
|
|
1768
|
-
// The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
|
|
1769
|
-
if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
|
|
1770
1924
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
1771
1925
|
// The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
|
|
1772
1926
|
recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
|
|
1773
|
-
//
|
|
1774
|
-
|
|
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
|
+
};
|
|
1775
1935
|
await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
|
|
1776
1936
|
// Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
|
|
1777
1937
|
if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
|
|
@@ -1783,14 +1943,14 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1783
1943
|
const frame=item.frameBox;
|
|
1784
1944
|
context.cardTags.set(`OPF card ${item.path}`,item.path);
|
|
1785
1945
|
const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
|
|
1786
|
-
const surface=itemContext.colorScheme[
|
|
1946
|
+
const surface=itemContext.colorScheme.surface??itemContext.colorScheme[itemContext.roles.dark?'dark2':'light2']??`#${itemContext.colors.surface}`;
|
|
1787
1947
|
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
1788
1948
|
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
1789
1949
|
fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
|
|
1790
1950
|
}
|
|
1791
1951
|
if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
|
|
1792
|
-
addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:
|
|
1793
|
-
} else if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
1952
|
+
addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:item.alignment,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?tagColor(itemContext):itemContext.textColor});
|
|
1953
|
+
} else if (["title", "subtitle", "tag"].includes(item.field) && !item.text?.richLines) {
|
|
1794
1954
|
// Estimated-font headings use one native text box, which still needs a
|
|
1795
1955
|
// role tag. Role recovery must not depend on outline measurement support.
|
|
1796
1956
|
const objectName = `OPF heading ${item.path} line 0`;
|
|
@@ -1799,36 +1959,34 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1799
1959
|
...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
|
|
1800
1960
|
...nativeFontOptions(item.textStyle),
|
|
1801
1961
|
color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
|
|
1802
|
-
align: physicalAlignment(
|
|
1962
|
+
align: physicalAlignment(item.alignment, item.text.directions?.[0]),
|
|
1803
1963
|
...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
|
|
1804
1964
|
objectName,
|
|
1805
1965
|
breakLine: false
|
|
1806
1966
|
});
|
|
1807
1967
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1808
1968
|
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options,item.payload?.numbering!==undefined);
|
|
1809
|
-
} else if (
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
// PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
|
|
1821
|
-
if(item.text.directions?.[index]==='rtl')for(const run of runs)run.options.rtlMode=true;
|
|
1822
|
-
if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
1823
|
-
}
|
|
1969
|
+
} else if (["text", "title", "subtitle", "tag"].includes(item.field) && item.text?.richLines) {
|
|
1970
|
+
// FA-10: a TextRun[] heading draws and exports like rich body text; its lines are named and tagged as a heading (the body's are not).
|
|
1971
|
+
const heading = item.field !== 'text';
|
|
1972
|
+
addRichFitLines(slide, item.text, item.box, itemContext, {
|
|
1973
|
+
value: item.value, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
|
|
1974
|
+
fallback: item.field === 'tag' ? tagColor(itemContext) : itemContext.textColor,
|
|
1975
|
+
...(heading ? {
|
|
1976
|
+
objectName: index => `OPF heading ${item.path} line ${index}`,
|
|
1977
|
+
tag: (name, index, count, boundary) => context.headingTags.set(name, {v: 1, group: item.path, field: item.field, line: index, count, ...boundary, base: richBase(item.text, item.textStyle, item.field === 'tag' ? tagHex(itemContext) : itemContext.colors.text)})
|
|
1978
|
+
} : {})
|
|
1979
|
+
});
|
|
1824
1980
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
1825
|
-
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:physicalAlignment(
|
|
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);
|
|
1826
1984
|
} else {
|
|
1827
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.
|
|
1828
|
-
const mediaNames = item.caption ? new Set([...context.
|
|
1829
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment:
|
|
1986
|
+
const mediaNames = item.caption ? new Set([...context.images.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
|
|
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);
|
|
1830
1988
|
if (item.caption) {
|
|
1831
|
-
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));
|
|
1832
1990
|
addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
|
|
1833
1991
|
}
|
|
1834
1992
|
}
|
|
@@ -1847,24 +2005,24 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1847
2005
|
}
|
|
1848
2006
|
}
|
|
1849
2007
|
|
|
1850
|
-
|
|
2008
|
+
// One slide's export context: core's resolveSlideContext (`core`: composeSlide's options, the records and the diagnostics) and
|
|
2009
|
+
// the exporter's colours and measurement-resolved families derived from it.
|
|
2010
|
+
function exportSlideContext(presentation, slideIndex, baseContext, options) {
|
|
2011
|
+
const slide = presentation.slides[slideIndex];
|
|
1851
2012
|
const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
const id = resolved.unresolvedFontScheme;
|
|
1860
|
-
options.onDiagnostic?.({ code: "unresolved-font-scheme", path, id, fallback: DEFAULTS.fontScheme, message: `Font scheme '${id}' is not in the inline or bundled catalogs; using the default font scheme '${DEFAULTS.fontScheme}'.` });
|
|
1861
|
-
}
|
|
2013
|
+
const core = coreContext(presentation, slideIndex, options);
|
|
2014
|
+
const resolved = resolvePresentationContext(effective, options, core);
|
|
2015
|
+
// A reference that matched no record is reported once per place it is written.
|
|
2016
|
+
for (const diagnostic of core.diagnostics) {
|
|
2017
|
+
if (baseContext.reportedReferences?.has(diagnostic.path)) continue;
|
|
2018
|
+
baseContext.reportedReferences?.add(diagnostic.path);
|
|
2019
|
+
options.onDiagnostic?.(diagnostic);
|
|
1862
2020
|
}
|
|
1863
2021
|
if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
|
|
1864
2022
|
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
1865
2023
|
throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
|
|
1866
2024
|
}
|
|
1867
|
-
const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors,
|
|
2025
|
+
const slideContext = { ...baseContext, core, composeFonts: {...core.options.fontFamilies, heading: resolved.fonts.heading, body: resolved.fonts.body, code: resolved.fonts.code}, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, roles: resolved.roles, variables: resolved.variables };
|
|
1868
2026
|
// A slide-level color scheme (directly or through a slide theme) pins every
|
|
1869
2027
|
// named color on that slide to literal sRGB; see schemeColorValue().
|
|
1870
2028
|
const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
|
|
@@ -1909,7 +2067,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1909
2067
|
addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
|
|
1910
2068
|
break;
|
|
1911
2069
|
case "quote":
|
|
1912
|
-
addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
|
|
2070
|
+
await addQuotePayload(slide, presentation, payload.quote, quoteLayout, context, options, path);
|
|
1913
2071
|
break;
|
|
1914
2072
|
case "timeline":
|
|
1915
2073
|
addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
|
|
@@ -1946,7 +2104,7 @@ function richLineRuns(line,color,native,context) {
|
|
|
1946
2104
|
const fallback=color.replace(/^#/,'');
|
|
1947
2105
|
return line.fragments.map(fragment=>{
|
|
1948
2106
|
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
|
|
1949
|
-
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
|
|
2107
|
+
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:(fragment.nominalSize??fragment.fontSize)*.75,color:linkColor(fragment.run,color,context),underline:fragment.run.underline?{style:'sng',color:linkColor(fragment.run,color,context)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/(fragment.nominalSize??fragment.fontSize)*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:|tel:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined,...langOptions(context,fragment.style.lang)}};
|
|
1950
2108
|
});
|
|
1951
2109
|
}
|
|
1952
2110
|
// Every native line of a list, marker paragraph or not, is named for the list's
|
|
@@ -2044,27 +2202,131 @@ function addListPayload(slide, items, region, context) {
|
|
|
2044
2202
|
slide.addText(runs, textBoxOptions(region, context, 15));
|
|
2045
2203
|
}
|
|
2046
2204
|
|
|
2205
|
+
// A content picture outside an image block (a timeline event image): one native picture contained in its region.
|
|
2047
2206
|
async function addImagePayload(slide, presentation, asset, region, path, context, options) {
|
|
2048
|
-
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' };
|
|
2049
2217
|
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
2050
|
-
if (!resolved)
|
|
2051
|
-
|
|
2052
|
-
|
|
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);
|
|
2053
2227
|
}
|
|
2054
|
-
const
|
|
2055
|
-
context.
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
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
|
|
2066
2282
|
});
|
|
2067
|
-
|
|
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);
|
|
2068
2330
|
}
|
|
2069
2331
|
|
|
2070
2332
|
// design.watermark: one native picture per slide, added after the slide image
|
|
@@ -2076,13 +2338,14 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
2076
2338
|
const watermark = local ? opfSlide.design.watermark : presentation.design?.watermark;
|
|
2077
2339
|
if (watermark === undefined || watermark === null || watermark === false) return;
|
|
2078
2340
|
const path = local ? `slides.${slideIndex}.design.watermark` : 'design.watermark';
|
|
2079
|
-
|
|
2080
|
-
const asset =
|
|
2081
|
-
|
|
2082
|
-
|
|
2341
|
+
// The schema requires exactly one of src and text, so there is no source-less watermark to report.
|
|
2342
|
+
const asset = watermark;
|
|
2343
|
+
const {widthInches, heightInches} = slideContext.dimensions;
|
|
2344
|
+
// FA-13: a text watermark is one native text box with the opacity as text alpha, centered and rotated as core's layoutWatermark says.
|
|
2345
|
+
if (isPlainObject(asset) && typeof asset.text === 'string') {
|
|
2346
|
+
addTextWatermark(slide, asset, slideIndex, {width: widthInches * 96, height: heightInches * 96}, slideContext, context, options, path);
|
|
2083
2347
|
return;
|
|
2084
2348
|
}
|
|
2085
|
-
const {widthInches, heightInches} = slideContext.dimensions;
|
|
2086
2349
|
const box = watermarkBox(widthInches, heightInches);
|
|
2087
2350
|
const outcome = {box};
|
|
2088
2351
|
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
@@ -2097,6 +2360,23 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
2097
2360
|
slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
|
|
2098
2361
|
}
|
|
2099
2362
|
|
|
2363
|
+
// The stamp takes the slide's own resolved fonts and default text color (readable on that slide's background), as the preview does.
|
|
2364
|
+
function addTextWatermark(slide, watermark, slideIndex, size, slideContext, context, options, path) {
|
|
2365
|
+
const layout = layoutWatermark(watermark.text, size, {fontFamily: slideContext.fonts.heading, fontWeight: 700, textMeasurement: options.textMeasurement});
|
|
2366
|
+
// The schema requires text, so this is null only for text that draws nothing (the preview draws nothing either).
|
|
2367
|
+
if (!layout) return;
|
|
2368
|
+
const opacity = watermarkOpacity(watermark);
|
|
2369
|
+
const {box} = layout;
|
|
2370
|
+
slide.addText(layout.text, {
|
|
2371
|
+
...textBoxOptions({x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96}, slideContext, layout.fontSize * .75),
|
|
2372
|
+
...nativeFontOptions(layout.style), bold: true,
|
|
2373
|
+
color: slideContext.textColor, transparency: Math.round((1 - opacity) * 100000) / 1000,
|
|
2374
|
+
align: 'center', valign: 'middle', fit: 'none', wrap: false, lineSpacingMultiple: 1,
|
|
2375
|
+
rotate: (layout.rotation + 360) % 360, objectName: watermarkTextName()
|
|
2376
|
+
});
|
|
2377
|
+
context.textWatermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, text: layout.text, opacity});
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2100
2380
|
// The deck logo (design.logo, a slide's own logo or the primary organization's) on a cover or section slide: one native
|
|
2101
2381
|
// picture per slide at core's geometry.logo box, fitted without cropping, anchored left and vertically centered, after
|
|
2102
2382
|
// the watermark and before content (the preview's paint order). The frame and the provenance tag are written after
|
|
@@ -2116,54 +2396,6 @@ async function addLogo(slide, presentation, logo, slideIndex, slideContext, cont
|
|
|
2116
2396
|
slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
|
|
2117
2397
|
}
|
|
2118
2398
|
|
|
2119
|
-
// design.slideImage: one native picture at the shared frame, beneath content.
|
|
2120
|
-
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
2121
|
-
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
2122
|
-
const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
|
|
2123
|
-
const outcome = { box, cover: image.fill === 'crop' };
|
|
2124
|
-
const resolved = await resolveImage(image.value, presentation, options, image.sourcePath, outcome);
|
|
2125
|
-
if (!resolved) {
|
|
2126
|
-
addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
|
|
2127
|
-
return;
|
|
2128
|
-
}
|
|
2129
|
-
const objectName = slideImageName(`slides.${slideIndex}`);
|
|
2130
|
-
const configured = image.path === 'design.slideImage' ? presentation.design?.slideImage : presentation.slides[slideIndex].design?.slideImage;
|
|
2131
|
-
const { src: _source, ...treatment } = isPlainObject(configured) && 'position' in configured ? configured : {};
|
|
2132
|
-
const paint = (entry, fallback) => {
|
|
2133
|
-
const hex = normalizeHex(exportColor(entry, context, fallback), fallback);
|
|
2134
|
-
const raw = exportColor(entry, context, fallback).replace(/^#/, '');
|
|
2135
|
-
return { hex, alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
|
|
2136
|
-
};
|
|
2137
|
-
// Native effects are resolved here, in the slide's color context.
|
|
2138
|
-
const effects = {
|
|
2139
|
-
shape: image.shape ?? null,
|
|
2140
|
-
border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
|
|
2141
|
-
opacity: image.opacity ?? null,
|
|
2142
|
-
recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
|
|
2143
|
-
: image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
|
|
2144
|
-
overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
|
|
2145
|
-
};
|
|
2146
|
-
// FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
|
|
2147
|
-
// manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
|
|
2148
|
-
context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
|
|
2149
|
-
// PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
|
|
2150
|
-
// 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture: with either the SVG stays its
|
|
2151
|
-
// PNG raster, so the effect applies as in the preview.
|
|
2152
|
-
if (outcome.svg) {
|
|
2153
|
-
const treated = [effects.recolor?.type === 'duotone' && 'duotone recolor', effects.shape && effects.shape.preset !== 'rect' && 'shape'].filter(Boolean);
|
|
2154
|
-
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.` });
|
|
2155
|
-
else context.svgPictures.set(objectName, outcome.svg);
|
|
2156
|
-
}
|
|
2157
|
-
const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
|
|
2158
|
-
context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
|
|
2159
|
-
slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
|
|
2160
|
-
// The overlay scrim is a separate native shape directly above the picture.
|
|
2161
|
-
if (effects.overlay) {
|
|
2162
|
-
const overlay = effects.overlay.box;
|
|
2163
|
-
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 } });
|
|
2164
|
-
}
|
|
2165
|
-
}
|
|
2166
|
-
|
|
2167
2399
|
// FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
|
|
2168
2400
|
// max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
|
|
2169
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`).
|
|
@@ -2202,7 +2434,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2202
2434
|
|
|
2203
2435
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
2204
2436
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
2205
|
-
const panelFill = context.colorScheme.surface ?? context.colorScheme[
|
|
2437
|
+
const panelFill = context.colorScheme.surface ?? context.colorScheme[context.roles.dark ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
|
|
2206
2438
|
const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
|
|
2207
2439
|
const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
|
|
2208
2440
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
@@ -2211,15 +2443,25 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2211
2443
|
const textSize = chartTextSize(context);
|
|
2212
2444
|
// RR-54: the columns' number formats (Excel codes per exported series) for the caches, labels, value axis and workbook.
|
|
2213
2445
|
// The value axis shows the first plotted series' format (General when it has none), as the preview does.
|
|
2446
|
+
// A combo chart's secondary value axis shows its first series' format (FA-15).
|
|
2447
|
+
const secondaryIndex = chartData.combo?.findIndex((entry) => entry.axis === 'secondary') ?? -1;
|
|
2214
2448
|
const numberFormats = chartData.formats && {...chartData.formats, axis: chartData.spec.grouping === 'percentStacked' ? undefined : chartData.formats.series[0],
|
|
2449
|
+
...(secondaryIndex >= 0 ? {secondaryAxis: chartData.formats.series[secondaryIndex]} : {}),
|
|
2215
2450
|
labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
|
|
2216
2451
|
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
|
|
2217
2452
|
options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
|
|
2218
2453
|
recordPayloadData(context, objectName, 'chart', presentation, path);
|
|
2219
2454
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
2455
|
+
// FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
|
|
2456
|
+
if (typeof chart.alt === 'string') context.chartAlts.set(objectName, chart.alt);
|
|
2220
2457
|
const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
|
|
2221
|
-
const palette =
|
|
2222
|
-
|
|
2458
|
+
const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
|
|
2459
|
+
// FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
|
|
2460
|
+
// contrast with those colours (src/chart-highlight.js).
|
|
2461
|
+
const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor, primary: `#${context.colors.accent}`,
|
|
2462
|
+
schemeFor: hex => schemeColorValue('primary', hex, context)});
|
|
2463
|
+
context.chartPalettes.set(objectName,highlight ? highlight.seriesFills : palette);
|
|
2464
|
+
if (highlight) context.chartHeadings.get(objectName).highlight = highlight;
|
|
2223
2465
|
if (chartData.chartex) {
|
|
2224
2466
|
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
2225
2467
|
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
@@ -2235,7 +2477,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2235
2477
|
// bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
|
|
2236
2478
|
// column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
|
|
2237
2479
|
const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
|
|
2238
|
-
|
|
2480
|
+
const chartOptionsXml = {
|
|
2239
2481
|
objectName,
|
|
2240
2482
|
x: region.x,
|
|
2241
2483
|
y: region.y,
|
|
@@ -2271,7 +2513,37 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2271
2513
|
...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
|
|
2272
2514
|
// ScatterWithMarkers: markers only, no connecting line.
|
|
2273
2515
|
...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
|
|
2274
|
-
}
|
|
2516
|
+
};
|
|
2517
|
+
if (chartData.combo?.length && chartData.combo.length === chartData.series.length) {
|
|
2518
|
+
slide.addChart(...comboChart(chartData, chartOptionsXml, {palette, chartOptions, labelColor, font: context.fonts.body, textSize}));
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
slide.addChart(chartData.type, chartData.series, chartOptionsXml);
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// FA-15: a combo chart is one PptxGenJS multi-type chart: a clustered column c:barChart on the primary axes, a c:lineChart with
|
|
2525
|
+
// markers for the primary-axis lines, and one for the secondary-axis lines on a second c:valAx (at the right, crossing at the
|
|
2526
|
+
// maximum of a deleted second c:catAx). Each series keeps its palette colour by its index in the whole chart (PptxGenJS restarts
|
|
2527
|
+
// the colour cycle per chart type), and the embedded workbook holds every series. The secondary axis has no gridlines and only
|
|
2528
|
+
// its own title; applyComboConstruct puts the line groups into schema order afterwards.
|
|
2529
|
+
function comboChart(chartData, options, {palette, chartOptions, labelColor, font, textSize}) {
|
|
2530
|
+
const series = chartData.series.map((entry, index) => ({...entry, color: palette[index % palette.length]}));
|
|
2531
|
+
const part = (role, axis) => series.filter((_, index) => chartData.combo[index].role === role && (role === 'bar' || chartData.combo[index].axis === axis));
|
|
2532
|
+
const bars = part('bar'), primaryLines = part('line', 'primary'), secondaryLines = part('line', 'secondary');
|
|
2533
|
+
const lineOptions = {lineDataSymbol: 'circle'};
|
|
2534
|
+
const types = [
|
|
2535
|
+
// The column group's own colour list: with one column series, a longer list would make PptxGenJS colour each column (c:dPt, RR-36).
|
|
2536
|
+
...(bars.length ? [{type: 'bar', data: bars, options: {barDir: 'col', barGrouping: 'clustered', chartColors: bars.map((entry) => entry.color)}}] : []),
|
|
2537
|
+
...(primaryLines.length ? [{type: 'line', data: primaryLines, options: lineOptions}] : []),
|
|
2538
|
+
...(secondaryLines.length ? [{type: 'line', data: secondaryLines, options: {...lineOptions, secondaryValAxis: true, secondaryCatAxis: true}}] : []),
|
|
2539
|
+
];
|
|
2540
|
+
// A gap in a line series breaks the line, as the preview draws it (PptxGenJS defaults to span).
|
|
2541
|
+
const {barGrouping: _grouping, ...rest} = options;
|
|
2542
|
+
const shared = {...rest, displayBlanksAs: 'gap'};
|
|
2543
|
+
if (!secondaryLines.length) return [types, {...shared, barDir: 'col'}];
|
|
2544
|
+
const title = chartOptions?.axisTitles?.secondary;
|
|
2545
|
+
const secondaryValue = {valGridLine: {style: 'none'}, showValAxisTitle: Boolean(title), ...(title ? {valAxisTitle: title, valAxisTitleColor: labelColor, valAxisTitleFontFace: font, valAxisTitleFontSize: textSize / 100} : {})};
|
|
2546
|
+
return [types, {...shared, barDir: 'col', valAxes: [{}, secondaryValue], catAxes: [{}, {catAxisHidden: true, showCatAxisTitle: false}]}];
|
|
2275
2547
|
}
|
|
2276
2548
|
|
|
2277
2549
|
// RR-54: a chart or table that uses a dataset, DataColumn objects, a mapping, a data source or number formats records its
|
|
@@ -2330,9 +2602,9 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2330
2602
|
const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context, defaultText), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
|
|
2331
2603
|
const runOptions = {
|
|
2332
2604
|
...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
2333
|
-
underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
2334
|
-
color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
|
|
2335
|
-
hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
|
|
2605
|
+
underline:run.underline ? {style:'sng',color:linkColor(run,color,context)} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
2606
|
+
color:linkColor(run,color,context),transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
|
|
2607
|
+
hyperlink:run.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? {url:run.link} : undefined,
|
|
2336
2608
|
};
|
|
2337
2609
|
// PptxGenJS marks every part of a newline-containing run as a paragraph
|
|
2338
2610
|
// break, including its final part. Split explicitly so the next styled
|
|
@@ -2394,6 +2666,39 @@ function pixelBox(region) {
|
|
|
2394
2666
|
return {x: region.x * 96, y: region.y * 96, width: region.w * 96, height: region.h * 96};
|
|
2395
2667
|
}
|
|
2396
2668
|
|
|
2669
|
+
// One native text box per laid-out rich line (core's richLines), each carrying its runs: run color, size, weight, underline, strike,
|
|
2670
|
+
// super/subscript (a citation marker is a baseline run) and http(s)/mailto links. The body's lines stay untagged; a heading or quote
|
|
2671
|
+
// body passes `objectName` and `tag`, which name and tag every line with the boundary between wrapped lines (a soft wrap inserts
|
|
2672
|
+
// nothing, a hard line break keeps its separator), recovered from the flattened text. `fallback` is the color of runs without one.
|
|
2673
|
+
function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAlignment, fallback, objectName, tag}) {
|
|
2674
|
+
const region = {x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96};
|
|
2675
|
+
const runText = run => typeof run === 'string' ? run : run.text;
|
|
2676
|
+
const whole = typeof value === 'string' ? value : value.map(runText).join('');
|
|
2677
|
+
const count = fit.richLines.length;
|
|
2678
|
+
let cursor = 0;
|
|
2679
|
+
for (const [index, line] of fit.richLines.entries()) {
|
|
2680
|
+
const runs = line.fragments.map(fragment => {
|
|
2681
|
+
const runColor = exportColor(fragment.run.color, context, context.colors.text);
|
|
2682
|
+
const color = nativeColor(fragment.run.color, runColor, context, fallback);
|
|
2683
|
+
return {text: fragment.text, options: {...nativeFontOptions(fragment.style), fontSize: (fragment.nominalSize ?? fragment.fontSize) * .75, color: linkColor(fragment.run, color, context), underline: fragment.run.underline ? {style: 'sng', color: linkColor(fragment.run, color, context)} : undefined, strike: fragment.run.strikethrough ? 'sngStrike' : undefined, baseline: fragment.baselineShift ? -fragment.baselineShift / (fragment.nominalSize ?? fragment.fontSize) * 2000 : undefined, hyperlink: fragment.kind !== 'marker' && fragment.run.link && /^(https?:|mailto:|tel:)/i.test(fragment.run.link) ? {url: fragment.run.link} : undefined, ...langOptions(context, fragment.style.lang)}};
|
|
2684
|
+
});
|
|
2685
|
+
// Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
|
|
2686
|
+
const placed = fit.placement?.lines[index], alignment = placed?.alignment ?? physicalAlignment(logicalAlignment, fit.directions?.[index]), factor = alignment === 'right' ? 1 : alignment === 'center' ? .5 : 0;
|
|
2687
|
+
const area = placed ? {...region, x: (placed.x + line.width * factor - box.width * factor) / 96, y: placed.y / 96, h: placed.height / 96} : {...region, y: region.y + line.y / 96, h: line.height / 96};
|
|
2688
|
+
// PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
|
|
2689
|
+
if (fit.directions?.[index] === 'rtl') for (const run of runs) run.options.rtlMode = true;
|
|
2690
|
+
let name;
|
|
2691
|
+
if (tag) {
|
|
2692
|
+
const text = fit.lines[index] ?? '', end = cursor + text.length;
|
|
2693
|
+
const newline = index < count - 1 && whole.startsWith(text, cursor) ? /^(\r\n|\r|\n)/.exec(whole.slice(end)) : null;
|
|
2694
|
+
name = objectName(index);
|
|
2695
|
+
tag(name, index, count, {boundary: index === count - 1 ? 'end' : newline ? 'hard' : 'soft', separator: newline ? newline[0] : ''});
|
|
2696
|
+
cursor = end + (newline ? newline[0].length : 0);
|
|
2697
|
+
}
|
|
2698
|
+
if (runs.length) slide.addText(runs, {...textBoxOptions(area, context, fit.fontSize * .75), align: alignment, fit: 'none', wrap: false, lineSpacingMultiple: 1, ...(name ? {objectName: name} : {})});
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2397
2702
|
// Match the published renderer's payload geometry and shared core text fitting.
|
|
2398
2703
|
// Each fitted line remains native editable text, without PowerPoint rewrapping it.
|
|
2399
2704
|
function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
@@ -2405,7 +2710,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2405
2710
|
if (fit.overflow && !config.diagnosticsHandled) {
|
|
2406
2711
|
const diagnostic = {code: 'text-overflow', path: config.path, message: 'Text exceeds its cell at the minimum font size; shorten it, increase its space, or split the slide.'};
|
|
2407
2712
|
options.onDiagnostic?.(diagnostic);
|
|
2408
|
-
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path,
|
|
2713
|
+
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, diagnostics: [diagnostic]});
|
|
2409
2714
|
}
|
|
2410
2715
|
// Generated socials carry one link per explicit source line; wrapped lines share it.
|
|
2411
2716
|
let sourceLineIndex = 0;
|
|
@@ -2469,7 +2774,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
2469
2774
|
for(const [index,part]of layout.parts.entries()){
|
|
2470
2775
|
if(part.type==='image'){
|
|
2471
2776
|
const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
|
|
2472
|
-
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);
|
|
2473
2778
|
// A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
|
|
2474
2779
|
if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
|
|
2475
2780
|
else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
|
|
@@ -2511,52 +2816,76 @@ function addCodePayload(slide, value, layout, region, context, path, options) {
|
|
|
2511
2816
|
if (invalid) throw new OPFPptxError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`, {path:part.path});
|
|
2512
2817
|
}
|
|
2513
2818
|
const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
|
|
2514
|
-
for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,
|
|
2515
|
-
|
|
2819
|
+
for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,diagnostics:layout.diagnostics});
|
|
2820
|
+
// FA-13: code.highlight is one native rectangle per run of marked lines, between the panel and the line text boxes; marked
|
|
2821
|
+
// lines keep the syntax colours, the others are dimmed, from the same core helpers the preview calls.
|
|
2822
|
+
const highlight = codeHighlight(value, layout, context);
|
|
2823
|
+
context.codeTags.set(panelName,codeManifest(value,layout,group,highlight?.bands.length));
|
|
2516
2824
|
const syntax = codeSyntax(value, layout, context, path, options);
|
|
2517
2825
|
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
|
|
2826
|
+
if (highlight) {
|
|
2827
|
+
const bodyPart = layout.parts.find(part => part.role === 'body');
|
|
2828
|
+
highlight.bands.forEach((band, bandIndex) => {
|
|
2829
|
+
const lineHeight = bodyPart.fit.lineHeight;
|
|
2830
|
+
const bandName = `OPF code ${group} highlight ${bandIndex + 1}`;
|
|
2831
|
+
context.codeTags.set(bandName, {v:1, group, role:'highlight', index:bandIndex});
|
|
2832
|
+
slide.addShape('rect', {x: region.x + .01, y: (bodyPart.box.y + band.first * lineHeight) / 96, w: region.w - .02, h: (band.last - band.first + 1) * lineHeight / 96,
|
|
2833
|
+
fill: {color: highlight.colors.band.slice(1)}, line: {type: 'none'}, objectName: bandName});
|
|
2834
|
+
});
|
|
2835
|
+
}
|
|
2518
2836
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2519
|
-
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,
|
|
2837
|
+
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,diagnostics:layout.diagnostics});
|
|
2520
2838
|
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
2521
2839
|
const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
|
|
2522
2840
|
const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
|
|
2523
2841
|
context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
|
|
2524
2842
|
// RR-07: the same token ranges and palette the preview paints; the text of the runs is the line text, unchanged.
|
|
2525
|
-
const runs = syntax && part.role==='body' ?
|
|
2843
|
+
const runs = syntax && part.role==='body' ? codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
|
|
2844
|
+
const lineColors = highlight && part.role==='body' ? (highlight.marked.has(highlight.numbers[index]) ? highlight.colors.lit : highlight.colors.dim) : undefined;
|
|
2526
2845
|
const lineText = part.text.slice(line.start,line.end);
|
|
2527
2846
|
const lineOptions = {
|
|
2528
2847
|
...textBoxOptions({x:part.box.x/96,y:(part.box.y+index*part.fit.lineHeight)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
|
|
2529
2848
|
...nativeFontOptions(part.style),
|
|
2530
|
-
color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2849
|
+
color:part.role==='body'?(lineColors?.plain.slice(1)??'E5E7EB'):'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2531
2850
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2532
2851
|
};
|
|
2533
2852
|
// A run's own options replace the line's, so every run repeats the font, size, alignment and tab stops and differs only in colour.
|
|
2534
|
-
slide.addText(runs?.some(run=>run.kind) ? runs.map(run=>({text:part.text.slice(run.start,run.end),options:{...lineOptions,color:syntax.palette[run.kind??'plain'].slice(1)}})) : lineText,lineOptions);
|
|
2853
|
+
slide.addText(runs?.some(run=>run.kind) ? runs.map(run=>({text:part.text.slice(run.start,run.end),options:{...lineOptions,color:(lineColors??syntax.palette)[run.kind??'plain'].slice(1)}})) : lineText,lineOptions);
|
|
2535
2854
|
}
|
|
2536
2855
|
}
|
|
2537
2856
|
}
|
|
2538
2857
|
|
|
2858
|
+
// FA-13: the marked lines of code.highlight, their bands (runs of displayed lines) and the lit/dimmed colours, from core (the preview
|
|
2859
|
+
// calls the same functions); undefined when the code marks no line.
|
|
2860
|
+
function codeHighlight(value, layout, context) {
|
|
2861
|
+
if (!value || typeof value !== 'object' || !Array.isArray(value.highlight)) return undefined;
|
|
2862
|
+
const body = layout.parts.find(part => part.role === 'body');
|
|
2863
|
+
if (!body?.fit) return undefined;
|
|
2864
|
+
const lines = codeHighlightLines(value.highlight, body.text).lines;
|
|
2865
|
+
if (!lines.length) return undefined;
|
|
2866
|
+
return {marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(context.colorScheme)};
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2539
2869
|
// Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
|
|
2540
2870
|
function codeSyntax(value, layout, context, path, options) {
|
|
2541
2871
|
const body = layout.parts.find(part => part.role === 'body');
|
|
2542
2872
|
const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
|
|
2543
2873
|
if (!body || !language) return undefined;
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
return tokens.length ? {tokens, palette: opfCore.codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
|
|
2874
|
+
const tokens = tokenizeCode(body.text, language);
|
|
2875
|
+
return tokens.length ? {tokens, palette: codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
|
|
2547
2876
|
}
|
|
2548
2877
|
|
|
2549
2878
|
function addMetricPayload(slide,value,layout,context,path,options) {
|
|
2550
2879
|
if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
|
|
2551
2880
|
const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
|
|
2552
2881
|
// RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
|
|
2553
|
-
const trendMark=
|
|
2882
|
+
const trendMark=metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`});
|
|
2554
2883
|
const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
|
|
2555
2884
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2556
2885
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
2557
2886
|
if (invalid) throw new OPFPptxError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`,{path:part.path});
|
|
2558
2887
|
if (!part.visible) continue;
|
|
2559
|
-
if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFPptxError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,
|
|
2888
|
+
if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFPptxError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,diagnostics:layout.diagnostics});
|
|
2560
2889
|
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
2561
2890
|
const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
|
|
2562
2891
|
context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
|
|
@@ -2582,37 +2911,80 @@ function addMetricPayload(slide,value,layout,context,path,options) {
|
|
|
2582
2911
|
}
|
|
2583
2912
|
}
|
|
2584
2913
|
|
|
2585
|
-
function addQuotePayload(slide, value, layout, context, options, path) {
|
|
2914
|
+
async function addQuotePayload(slide, presentation, value, layout, context, options, path) {
|
|
2586
2915
|
if (!layout) throw new OPFPptxError('missing-quote-layout', 'Quote export requires a coordinated core build with shared quote geometry.', {path});
|
|
2587
|
-
for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before exporting.', {path:part.path,
|
|
2916
|
+
for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before exporting.', {path:part.path,diagnostics:layout.diagnostics});
|
|
2588
2917
|
// Every line shape is tagged (OPF_QUOTE_V1) so an unchanged export re-imports as a quote payload (FF-57).
|
|
2589
2918
|
const group=String(context.quoteTags.size),anchor=quoteManifest(value,layout);
|
|
2590
2919
|
for (const [index,part] of layout.parts.entries()) {
|
|
2920
|
+
// FA-10: a TextRun[] quote body (core's rich lines, quotation marks joined to its first and last run) is exported line by line like rich body text, with the same quote tags.
|
|
2921
|
+
if (part.runs && part.fit.richLines) {
|
|
2922
|
+
addRichFitLines(slide, part.fit, part.box, context, {
|
|
2923
|
+
value: part.text, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
|
|
2924
|
+
objectName: line => `OPF quote ${group} part ${index} line ${line}`,
|
|
2925
|
+
tag: (name, line, count, boundary) => context.quoteTags.set(name, {v: 1, role: 'text', group, part: index, line, count, ...boundary, base: richBase(part.fit, part.style, context.colors.text), ...(index === 0 && line === 0 ? {anchor} : {})})
|
|
2926
|
+
});
|
|
2927
|
+
continue;
|
|
2928
|
+
}
|
|
2591
2929
|
addMeasuredPayloadText(slide,part.text,part.box,context,options,{
|
|
2592
2930
|
path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
|
|
2593
2931
|
color:part.role==='footer'?context.mutedColor:context.textColor,
|
|
2594
2932
|
quote:{group,part:index,anchor},
|
|
2595
2933
|
});
|
|
2596
2934
|
}
|
|
2935
|
+
if (layout.photo) await addQuotePhoto(slide, presentation, layout.photo, group, context, options);
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
// FA-12: the attributed person's headshot is a native picture in the core circle frame: cropped to fill it (a:srcRect after the bytes
|
|
2939
|
+
// are embedded) with the `ellipse` preset geometry, the alt text as its description, tagged into the quote's provenance group.
|
|
2940
|
+
async function addQuotePhoto(slide, presentation, photo, group, context, options) {
|
|
2941
|
+
const box = { x: photo.box.x / 96, y: photo.box.y / 96, w: photo.box.width / 96, h: photo.box.height / 96 };
|
|
2942
|
+
const outcome = { box, cover: true };
|
|
2943
|
+
const resolved = await resolveImage(photo.value, presentation, options, photo.path, outcome);
|
|
2944
|
+
if (!resolved) {
|
|
2945
|
+
addImagePlaceholder(slide, presentation, photo.value, box, photo.path, context, options);
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const objectName = quotePhotoName(group);
|
|
2949
|
+
// PowerPoint is not confirmed to apply a non-rectangular mask to an SVG picture, so an SVG photo exports as its PNG raster (as a masked slide image does).
|
|
2950
|
+
if (outcome.svg) options.onDiagnostic?.({ code: 'svg-image-rasterized', path: photo.path, message: 'An SVG quote photo exports as its PNG raster so the circular mask applies as in the preview; PowerPoint has not been confirmed to apply it to an SVG picture.' });
|
|
2951
|
+
context.quotePhotos.set(objectName, { region: box, mode: 'crop', path: photo.path, shape: photo.shape });
|
|
2952
|
+
context.quoteTags.set(objectName, { v: 1, role: 'photo', group });
|
|
2953
|
+
const alt = assetAlt(photo.value, presentation);
|
|
2954
|
+
context.pictureText.set(objectName, pictureText(alt, photo.value, presentation));
|
|
2955
|
+
slide.addImage({ ...resolved, objectName, ...box, altText: alt });
|
|
2597
2956
|
}
|
|
2598
2957
|
|
|
2599
2958
|
function addTimelinePayload(slide, value, layout, context, options, path) {
|
|
2600
2959
|
if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
|
|
2601
2960
|
// Validate optional field fits before the provenance manifest dereferences
|
|
2602
2961
|
// source lines or any drawing/tag mutation is performed.
|
|
2603
|
-
for(const part of layout.parts)if(!part.fit)throw new OPFPptxError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,
|
|
2962
|
+
for(const part of layout.parts)if(!part.fit)throw new OPFPptxError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,diagnostics:layout.diagnostics});
|
|
2604
2963
|
const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
|
|
2605
2964
|
const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
|
|
2606
2965
|
const {x1,y1,x2,y2}=layout.connector;
|
|
2607
2966
|
slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,width:3*scale*.75}});
|
|
2608
2967
|
context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
|
|
2968
|
+
// FA-11: a status draws from the deck's colors through core's shared shapes: native ellipses (solid fill, or the
|
|
2969
|
+
// background fill plus a line for a hollow marker or a ring) and a muted text color for a planned event.
|
|
2970
|
+
const statusColors={background:`#${normalizeHex(context.colors.background)}`,primary:`#${context.colors.accent}`,text:`#${normalizeHex(context.colors.text)}`,mutedText:`#${context.colors.mutedText}`};
|
|
2609
2971
|
for(const marker of layout.markers){
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2972
|
+
if(!marker.status){
|
|
2973
|
+
const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
|
|
2974
|
+
slide.addShape('ellipse',{objectName,x:(marker.x-marker.radius)/96,y:(marker.y-marker.radius)/96,w:marker.radius*2/96,h:marker.radius*2/96,fill:{color:context.colors.accent},line:{transparency:100}});
|
|
2975
|
+
context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
|
|
2976
|
+
continue;
|
|
2977
|
+
}
|
|
2978
|
+
for(const shape of timelineMarkerShapes(marker,statusColors)){
|
|
2979
|
+
const objectName=`OPF timeline ${group} ${shape.role} ${marker.eventIndex}`;
|
|
2980
|
+
slide.addShape(shape.shape,{objectName,x:(shape.cx-shape.radius)/96,y:(shape.cy-shape.radius)/96,w:shape.radius*2/96,h:shape.radius*2/96,
|
|
2981
|
+
fill:{color:normalizeHex(shape.fill)},line:shape.stroke?{color:normalizeHex(shape.stroke.color),width:shape.stroke.width*.75}:{transparency:100}});
|
|
2982
|
+
context.timelineTags.set(objectName,{v:1,group,role:shape.role,eventIndex:marker.eventIndex,...(shape.role==='marker'?{status:marker.status}:{})});
|
|
2983
|
+
}
|
|
2613
2984
|
}
|
|
2614
2985
|
for(const [index,part]of layout.parts.entries()){
|
|
2615
|
-
|
|
2986
|
+
const statusColor=part.status==='planned'?normalizeHex(timelineTextColor(part,statusColors)):undefined;
|
|
2987
|
+
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,timeline:{group,part:index,anchor},...(statusColor?{color:statusColor}:{})});
|
|
2616
2988
|
}
|
|
2617
2989
|
}
|
|
2618
2990
|
|
|
@@ -2777,6 +3149,12 @@ function chosenFamilyMeasurement(measurement) {
|
|
|
2777
3149
|
return wrapped;
|
|
2778
3150
|
}
|
|
2779
3151
|
|
|
3152
|
+
// FA-13: TextRun.lang as the run's own proofing language (a:rPr lang), when it differs from the deck language.
|
|
3153
|
+
function langOptions(context, tag) {
|
|
3154
|
+
const lang = runLanguageTag(context.scriptFonts, tag);
|
|
3155
|
+
return lang ? {lang} : {};
|
|
3156
|
+
}
|
|
3157
|
+
|
|
2780
3158
|
function nativeFontOptions(style) {
|
|
2781
3159
|
const face=style.fontFace;
|
|
2782
3160
|
if(face!==undefined) {
|
|
@@ -2826,14 +3204,15 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2826
3204
|
options: {
|
|
2827
3205
|
bold: run?.bold,
|
|
2828
3206
|
italic: run?.italic,
|
|
2829
|
-
underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
|
|
3207
|
+
underline: run?.underline ? { style: "sng", color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context) } : undefined,
|
|
2830
3208
|
strike: run?.strikethrough ? "sngStrike" : undefined,
|
|
2831
|
-
color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
|
|
3209
|
+
color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context),
|
|
2832
3210
|
fontFace: run?.fontFamily ?? context.fonts.body,
|
|
2833
3211
|
fontSize: run?.fontSize ?? fallbackFontSize,
|
|
2834
3212
|
superscript: run?.superscript,
|
|
2835
3213
|
subscript: !run?.superscript && run?.subscript,
|
|
2836
|
-
hyperlink: run?.link && /^(https?:|mailto:)/i.test(run.link) ? { url: run.link } : undefined
|
|
3214
|
+
hyperlink: run?.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? { url: run.link } : undefined,
|
|
3215
|
+
...langOptions(context, run?.lang)
|
|
2837
3216
|
}
|
|
2838
3217
|
};
|
|
2839
3218
|
});
|
|
@@ -2860,7 +3239,7 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
2860
3239
|
return unplottable("dataset-unknown", "The chart's dataset is missing, so it cannot be drawn here.", `The chart references dataset '${stringifyText(authored.dataset)}', which the document does not hold (or a field it names).`);
|
|
2861
3240
|
}
|
|
2862
3241
|
if (!authored || !Array.isArray(authored.columns) || !Array.isArray(authored.rows)) {
|
|
2863
|
-
return unplottable("
|
|
3242
|
+
return unplottable("no-columns", "The chart has no inline data, so it cannot be drawn here.", "The chart data is not inline columns and rows. Supply inline columns and rows or a dataset.");
|
|
2864
3243
|
}
|
|
2865
3244
|
if (authored.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2866
3245
|
if (authored.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
@@ -2887,14 +3266,16 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
2887
3266
|
: 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
|
|
2888
3267
|
adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart ${reason}; its data is exported as a native clustered column chart instead.`});
|
|
2889
3268
|
}
|
|
2890
|
-
|
|
3269
|
+
// FA-14: the resolved table (category, X, series columns), which chart.highlight names its series and categories in.
|
|
3270
|
+
const mapped = {resolved: {columns: data.columns, hasX: resolvedData.hasX === true, rows: data.rows}, type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, diagnostics, heading: data.columns[0], pointCount: data.rows.length,
|
|
3271
|
+
// FA-15: core's combo plan (column series, then the primary-axis lines, then the secondary-axis lines), aligned with the series.
|
|
3272
|
+
...(spec.family === 'combo' ? {combo: comboPlanOf(resolvedData, data.columns.length - 1)} : {})};
|
|
2891
3273
|
// Number formats only when a column has one: a chart without formats carries no `formats` and is written as before.
|
|
2892
3274
|
const withFormats = (result, formats) => formats.series.some((code) => code !== undefined) || formats.x !== undefined ? {...result, formats} : result;
|
|
2893
3275
|
if (data.columns.length === 1) {
|
|
2894
3276
|
const heading = data.columns[0];
|
|
2895
|
-
// The lone column holds the chart's values.
|
|
2896
|
-
//
|
|
2897
|
-
// cell that holds no number is skipped, never plotted as 0.
|
|
3277
|
+
// The lone column holds the chart's values. Each cell is read with chartNumber and each non-numeric cell is counted
|
|
3278
|
+
// as rejected. A cell that holds no number is skipped, never plotted as 0.
|
|
2898
3279
|
const points = data.rows.map((row, index) => ({row: index + 1, value: chartNumber(row?.[0]), cell: row?.[0]}));
|
|
2899
3280
|
for (const point of points) if (point.value === null && point.cell !== null && point.cell !== undefined && point.cell !== '') rejected.push({path: `/data/rows/${point.row - 1}/0`, cell: point.cell});
|
|
2900
3281
|
const plottedPoints = points.filter((point) => point.value !== null);
|
|
@@ -2964,6 +3345,12 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
2964
3345
|
return withFormats({...mapped, series, hasCategories: true, adaptations, notNumeric}, formats);
|
|
2965
3346
|
}
|
|
2966
3347
|
|
|
3348
|
+
/** Core's combo plan; a core without one draws every series as columns except the last, a line on the primary axis (the preview's rule). */
|
|
3349
|
+
function comboPlanOf(resolved, count) {
|
|
3350
|
+
if (Array.isArray(resolved.combo) && resolved.combo.length === count) return resolved.combo;
|
|
3351
|
+
return Array.from({length: count}, (_, index) => ({role: count > 1 && index === count - 1 ? 'line' : 'bar', axis: 'primary'}));
|
|
3352
|
+
}
|
|
3353
|
+
|
|
2967
3354
|
/** A chart column's name: the string, or a DataColumn's `name`. */
|
|
2968
3355
|
function dataColumnName(column) {
|
|
2969
3356
|
return typeof column === 'string' ? column : column !== null && typeof column === 'object' && typeof column.name === 'string' ? column.name : undefined;
|
|
@@ -3193,71 +3580,12 @@ function assetAlt(asset, presentation) {
|
|
|
3193
3580
|
return undefined;
|
|
3194
3581
|
}
|
|
3195
3582
|
|
|
3196
|
-
function resolveCatalogRecord(presentation, kind, reference, fallbackId) {
|
|
3197
|
-
const id = referenceId(reference) ?? fallbackId;
|
|
3198
|
-
const inlineRecords = normalizeRecords(presentation.catalogs?.[kind]);
|
|
3199
|
-
return findById(inlineRecords, id)
|
|
3200
|
-
?? findById(defaultCatalog(kind), id)
|
|
3201
|
-
?? findById(defaultCatalog(kind), fallbackId)
|
|
3202
|
-
?? null;
|
|
3203
|
-
}
|
|
3204
|
-
|
|
3205
|
-
function resolveDesignRecord(presentation, kind, reference, fallbackId) {
|
|
3206
|
-
const base = resolveCatalogRecord(presentation, kind, reference, fallbackId) ?? {};
|
|
3207
|
-
return {
|
|
3208
|
-
...base,
|
|
3209
|
-
...(isPlainObject(reference) ? withoutSchema(reference) : {})
|
|
3210
|
-
};
|
|
3211
|
-
}
|
|
3212
|
-
|
|
3213
3583
|
function referenceId(reference) {
|
|
3214
3584
|
if (typeof reference === "string") return reference;
|
|
3215
3585
|
if (isPlainObject(reference) && typeof reference.id === "string") return reference.id;
|
|
3216
3586
|
return null;
|
|
3217
3587
|
}
|
|
3218
3588
|
|
|
3219
|
-
const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
3220
|
-
|
|
3221
|
-
// Same order as opf-render's socialPlatformRecords so preview and export format
|
|
3222
|
-
// socials identically. Core applies inline document records first; then the
|
|
3223
|
-
// document catalog source (host-supplied options.catalogSources, or the bundled
|
|
3224
|
-
// catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
|
|
3225
|
-
// bundled catalog (the engine default source also resolves to it).
|
|
3226
|
-
function socialPlatformRecords(presentation, options) {
|
|
3227
|
-
const kind = "socialPlatforms", declared = presentation.catalogs?.[kind]?.source;
|
|
3228
|
-
// `source` is one source or an ordered search path (an array): records of each source in order, first match wins.
|
|
3229
|
-
const sourceRecords = (Array.isArray(declared) ? declared : [declared]).filter(source => typeof source === "string").flatMap(source => {
|
|
3230
|
-
const bySource = options.catalogSources?.[source];
|
|
3231
|
-
return bySource ? normalizeRecords(bySource)
|
|
3232
|
-
: source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/") ? defaultCatalog(kind) : [];
|
|
3233
|
-
});
|
|
3234
|
-
return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
|
|
3235
|
-
}
|
|
3236
|
-
|
|
3237
|
-
function defaultCatalog(kind) {
|
|
3238
|
-
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
3239
|
-
}
|
|
3240
|
-
|
|
3241
|
-
function normalizeRecords(catalog) {
|
|
3242
|
-
if (!catalog) return [];
|
|
3243
|
-
if (Array.isArray(catalog)) return catalog;
|
|
3244
|
-
if (Array.isArray(catalog.records)) return catalog.records;
|
|
3245
|
-
return [];
|
|
3246
|
-
}
|
|
3247
|
-
|
|
3248
|
-
function findById(records, id) {
|
|
3249
|
-
return records.find((record) => record?.id === id) ?? null;
|
|
3250
|
-
}
|
|
3251
|
-
|
|
3252
|
-
function withoutSchema(value) {
|
|
3253
|
-
return Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$schema"));
|
|
3254
|
-
}
|
|
3255
|
-
|
|
3256
|
-
function resolveDimensions(value) {
|
|
3257
|
-
const { width, height } = resolveCanvasDimensions(value);
|
|
3258
|
-
return { widthInches: width / 96, heightInches: height / 96 };
|
|
3259
|
-
}
|
|
3260
|
-
|
|
3261
3589
|
// A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
|
|
3262
3590
|
// as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
|
|
3263
3591
|
function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
|
|
@@ -3269,29 +3597,18 @@ function resolveBackground(value, colorScheme, variables = {}) {
|
|
|
3269
3597
|
const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
|
|
3270
3598
|
if (typeof value === "string") {
|
|
3271
3599
|
if (value.startsWith("#")) return normalizeHex(value, fallback);
|
|
3272
|
-
return normalizeHex(colorScheme[value] ?? colorScheme
|
|
3600
|
+
return normalizeHex(colorScheme[value] ?? defaultSlideBackground(colorScheme), fallback);
|
|
3273
3601
|
}
|
|
3274
3602
|
if (isPlainObject(value)) {
|
|
3275
3603
|
if (value.type === "solid" && value.color) return reference(value.color);
|
|
3276
3604
|
if (value.type === "theme" && value.slot) {
|
|
3277
|
-
return normalizeHex(colorScheme[value.slot] ?? colorScheme
|
|
3605
|
+
return normalizeHex(colorScheme[value.slot] ?? defaultSlideBackground(colorScheme), fallback);
|
|
3278
3606
|
}
|
|
3279
3607
|
// Like the SVG preview, text contrast follows a pattern's background color.
|
|
3280
3608
|
if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
|
|
3281
3609
|
if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
|
|
3282
3610
|
}
|
|
3283
|
-
return normalizeHex(colorScheme
|
|
3284
|
-
}
|
|
3285
|
-
|
|
3286
|
-
function resolveFonts(fontScheme) {
|
|
3287
|
-
return {id:fontScheme.id,...resolveFontFamilies(fontScheme),scheme:{type:fontScheme.type,major:fontScheme.major,minor:fontScheme.minor}};
|
|
3288
|
-
}
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
function readableTextColor(background, colorScheme) {
|
|
3292
|
-
return isDarkHex(background)
|
|
3293
|
-
? normalizeHex(colorScheme.light1 ?? "#FFFFFF")
|
|
3294
|
-
: normalizeHex(colorScheme.text ?? colorScheme.dark1 ?? "#0F172A");
|
|
3611
|
+
return normalizeHex(defaultSlideBackground(colorScheme), fallback);
|
|
3295
3612
|
}
|
|
3296
3613
|
|
|
3297
3614
|
function normalizeHex(value, fallback = "000000") {
|
|
@@ -3306,15 +3623,6 @@ function normalizeHex(value, fallback = "000000") {
|
|
|
3306
3623
|
return fallback.replace(/^#/, "").toUpperCase().slice(0, 6);
|
|
3307
3624
|
}
|
|
3308
3625
|
|
|
3309
|
-
function isDarkHex(value) {
|
|
3310
|
-
const hex = normalizeHex(value);
|
|
3311
|
-
if (!/^[0-9A-F]{6}$/.test(hex)) return false;
|
|
3312
|
-
const red = Number.parseInt(hex.slice(0, 2), 16);
|
|
3313
|
-
const green = Number.parseInt(hex.slice(2, 4), 16);
|
|
3314
|
-
const blue = Number.parseInt(hex.slice(4, 6), 16);
|
|
3315
|
-
return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
|
|
3316
|
-
}
|
|
3317
|
-
|
|
3318
3626
|
// The master, layout and notes master half of the native header/footer (RR-11): placeholders and p:hf on the finished parts.
|
|
3319
3627
|
// Every deck gets them (flags off when no slide uses a type), so Insert > Header & Footer works on a deck with no footer too.
|
|
3320
3628
|
function writeNativeFurnitureMasters(output, context) {
|
|
@@ -3373,13 +3681,18 @@ async function normalizePptxZip(raw, context) {
|
|
|
3373
3681
|
const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
|
|
3374
3682
|
if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
|
|
3375
3683
|
entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
|
|
3684
|
+
const highlight=context.chartHeadings.get(name).highlight;
|
|
3685
|
+
if(highlight){
|
|
3686
|
+
try{entries[chartPart]=encodeText(applyChartHighlight(decodeText(entries[chartPart]),highlight));}
|
|
3687
|
+
catch(error){throw new OPFPptxError('packaging-failed',`Chart highlight could not be written: ${errorMessage(error)}`);}
|
|
3688
|
+
}
|
|
3376
3689
|
// PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
|
|
3377
3690
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
3378
3691
|
entries[chartPart]=encodeText(decodeText(entries[chartPart]).replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g,properties=>properties.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g,()=>`<a:solidFill><a:srgbClr val="${labelColor}"/></a:solidFill>`)));
|
|
3379
3692
|
entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
|
|
3380
3693
|
// RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
|
|
3381
3694
|
const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
|
|
3382
|
-
if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount}));
|
|
3695
|
+
if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount,highlight}));
|
|
3383
3696
|
// RR-54: the columns' number formats, after the labels: caches, label and value-axis formats, then the workbook cells.
|
|
3384
3697
|
const numberFormats=context.chartHeadings.get(name).numberFormats;
|
|
3385
3698
|
if(numberFormats){
|
|
@@ -3390,6 +3703,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
3390
3703
|
}
|
|
3391
3704
|
}
|
|
3392
3705
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
3706
|
+
if (context.chartAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyChartAlt(xml, context.chartAlts); if (next !== xml) entries[part] = encodeText(next); }
|
|
3393
3707
|
// Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
|
|
3394
3708
|
try {
|
|
3395
3709
|
attachChartexParts(entries, context.chartex, parseRelationships);
|
|
@@ -3408,7 +3722,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
3408
3722
|
const relationships = parseRelationships(entries, part);
|
|
3409
3723
|
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
3410
3724
|
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
3411
|
-
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);
|
|
3412
3726
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
3413
3727
|
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
3414
3728
|
}
|
|
@@ -3437,11 +3751,18 @@ async function normalizePptxZip(raw, context) {
|
|
|
3437
3751
|
}
|
|
3438
3752
|
imageMetadata.set(part, metadata);
|
|
3439
3753
|
}
|
|
3440
|
-
//
|
|
3754
|
+
// Image pictures need the embedded dimensions (core's fitImage crops and pads them); document provenance (FF-32)
|
|
3441
3755
|
// later records the placed frames as slide geometry evidence.
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
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
|
+
}
|
|
3765
|
+
tagTextWatermarks(entries, context.textWatermarks);
|
|
3445
3766
|
placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
3446
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 });
|
|
3447
3768
|
});
|
|
@@ -3599,7 +3920,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3599
3920
|
const metadata = imageMetadata.get(backgroundRelationships.get(id)?.path);
|
|
3600
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 });
|
|
3601
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.'});
|
|
3602
|
-
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>`;
|
|
3603
3924
|
});
|
|
3604
3925
|
}
|
|
3605
3926
|
// PptxGenJS table IDs can collide with other objects on the same slide.
|
|
@@ -3685,7 +4006,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3685
4006
|
}
|
|
3686
4007
|
const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
|
|
3687
4008
|
if (text) picture = writePictureText(picture, text);
|
|
3688
|
-
|
|
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]);
|
|
3689
4011
|
if (!placement) return picture;
|
|
3690
4012
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
3691
4013
|
const dimensions = imageMetadata.get(relationships.get(id)?.path);
|
|
@@ -3698,6 +4020,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3698
4020
|
const attrs = Object.entries(fitted.crop).map(([key, value]) => `${key}="${value}"`).join(' ');
|
|
3699
4021
|
picture = picture.replace('<a:stretch>', `<a:srcRect ${attrs}/><a:stretch>`);
|
|
3700
4022
|
}
|
|
4023
|
+
// The circular headshot mask: the core frame's DrawingML preset (ellipse) instead of the rectangle.
|
|
4024
|
+
if (placement.shape) picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, `<a:prstGeom prst="${placement.shape.preset}"><a:avLst/></a:prstGeom>`);
|
|
3701
4025
|
return picture;
|
|
3702
4026
|
});
|
|
3703
4027
|
// Native bullets otherwise inherit the first rich run's size, font and
|
|
@@ -3733,6 +4057,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3733
4057
|
if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
|
|
3734
4058
|
else if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) xml = themeEastAsianFromLatin(xml);
|
|
3735
4059
|
if (/^ppt\/(?:slides\/slide|notesSlides\/notesSlide)\d+\.xml$/.test(path)) xml = stripRunScriptFonts(xml);
|
|
4060
|
+
if (context.scriptFonts && /^ppt\/slides\/slide\d+\.xml$/.test(path)) xml = runLanguageFonts(xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
|
|
3736
4061
|
return encodeText(normalizePartReferences(xml, renameMaps));
|
|
3737
4062
|
}
|
|
3738
4063
|
return bytes;
|