@openpresentation/opf-pptx 0.13.1 → 0.14.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/DEPENDENCY-NOTES.md +1 -1
- package/README.md +34 -16
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background.js +2 -3
- 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 +59 -58
- 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 +20 -8
- 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/index.d.ts +32 -25
- package/dist/index.js +481 -235
- 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 +81 -54
- package/dist/theme-colors.js +1 -1
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +7 -7
- package/dist/watermark-provenance.js +65 -3
- package/package.json +5 -5
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';
|
|
@@ -33,33 +35,36 @@ import {importImageOrientation} from './image-import.js';
|
|
|
33
35
|
import {extractSignals, normalizeSignalOptions, themeFactsFor} from './import-signals.js';
|
|
34
36
|
import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-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
|
+
chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
|
|
50
|
+
DEFAULT_FONT_SCHEME, defaultSlideBackground, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
|
|
51
|
+
resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
|
|
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
58
|
catalogs as bundledCatalogs,
|
|
54
|
-
|
|
59
|
+
hasContentVariables,
|
|
60
|
+
isTemplate,
|
|
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",
|
|
@@ -225,7 +227,9 @@ export async function toPptx(input, options = {}) {
|
|
|
225
227
|
}
|
|
226
228
|
const presentation = resolveTemplateInput(parseInput(input), options);
|
|
227
229
|
assertValidBoundary(presentation);
|
|
228
|
-
options
|
|
230
|
+
// `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
|
|
231
|
+
// chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
|
|
232
|
+
options = {...options, textMeasurement: chosenFamilyMeasurement(options.fonts?.textMeasurement), svgRasters: new Map()};
|
|
229
233
|
|
|
230
234
|
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
231
235
|
context.listMarkers = new Map();
|
|
@@ -237,6 +241,7 @@ export async function toPptx(input, options = {}) {
|
|
|
237
241
|
context.pictureText = new Map();
|
|
238
242
|
context.slideImages = new Map();
|
|
239
243
|
context.watermarks = new Map();
|
|
244
|
+
context.textWatermarks = new Map();
|
|
240
245
|
context.logos = new Map();
|
|
241
246
|
context.logoPlaceholderTags = new Map();
|
|
242
247
|
context.bulletImages = new Map();
|
|
@@ -249,6 +254,7 @@ export async function toPptx(input, options = {}) {
|
|
|
249
254
|
context.plainTextTags = new Map();
|
|
250
255
|
context.timelineTags = new Map();
|
|
251
256
|
context.quoteTags = new Map();
|
|
257
|
+
context.quotePhotos = new Map();
|
|
252
258
|
context.furnitureTags = new Map();
|
|
253
259
|
context.furnitureLogoTags = new Map();
|
|
254
260
|
context.furnitureFields = new Map();
|
|
@@ -261,6 +267,7 @@ export async function toPptx(input, options = {}) {
|
|
|
261
267
|
context.metricTags = new Map();
|
|
262
268
|
context.metricDescriptions = new Map();
|
|
263
269
|
context.chartHeadings = new Map();
|
|
270
|
+
context.chartAlts = new Map();
|
|
264
271
|
context.chartFonts = new Map();
|
|
265
272
|
context.chartPalettes = new Map();
|
|
266
273
|
context.chartex = new Map();
|
|
@@ -278,7 +285,7 @@ export async function toPptx(input, options = {}) {
|
|
|
278
285
|
}
|
|
279
286
|
context.chartexMode = options.chartex ?? 'auto';
|
|
280
287
|
Object.assign(context, exportTheme(presentation, context));
|
|
281
|
-
context.
|
|
288
|
+
context.reportedReferences = new Set();
|
|
282
289
|
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
283
290
|
context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
|
|
284
291
|
mode: options.provenance ?? "full",
|
|
@@ -297,14 +304,14 @@ export async function toPptx(input, options = {}) {
|
|
|
297
304
|
context.masterBackground = masterBackground(context);
|
|
298
305
|
context.linkSentinels = linkSentinels(presentation);
|
|
299
306
|
const pptx = new PptxGenJS();
|
|
300
|
-
configurePresentation(pptx, presentation, {...context,fonts:
|
|
307
|
+
configurePresentation(pptx, presentation, {...context,fonts:exportSlideContext(presentation,0,context,options).fonts});
|
|
301
308
|
|
|
302
309
|
for (let index = 0; index < presentation.slides.length; index += 1) {
|
|
303
310
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
304
311
|
}
|
|
305
312
|
|
|
306
313
|
// FF-08: pitchFamily per exported family, from each slide's resolved scheme.
|
|
307
|
-
context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) =>
|
|
314
|
+
context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => exportSlideContext(presentation, index, context, options).fonts),
|
|
308
315
|
[...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
|
|
309
316
|
|
|
310
317
|
let raw;
|
|
@@ -384,6 +391,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
384
391
|
const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
|
|
385
392
|
if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
|
|
386
393
|
if (furniture.organization) imported.organization = furniture.organization;
|
|
394
|
+
if (furniture.speaker) imported.speaker = furniture.speaker;
|
|
387
395
|
|
|
388
396
|
// RR-54: the datasets recorded at export (OPF_DATASETS_V1). Chart and table frames restore their dataset references against
|
|
389
397
|
// them, so the document carries them before document provenance validates the restored document.
|
|
@@ -391,7 +399,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
391
399
|
const mediaRegistry = Object.create(null);
|
|
392
400
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
393
401
|
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]));
|
|
402
|
+
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), deckLang: observedLanguage.lang, codeFamily: Object.keys(entries).some(path => /^ppt\/tags\/opf/i.test(path)) ? importedCodeFamily(imported.design) : undefined, dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
|
|
395
403
|
}
|
|
396
404
|
if (datasets) imported.datasets = datasets;
|
|
397
405
|
// Native sections (PowerPoint's own section list, `Default Section` = none)
|
|
@@ -421,14 +429,14 @@ export async function fromPptx(input, options = {}) {
|
|
|
421
429
|
let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
|
|
422
430
|
let restoredGroups = [];
|
|
423
431
|
try {
|
|
424
|
-
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
|
|
432
|
+
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true, speakerConflict: furniture.speakerConflict === true,
|
|
425
433
|
// Host catalogs format stored socials exactly as export did (FF-34).
|
|
426
434
|
socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
|
|
427
435
|
nativeSections: slideSections,
|
|
428
436
|
slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
|
|
429
437
|
// The stored language wins while the runs still carry its tag (FF-07).
|
|
430
438
|
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
431
|
-
imported = applyDocumentProvenance(imported, restored,
|
|
439
|
+
imported = applyDocumentProvenance(imported, restored, checkFormat, report);
|
|
432
440
|
slideProvenance = restored.slides;
|
|
433
441
|
restoredGroups = restored.groups;
|
|
434
442
|
} catch (error) {
|
|
@@ -443,11 +451,11 @@ export async function fromPptx(input, options = {}) {
|
|
|
443
451
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
444
452
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
445
453
|
|
|
446
|
-
const
|
|
447
|
-
if (!
|
|
454
|
+
const validation = checkFormat(imported);
|
|
455
|
+
if (!validation.valid) {
|
|
448
456
|
throw new OPFPptxError("invalid-import-opf", "Imported PPTX did not produce valid OPF.", {
|
|
449
|
-
|
|
450
|
-
|
|
457
|
+
findings: validation.findings.filter(finding => finding.severity === "error"),
|
|
458
|
+
report: validation
|
|
451
459
|
});
|
|
452
460
|
}
|
|
453
461
|
|
|
@@ -458,7 +466,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
458
466
|
const archive = {part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]};
|
|
459
467
|
const signals = extractSignals({archive, presentationRoot, slidePaths, themeFacts: themeFactsFor(slidePaths[0], archive), limits: signalLimits,
|
|
460
468
|
recorded: furnitureContexts.map(context => context.signalSources), slideProvenance, contentPaths});
|
|
461
|
-
return {
|
|
469
|
+
return {presentation: imported, signals};
|
|
462
470
|
}
|
|
463
471
|
|
|
464
472
|
// RR-08: run colours named by a ColorRef (variable, scheme slot, role) come back as that name where the document's own
|
|
@@ -623,6 +631,16 @@ function readCoreProperties(entries) {
|
|
|
623
631
|
};
|
|
624
632
|
}
|
|
625
633
|
|
|
634
|
+
// FA-13: the code family of the imported design's font scheme (a run in it is an inline code run). The scheme's own `code` role,
|
|
635
|
+
// else the bundled record's, else the shared fallback; heading and body families are never code. Only a package the exporter
|
|
636
|
+
// wrote (it carries OPF tag parts) is read this way; a deck from another tool keeps its runs' font families as they are.
|
|
637
|
+
function importedCodeFamily(design) {
|
|
638
|
+
const reference = design?.fontScheme;
|
|
639
|
+
const id = referenceId(reference);
|
|
640
|
+
const base = (id && findById(defaultCatalog("fontSchemes"), id)) || findById(defaultCatalog("fontSchemes"), DEFAULT_FONT_SCHEME);
|
|
641
|
+
return resolveFontFamilies({...base, ...(isPlainObject(reference) ? reference : {})}).code;
|
|
642
|
+
}
|
|
643
|
+
|
|
626
644
|
function dimensionsFromPresentation(presentationRoot) {
|
|
627
645
|
const size = presentationRoot["p:sldSz"];
|
|
628
646
|
const width = emuToInches(size?.cx);
|
|
@@ -659,6 +677,11 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
659
677
|
nativeContext.slideImagePictures = slideImage.consumed;
|
|
660
678
|
nativeContext.slideImageShapes = slideImage.consumedShapes;
|
|
661
679
|
const watermarkPath = `slides.${slideIndex}.design.watermark`;
|
|
680
|
+
// FA-13: a text watermark is a tagged native text box; it is not content and never imports as a text block.
|
|
681
|
+
const textWatermark = importTextWatermark(nativeContext.shapes, nativeContext.paragraphs.map(shapeParagraphs => shapeParagraphs.map(paragraph => ({text: paragraph.text}))), relationships, entries, slideIndex,
|
|
682
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
|
|
683
|
+
if (textWatermark.design) slide.design = {...slide.design, ...textWatermark.design};
|
|
684
|
+
nativeContext.watermarkShapes = textWatermark.consumed;
|
|
662
685
|
const watermark = importWatermark(nativeContext.pictures, relationships, entries, slideIndex,
|
|
663
686
|
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
664
687
|
// The recorded fit is re-derived from design.watermark.
|
|
@@ -684,12 +707,22 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
684
707
|
// fill an absent role by geometry; explicit native placeholders still apply.
|
|
685
708
|
const inferHeadings = !items.some(item => item.heading||item.sourceText);
|
|
686
709
|
const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
|
|
710
|
+
// FA-10: a heading is its plain text, or TextRun[] when its native runs are formatted differently from one another (the way
|
|
711
|
+
// body text reads current native runs). A tagged line group brings its own value; one native shape (a title placeholder, or the
|
|
712
|
+
// shape the importer took for the title) is read through the native body reader.
|
|
713
|
+
const headingText = item => {
|
|
714
|
+
const key = item.sources?.length === 1 ? /^sp:(\d+)$/.exec(item.sources[0]) : null;
|
|
715
|
+
const parts = item.richText === undefined && key ? joinRichLines([{index: Number(key[1])}], nativeContext.readBody, () => '') : undefined;
|
|
716
|
+
const value = item.richText ?? (parts ? headingValue(parts) : undefined);
|
|
717
|
+
// Only when it reads as the same words as the plain path: the native text is authoritative.
|
|
718
|
+
return Array.isArray(value) && value.map(run => typeof run === 'string' ? run : run.text).join('') === item.text ? value : item.text;
|
|
719
|
+
};
|
|
687
720
|
const tagItem=heading('tag');
|
|
688
|
-
if(tagItem)slide.tag=tagItem
|
|
721
|
+
if(tagItem)slide.tag=headingText(tagItem);
|
|
689
722
|
const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
|
|
690
|
-
if (titleItem) slide.title = titleItem
|
|
723
|
+
if (titleItem) slide.title = headingText(titleItem);
|
|
691
724
|
const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
|
|
692
|
-
if (subtitleItem) slide.subtitle = subtitleItem
|
|
725
|
+
if (subtitleItem) slide.subtitle = headingText(subtitleItem);
|
|
693
726
|
|
|
694
727
|
// Heading inference keeps its existing scalar/native metrics. Only remaining
|
|
695
728
|
// ordinary body shapes gain current rich values; tagged recovery is separate.
|
|
@@ -760,16 +793,21 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
760
793
|
nativeContext.signalRoles = roles;
|
|
761
794
|
const readBody = nativeBodyReader(slidePath, {
|
|
762
795
|
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}`}));
|
|
796
|
+
}, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}), {deckLang: options.deckLang, codeFamily: options.codeFamily});
|
|
764
797
|
const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
|
|
765
798
|
const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
|
|
766
799
|
const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
767
800
|
const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
|
|
768
801
|
nativeContext.mediaAssets = media.assets;
|
|
769
|
-
|
|
802
|
+
nativeContext.readBody=readBody;
|
|
803
|
+
const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}),readBody);
|
|
770
804
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
771
805
|
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`})
|
|
806
|
+
const quotes=importQuoteGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote`}),nativeContext.pictures,
|
|
807
|
+
picture=>importPicture(entries,picture,slidePath,relationships,diagnostic=>{
|
|
808
|
+
// The recorded crop is re-derived from the circular frame of the quote photo.
|
|
809
|
+
if(diagnostic.code!=='unsupported-image-crop')options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.quote.photo`});
|
|
810
|
+
}),readBody);
|
|
773
811
|
// RR-34: tagged caption and footnote lines are not content; captions re-attach to their media block and notes rebuild cite/footnote.
|
|
774
812
|
const annotations=importAnnotations({shapes,paragraphs,relationships,entries,slideIndex,readBody,report:diagnostic=>options.onDiagnostic?.(diagnostic)});
|
|
775
813
|
nativeContext.annotations=annotations;
|
|
@@ -778,7 +816,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
778
816
|
if(item){
|
|
779
817
|
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
780
818
|
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)});
|
|
819
|
+
items.push({...item,heading:group.field,sourceText:!group.field,sources:sourcesOf(group.shapes),...(group.richText!==undefined?{richText:group.richText}:{})});
|
|
782
820
|
}
|
|
783
821
|
}
|
|
784
822
|
for(const group of timelines.items){
|
|
@@ -787,18 +825,18 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
787
825
|
items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload,sources:sourcesOf(group.shapes)});
|
|
788
826
|
}
|
|
789
827
|
for(const group of quotes.items){
|
|
790
|
-
const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
|
|
828
|
+
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
829
|
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)});
|
|
830
|
+
items.push({kind:'quote',bounds:union,payload:group.payload,sources:[...sourcesOf(group.shapes),...group.pictures.map(picture=>`pic:${nativeContext.pictures.indexOf(picture)}`)]});
|
|
793
831
|
}
|
|
794
832
|
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
833
|
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
834
|
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
835
|
for (const [index,shape] of shapes.entries()) {
|
|
798
|
-
const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
|
|
836
|
+
const role = furniture.text.has(index) ? 'furniture' : nativeContext.slideImageShapes?.has(index) ? 'slide-image' : nativeContext.watermarkShapes?.has(index) ? 'watermark' : nativeContext.logoPlaceholderShapes?.has(index) ? 'logo' : cards.has(shape) ? 'card-frame'
|
|
799
837
|
: 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
838
|
if (role) roles.set(shapeKeys.get(shape), role);
|
|
801
|
-
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
|
|
839
|
+
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.watermarkShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
|
|
802
840
|
if (annotations.consumed.has(shape)) { roles.set(shapeKeys.get(shape), 'annotation'); continue; }
|
|
803
841
|
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
842
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
@@ -834,7 +872,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
834
872
|
}
|
|
835
873
|
|
|
836
874
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
837
|
-
const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : undefined;
|
|
875
|
+
const pictureRole = furniture.pictures.has(index) ? 'furniture' : nativeContext.slideImagePictures?.has(index) ? 'slide-image' : nativeContext.watermarkPictures?.has(index) ? 'watermark' : nativeContext.logoPictures?.has(index) ? 'logo' : quotes.consumedPictures.has(picture) ? 'quote' : undefined;
|
|
838
876
|
if (pictureRole) { roles.set(`pic:${index}`, pictureRole); continue; }
|
|
839
877
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
840
878
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
@@ -916,15 +954,18 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
916
954
|
: chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
|
|
917
955
|
const stored = imported && record('chart');
|
|
918
956
|
const part = relationships.get(chartRelId)?.path;
|
|
919
|
-
const
|
|
957
|
+
const restored = stored && part && entries[part]
|
|
920
958
|
? restore('chart', imported, () => restoreChartData(imported, stored, chartEvidence(decodeText(entries[part])), {datasets: data.datasets, report: diagnostic => data.report?.('chart', diagnostic)}))
|
|
921
959
|
: imported;
|
|
960
|
+
const chart = restored?.type === 'combo' ? elideComboDefault(restored, data.datasets) : restored;
|
|
961
|
+
// FA-09: the frame's descr (or PowerPoint's decorative marker) is the chart's text alternative.
|
|
962
|
+
const alt = chart ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
|
|
922
963
|
return {
|
|
923
964
|
kind: "chart",
|
|
924
965
|
bounds,
|
|
925
966
|
name,
|
|
926
967
|
payload: chart
|
|
927
|
-
? { type: "chart", chart }
|
|
968
|
+
? { type: "chart", chart: alt === undefined ? chart : { ...chart, alt } }
|
|
928
969
|
: { type: "text", text: `PowerPoint chart: ${name || chartRelId}` }
|
|
929
970
|
};
|
|
930
971
|
}
|
|
@@ -1211,9 +1252,12 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
|
|
|
1211
1252
|
const plotArea = doc["c:chartSpace"]?.["c:chart"]?.["c:plotArea"];
|
|
1212
1253
|
if (!plotArea) return null;
|
|
1213
1254
|
|
|
1214
|
-
|
|
1255
|
+
// FA-15: a clustered column group with one or two line groups is a combo chart; its series are read in c:order.
|
|
1256
|
+
const combo = comboFromNative(plotArea);
|
|
1257
|
+
const comboSeries = combo ? comboSeriesOf(combo) : undefined;
|
|
1258
|
+
const chartNode = combo ? {node: combo.bar, type: 'combo'} : firstChartNode(plotArea);
|
|
1215
1259
|
if (!chartNode) return null;
|
|
1216
|
-
const series = asArray(chartNode.node["c:ser"]);
|
|
1260
|
+
const series = comboSeries ? comboSeries.map(entry => entry.ser) : asArray(chartNode.node["c:ser"]);
|
|
1217
1261
|
if (series.length === 0) return null;
|
|
1218
1262
|
if (series.length > MAX_CHART_CACHE_POINTS) {
|
|
1219
1263
|
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 +1270,8 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
|
|
|
1226
1270
|
const xCode = chartNode.type === 'scatter' ? cacheFormatCode(series[0]?.["c:xVal"]) : undefined;
|
|
1227
1271
|
// RR-35: axis titles, legend position and data labels read back into the chart's option fields.
|
|
1228
1272
|
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
|
|
1273
|
+
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,
|
|
1274
|
+
...(combo ? {combo: {lineNode: combo.lines[0]?.node, secondaryValueAxis: secondaryValueAxisOf(plotArea, combo)}} : {})});
|
|
1230
1275
|
for (const note of notes) report?.(note);
|
|
1231
1276
|
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
1277
|
return chart && Object.keys(options).length ? {...chart, ...options} : chart;
|
|
@@ -1257,10 +1302,57 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
|
|
|
1257
1302
|
data: {
|
|
1258
1303
|
columns: formattedColumns([readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names], [undefined, ...valueCodes], report),
|
|
1259
1304
|
rows
|
|
1260
|
-
}
|
|
1305
|
+
},
|
|
1306
|
+
...(comboSeries ? comboFields(comboSeries, names, report) : {})
|
|
1261
1307
|
});
|
|
1262
1308
|
}
|
|
1263
1309
|
|
|
1310
|
+
// FA-15: the series of a combo plot area with their role and axis, in c:order (document order breaks ties).
|
|
1311
|
+
function comboSeriesOf(combo) {
|
|
1312
|
+
const entries = [
|
|
1313
|
+
...asArray(combo.bar["c:ser"]).map(ser => ({ser, role: 'bar', secondary: false})),
|
|
1314
|
+
...combo.lines.flatMap(group => asArray(group.node["c:ser"]).map(ser => ({ser, role: 'line', secondary: group.secondary}))),
|
|
1315
|
+
];
|
|
1316
|
+
const order = (entry, index) => {
|
|
1317
|
+
const value = Number(entry.ser?.["c:order"]?.val);
|
|
1318
|
+
return Number.isFinite(value) ? value : index;
|
|
1319
|
+
};
|
|
1320
|
+
return entries.map((entry, index) => ({...entry, key: order(entry, index), index})).sort((a, b) => a.key - b.key || a.index - b.index);
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// The c:valAx a secondary line group plots against (its axis that the column group does not use), or undefined.
|
|
1324
|
+
function secondaryValueAxisOf(plotArea, combo) {
|
|
1325
|
+
const group = combo.lines.find(entry => entry.secondary);
|
|
1326
|
+
if (!group) return undefined;
|
|
1327
|
+
const ids = new Set(asArray(group.node["c:axId"]).map(axis => String(axis?.val)));
|
|
1328
|
+
return asArray(plotArea["c:valAx"]).find(axis => ids.has(String(axis?.["c:axId"]?.val)));
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// `line` and `secondaryAxis` of an imported combo chart by series name (elideComboDefault drops a `line` that is the default).
|
|
1332
|
+
// Combo lines always draw markers in OPF; a native line without markers is reported, never silently changed.
|
|
1333
|
+
function comboFields(comboSeries, names, report) {
|
|
1334
|
+
const line = names.filter((_, index) => comboSeries[index].role === 'line');
|
|
1335
|
+
const secondaryAxis = names.filter((_, index) => comboSeries[index].secondary);
|
|
1336
|
+
const unmarked = names.filter((_, index) => comboSeries[index].role === 'line' && comboSeries[index].ser?.["c:marker"]?.["c:symbol"]?.val === 'none');
|
|
1337
|
+
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.`});
|
|
1338
|
+
if (new Set(line).size !== line.length || new Set(names).size !== names.length) {
|
|
1339
|
+
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).'});
|
|
1340
|
+
return {};
|
|
1341
|
+
}
|
|
1342
|
+
return {...(line.length ? {line} : {}), ...(secondaryAxis.length ? {secondaryAxis} : {})};
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// An imported combo chart writes `line` only when it differs from the default (the last plotted series), judged on the final
|
|
1346
|
+
// data (a restored data record can order the columns differently from the native series).
|
|
1347
|
+
function elideComboDefault(chart, datasets) {
|
|
1348
|
+
if (!Array.isArray(chart.line)) return chart;
|
|
1349
|
+
const {line, ...rest} = chart;
|
|
1350
|
+
const resolved = resolveChartData(rest, {datasets: datasets ?? {}});
|
|
1351
|
+
if (!resolved.ok || !Array.isArray(resolved.combo)) return chart;
|
|
1352
|
+
const defaults = resolved.columns.slice(1).filter((_, index) => resolved.combo[index]?.role === 'line');
|
|
1353
|
+
return defaults.length === line.length && defaults.every(name => line.includes(name)) ? rest : chart;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1264
1356
|
// The format code of a numeric cache (c:numRef/c:numCache or c:numLit), or undefined.
|
|
1265
1357
|
function cacheFormatCode(node) {
|
|
1266
1358
|
const cache = node?.["c:numRef"]?.["c:numCache"] ?? node?.["c:numLit"];
|
|
@@ -1505,60 +1597,63 @@ function parseInput(input) {
|
|
|
1505
1597
|
// required variable is refused. Decks without content variables are returned untouched. The package stores the resolved
|
|
1506
1598
|
// deck, not the template form, so fromPptx returns the filled deck.
|
|
1507
1599
|
function resolveTemplateInput(presentation, options) {
|
|
1508
|
-
if (
|
|
1600
|
+
if (!isPlainObject(presentation)) return presentation;
|
|
1509
1601
|
const values = options.variables;
|
|
1510
1602
|
if (values !== undefined && !isPlainObject(values)) {
|
|
1511
1603
|
throw new OPFPptxError("invalid-variables", "The variables option must be an object keyed by variable id.", {path: "options.variables"});
|
|
1512
1604
|
}
|
|
1513
|
-
const template =
|
|
1514
|
-
if (!template && !
|
|
1515
|
-
const result =
|
|
1605
|
+
const template = isTemplate(presentation);
|
|
1606
|
+
if (!template && !hasContentVariables(presentation) && !(values && Object.keys(values).length)) return presentation;
|
|
1607
|
+
const result = resolveVariables(presentation, values ?? {}, {examples: template});
|
|
1516
1608
|
const errors = result.diagnostics.filter(entry => entry.severity === "error");
|
|
1517
1609
|
if (errors.length) {
|
|
1518
|
-
throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {
|
|
1610
|
+
throw new OPFPptxError(errors.some(entry => entry.code === "variable-unfilled") ? "unfilled-variables" : "invalid-variables", errors[0].message, {findings: errors, path: errors[0].path});
|
|
1519
1611
|
}
|
|
1520
1612
|
for (const entry of result.diagnostics) {
|
|
1521
|
-
if (entry.code === "variable-example-used") options.onDiagnostic?.({code:
|
|
1613
|
+
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
1614
|
}
|
|
1523
1615
|
return result.presentation;
|
|
1524
1616
|
}
|
|
1525
1617
|
|
|
1526
1618
|
function assertValidBoundary(presentation) {
|
|
1527
|
-
const
|
|
1528
|
-
if (!
|
|
1619
|
+
const report = checkFormat(presentation);
|
|
1620
|
+
if (!report.valid) {
|
|
1529
1621
|
throw new OPFPptxError("invalid-opf", "OPF validation failed.", {
|
|
1530
|
-
|
|
1531
|
-
|
|
1622
|
+
findings: report.findings.filter(finding => finding.severity === "error"),
|
|
1623
|
+
report
|
|
1532
1624
|
});
|
|
1533
1625
|
}
|
|
1534
1626
|
}
|
|
1535
1627
|
|
|
1536
|
-
|
|
1628
|
+
// RR-55: what a slide (or, for the deck level, an empty slide) resolves to in core, the one chain every engine shares: canvas,
|
|
1629
|
+
// layout, font families, alignment and content box as composeSlide's options, plus the theme, colour scheme and font scheme
|
|
1630
|
+
// records they came from. `diagnostics` names references that matched no record.
|
|
1631
|
+
function coreContext(presentation, index, options) {
|
|
1632
|
+
return resolveCoreSlideContext(presentation, index, {fonts: options.textMeasurement ? {textMeasurement: options.textMeasurement} : undefined, date: options.date});
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// The families composition and the package use: core's, with the measurement provider's own resolution of the chosen family
|
|
1636
|
+
// (FF-31) applied to the three roles the exporter writes.
|
|
1637
|
+
function exportFonts(core, textMeasurement) {
|
|
1638
|
+
const {fontFamilies} = core.options, scheme = core.resolved.fontScheme;
|
|
1639
|
+
const fonts = {id: scheme.id, ...fontFamilies, scheme: {type: scheme.type, major: scheme.major, minor: scheme.minor}};
|
|
1640
|
+
for (const role of ["heading", "body", "code"]) fonts[role] = resolveTextStyle({fontFamily: fonts[role], fontWeight: role === "heading" ? 700 : 400}, textMeasurement).fontFamily;
|
|
1641
|
+
return fonts;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
function resolvePresentationContext(presentation, options, core = coreContext({...presentation, slides: [{}]}, 0, {...options, textMeasurement: undefined})) {
|
|
1537
1645
|
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);
|
|
1646
|
+
// `core` defaults to the deck level: the resolution of a slide that sets nothing of its own.
|
|
1647
|
+
// The theme and colour scheme records come from core, which falls back to the default records (reporting unresolved-theme and
|
|
1648
|
+
// unresolved-color-scheme) for a reference that matches no record.
|
|
1649
|
+
const {theme, colorScheme} = core.resolved;
|
|
1650
|
+
const dimensions = {widthInches: core.options.width / 96, heightInches: core.options.height / 96};
|
|
1556
1651
|
const variables = resolveVariableColors(presentation.variables);
|
|
1557
1652
|
const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
|
|
1558
|
-
const fonts =
|
|
1559
|
-
for
|
|
1560
|
-
const
|
|
1561
|
-
const
|
|
1653
|
+
const fonts = exportFonts(core, options.textMeasurement);
|
|
1654
|
+
// One resolution for every color role (core resolveColorRoles), shared with the SVG preview and the audit.
|
|
1655
|
+
const roles = resolveColorRoles(colorScheme, {background: `#${background}`});
|
|
1656
|
+
const textColor = normalizeHex(roles.text);
|
|
1562
1657
|
|
|
1563
1658
|
return {
|
|
1564
1659
|
seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
|
|
@@ -1568,16 +1663,19 @@ function resolvePresentationContext(presentation, options) {
|
|
|
1568
1663
|
compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
|
|
1569
1664
|
layoutName: "OPF_CANVAS",
|
|
1570
1665
|
dimensions,
|
|
1666
|
+
theme,
|
|
1571
1667
|
colorScheme,
|
|
1572
1668
|
backgroundDefinition: design.background ?? theme?.background,
|
|
1573
1669
|
fonts,
|
|
1574
|
-
|
|
1670
|
+
// The color roles a ColorRef names (primary, secondary, accent, background, surface, text, textSecondary, hyperlink).
|
|
1671
|
+
roles,
|
|
1575
1672
|
colors: {
|
|
1576
1673
|
background,
|
|
1577
1674
|
text: textColor,
|
|
1578
|
-
mutedText: normalizeHex(
|
|
1579
|
-
accent
|
|
1580
|
-
|
|
1675
|
+
mutedText: normalizeHex(roles.textSecondary),
|
|
1676
|
+
// The primary color, which the exporter draws accent fills (table headers, tag, media badge) in. The `accent` ColorRef role is roles.accent.
|
|
1677
|
+
accent: normalizeHex(roles.primary),
|
|
1678
|
+
surface: normalizeHex(roles.surface),
|
|
1581
1679
|
border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
|
|
1582
1680
|
},
|
|
1583
1681
|
variables
|
|
@@ -1618,11 +1716,31 @@ function contrastRatio(first, second) {
|
|
|
1618
1716
|
const a = relativeLuminance(first), b = relativeLuminance(second);
|
|
1619
1717
|
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
1620
1718
|
}
|
|
1719
|
+
// The tag's color as a literal (the one tagColor writes, resolved).
|
|
1720
|
+
function tagHex(context) {
|
|
1721
|
+
return contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST ? context.colors.text : context.colors.accent;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
// The defaults a rich heading or quote body is exported with on every run (weight, color, size, family). The tag records them so that
|
|
1725
|
+
// import can tell the heading's own look from run formatting the author chose.
|
|
1726
|
+
function richBase(fit, style, color) {
|
|
1727
|
+
const font = nativeFontOptions(style);
|
|
1728
|
+
return {bold: font.bold === true, color: `#${normalizeHex(color)}`, fontSize: Math.round(fit.fontSize * .75 * 100) / 100, fontFamily: font.fontFace};
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1621
1731
|
function tagColor(context) {
|
|
1622
1732
|
if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
|
|
1623
1733
|
return nativeColor('primary', context.colors.accent, context);
|
|
1624
1734
|
}
|
|
1625
1735
|
|
|
1736
|
+
// A link run with no color of its own is written in the theme hyperlink color (a:schemeClr hlink where the deck theme
|
|
1737
|
+
// holds that exact color, else the literal), underlined by PowerPoint, as the preview draws it. A run color is kept.
|
|
1738
|
+
function linkColor(run, color, context) {
|
|
1739
|
+
const link = typeof run?.link === 'string' && /^(https?:|mailto:|tel:)/i.test(run.link);
|
|
1740
|
+
if (!link || !(run.color === undefined || run.color === null || run.color === '')) return color;
|
|
1741
|
+
return nativeColor('hyperlink', normalizeHex(context.roles.hyperlink), context);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1626
1744
|
// FF-24c: two literals no document color uses stand in for hlink and folHlink
|
|
1627
1745
|
// (see nativeColor). When the document uses every candidate, those colors stay literal.
|
|
1628
1746
|
const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
|
|
@@ -1662,7 +1780,7 @@ function masterBackground(context) {
|
|
|
1662
1780
|
function exportTheme(presentation, context) {
|
|
1663
1781
|
const reference = presentation.design?.theme;
|
|
1664
1782
|
const id = referenceId(reference);
|
|
1665
|
-
const theme = id ?
|
|
1783
|
+
const theme = id ? context.theme : null;
|
|
1666
1784
|
const scheme = context.colorScheme;
|
|
1667
1785
|
return {
|
|
1668
1786
|
themeColors: themeSlotColors(scheme),
|
|
@@ -1719,7 +1837,19 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
1719
1837
|
|
|
1720
1838
|
async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
|
|
1721
1839
|
const slide = pptx.addSlide();
|
|
1722
|
-
const slideContext =
|
|
1840
|
+
const slideContext = exportSlideContext(presentation, slideIndex, context, options);
|
|
1841
|
+
const { widthInches, heightInches } = slideContext.dimensions;
|
|
1842
|
+
// A slide with no layout, or one that names a layout no catalog defines, is composed with no layout record (automatic composition),
|
|
1843
|
+
// as in the preview; core reports the unknown id as unresolved-layout.
|
|
1844
|
+
// Core's resolved options (canvas, layout, families, alignment, content box, darkBackground), with the exporter's own additions:
|
|
1845
|
+
// the families after the measurement provider's resolution of the chosen family, the raster padding and the host's social platform records.
|
|
1846
|
+
const composeOptions = { ...slideContext.core.options, fontFamilies: slideContext.composeFonts, textRasterPadding:options.textRasterPadding, socialPlatforms: socialPlatformRecords(presentation, options) };
|
|
1847
|
+
// Core resolves every shared design key once (slide design, deck design, then the layout record's design): the text alignment of
|
|
1848
|
+
// each item (item.alignment), and the imageFill that backgrounds and picture placements use (SlideComposition.design).
|
|
1849
|
+
const geometry = composeSlide(opfSlide, composeOptions);
|
|
1850
|
+
slideContext.imageFill = geometry.design.imageFill ?? "fit";
|
|
1851
|
+
// The default footer band (date left, text center, number right): where a footer added natively lands (RR-11).
|
|
1852
|
+
if (slideIndex === 0) context.defaultFooterOptions = composeOptions;
|
|
1723
1853
|
slide.background = { color: slideContext.colors.background };
|
|
1724
1854
|
const backgroundDefinition = slideContext.backgroundDefinition;
|
|
1725
1855
|
const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
|
|
@@ -1750,23 +1880,6 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1750
1880
|
slide.color = slideContext.textColor;
|
|
1751
1881
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
1752
1882
|
|
|
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
1883
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
1771
1884
|
// The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
|
|
1772
1885
|
recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
|
|
@@ -1783,14 +1896,14 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1783
1896
|
const frame=item.frameBox;
|
|
1784
1897
|
context.cardTags.set(`OPF card ${item.path}`,item.path);
|
|
1785
1898
|
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[
|
|
1899
|
+
const surface=itemContext.colorScheme.surface??itemContext.colorScheme[itemContext.roles.dark?'dark2':'light2']??`#${itemContext.colors.surface}`;
|
|
1787
1900
|
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
1788
1901
|
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
1789
1902
|
fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
|
|
1790
1903
|
}
|
|
1791
1904
|
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)) {
|
|
1905
|
+
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});
|
|
1906
|
+
} else if (["title", "subtitle", "tag"].includes(item.field) && !item.text?.richLines) {
|
|
1794
1907
|
// Estimated-font headings use one native text box, which still needs a
|
|
1795
1908
|
// role tag. Role recovery must not depend on outline measurement support.
|
|
1796
1909
|
const objectName = `OPF heading ${item.path} line 0`;
|
|
@@ -1799,34 +1912,30 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1799
1912
|
...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
|
|
1800
1913
|
...nativeFontOptions(item.textStyle),
|
|
1801
1914
|
color: item.field === "tag" ? tagColor(itemContext) : itemContext.textColor,
|
|
1802
|
-
align: physicalAlignment(
|
|
1915
|
+
align: physicalAlignment(item.alignment, item.text.directions?.[0]),
|
|
1803
1916
|
...(item.text.directions?.[0] === 'rtl' ? {rtlMode: true} : {}),
|
|
1804
1917
|
objectName,
|
|
1805
1918
|
breakLine: false
|
|
1806
1919
|
});
|
|
1807
1920
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1808
1921
|
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
|
-
}
|
|
1922
|
+
} else if (["text", "title", "subtitle", "tag"].includes(item.field) && item.text?.richLines) {
|
|
1923
|
+
// 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).
|
|
1924
|
+
const heading = item.field !== 'text';
|
|
1925
|
+
addRichFitLines(slide, item.text, item.box, itemContext, {
|
|
1926
|
+
value: item.value, alignment: item.text.placement?.alignment ?? item.alignment ?? 'left',
|
|
1927
|
+
fallback: item.field === 'tag' ? tagColor(itemContext) : itemContext.textColor,
|
|
1928
|
+
...(heading ? {
|
|
1929
|
+
objectName: index => `OPF heading ${item.path} line ${index}`,
|
|
1930
|
+
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)})
|
|
1931
|
+
} : {})
|
|
1932
|
+
});
|
|
1824
1933
|
} 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(
|
|
1934
|
+
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])});
|
|
1826
1935
|
} else {
|
|
1827
1936
|
// 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
1937
|
const mediaNames = item.caption ? new Set([...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()]) : undefined;
|
|
1829
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment:
|
|
1938
|
+
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
1939
|
if (item.caption) {
|
|
1831
1940
|
const mediaName = item.field === 'video' ? `OPF media ${item.path} frame` : [...context.imagePlacements.keys(), ...context.imagePlaceholders.keys(), ...context.chartHeadings.keys(), ...context.tableHeaders.keys()].find(name => !mediaNames.has(name));
|
|
1832
1941
|
addCaption(slide, item, mediaName, itemContext, exportHelpers, (code, message) => new OPFPptxError(code, message, {path: item.caption.path}));
|
|
@@ -1847,24 +1956,24 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1847
1956
|
}
|
|
1848
1957
|
}
|
|
1849
1958
|
|
|
1850
|
-
|
|
1959
|
+
// One slide's export context: core's resolveSlideContext (`core`: composeSlide's options, the records and the diagnostics) and
|
|
1960
|
+
// the exporter's colours and measurement-resolved families derived from it.
|
|
1961
|
+
function exportSlideContext(presentation, slideIndex, baseContext, options) {
|
|
1962
|
+
const slide = presentation.slides[slideIndex];
|
|
1851
1963
|
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
|
-
}
|
|
1964
|
+
const core = coreContext(presentation, slideIndex, options);
|
|
1965
|
+
const resolved = resolvePresentationContext(effective, options, core);
|
|
1966
|
+
// A reference that matched no record is reported once per place it is written.
|
|
1967
|
+
for (const diagnostic of core.diagnostics) {
|
|
1968
|
+
if (baseContext.reportedReferences?.has(diagnostic.path)) continue;
|
|
1969
|
+
baseContext.reportedReferences?.add(diagnostic.path);
|
|
1970
|
+
options.onDiagnostic?.(diagnostic);
|
|
1862
1971
|
}
|
|
1863
1972
|
if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
|
|
1864
1973
|
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
1865
1974
|
throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
|
|
1866
1975
|
}
|
|
1867
|
-
const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors,
|
|
1976
|
+
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
1977
|
// A slide-level color scheme (directly or through a slide theme) pins every
|
|
1869
1978
|
// named color on that slide to literal sRGB; see schemeColorValue().
|
|
1870
1979
|
const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
|
|
@@ -1909,7 +2018,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1909
2018
|
addMetricPayload(slide, payload.metric,metricLayout,context,path,options);
|
|
1910
2019
|
break;
|
|
1911
2020
|
case "quote":
|
|
1912
|
-
addQuotePayload(slide, payload.quote, quoteLayout, context, options, path);
|
|
2021
|
+
await addQuotePayload(slide, presentation, payload.quote, quoteLayout, context, options, path);
|
|
1913
2022
|
break;
|
|
1914
2023
|
case "timeline":
|
|
1915
2024
|
addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
|
|
@@ -1946,7 +2055,7 @@ function richLineRuns(line,color,native,context) {
|
|
|
1946
2055
|
const fallback=color.replace(/^#/,'');
|
|
1947
2056
|
return line.fragments.map(fragment=>{
|
|
1948
2057
|
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}};
|
|
2058
|
+
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
2059
|
});
|
|
1951
2060
|
}
|
|
1952
2061
|
// Every native line of a list, marker paragraph or not, is named for the list's
|
|
@@ -2076,13 +2185,14 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
2076
2185
|
const watermark = local ? opfSlide.design.watermark : presentation.design?.watermark;
|
|
2077
2186
|
if (watermark === undefined || watermark === null || watermark === false) return;
|
|
2078
2187
|
const path = local ? `slides.${slideIndex}.design.watermark` : 'design.watermark';
|
|
2079
|
-
|
|
2080
|
-
const asset =
|
|
2081
|
-
|
|
2082
|
-
|
|
2188
|
+
// The schema requires exactly one of src and text, so there is no source-less watermark to report.
|
|
2189
|
+
const asset = watermark;
|
|
2190
|
+
const {widthInches, heightInches} = slideContext.dimensions;
|
|
2191
|
+
// FA-13: a text watermark is one native text box with the opacity as text alpha, centered and rotated as core's layoutWatermark says.
|
|
2192
|
+
if (isPlainObject(asset) && typeof asset.text === 'string') {
|
|
2193
|
+
addTextWatermark(slide, asset, slideIndex, {width: widthInches * 96, height: heightInches * 96}, slideContext, context, options, path);
|
|
2083
2194
|
return;
|
|
2084
2195
|
}
|
|
2085
|
-
const {widthInches, heightInches} = slideContext.dimensions;
|
|
2086
2196
|
const box = watermarkBox(widthInches, heightInches);
|
|
2087
2197
|
const outcome = {box};
|
|
2088
2198
|
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
@@ -2097,6 +2207,23 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
2097
2207
|
slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
|
|
2098
2208
|
}
|
|
2099
2209
|
|
|
2210
|
+
// The stamp takes the slide's own resolved fonts and default text color (readable on that slide's background), as the preview does.
|
|
2211
|
+
function addTextWatermark(slide, watermark, slideIndex, size, slideContext, context, options, path) {
|
|
2212
|
+
const layout = layoutWatermark(watermark.text, size, {fontFamily: slideContext.fonts.heading, fontWeight: 700, textMeasurement: options.textMeasurement});
|
|
2213
|
+
// The schema requires text, so this is null only for text that draws nothing (the preview draws nothing either).
|
|
2214
|
+
if (!layout) return;
|
|
2215
|
+
const opacity = watermarkOpacity(watermark);
|
|
2216
|
+
const {box} = layout;
|
|
2217
|
+
slide.addText(layout.text, {
|
|
2218
|
+
...textBoxOptions({x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96}, slideContext, layout.fontSize * .75),
|
|
2219
|
+
...nativeFontOptions(layout.style), bold: true,
|
|
2220
|
+
color: slideContext.textColor, transparency: Math.round((1 - opacity) * 100000) / 1000,
|
|
2221
|
+
align: 'center', valign: 'middle', fit: 'none', wrap: false, lineSpacingMultiple: 1,
|
|
2222
|
+
rotate: (layout.rotation + 360) % 360, objectName: watermarkTextName()
|
|
2223
|
+
});
|
|
2224
|
+
context.textWatermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, text: layout.text, opacity});
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2100
2227
|
// The deck logo (design.logo, a slide's own logo or the primary organization's) on a cover or section slide: one native
|
|
2101
2228
|
// picture per slide at core's geometry.logo box, fitted without cropping, anchored left and vertically centered, after
|
|
2102
2229
|
// the watermark and before content (the preview's paint order). The frame and the provenance tag are written after
|
|
@@ -2202,7 +2329,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2202
2329
|
|
|
2203
2330
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
2204
2331
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
2205
|
-
const panelFill = context.colorScheme.surface ?? context.colorScheme[
|
|
2332
|
+
const panelFill = context.colorScheme.surface ?? context.colorScheme[context.roles.dark ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
|
|
2206
2333
|
const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
|
|
2207
2334
|
const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
|
|
2208
2335
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
@@ -2211,15 +2338,25 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2211
2338
|
const textSize = chartTextSize(context);
|
|
2212
2339
|
// RR-54: the columns' number formats (Excel codes per exported series) for the caches, labels, value axis and workbook.
|
|
2213
2340
|
// The value axis shows the first plotted series' format (General when it has none), as the preview does.
|
|
2341
|
+
// A combo chart's secondary value axis shows its first series' format (FA-15).
|
|
2342
|
+
const secondaryIndex = chartData.combo?.findIndex((entry) => entry.axis === 'secondary') ?? -1;
|
|
2214
2343
|
const numberFormats = chartData.formats && {...chartData.formats, axis: chartData.spec.grouping === 'percentStacked' ? undefined : chartData.formats.series[0],
|
|
2344
|
+
...(secondaryIndex >= 0 ? {secondaryAxis: chartData.formats.series[secondaryIndex]} : {}),
|
|
2215
2345
|
labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
|
|
2216
2346
|
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
|
|
2217
2347
|
options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
|
|
2218
2348
|
recordPayloadData(context, objectName, 'chart', presentation, path);
|
|
2219
2349
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
2350
|
+
// FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
|
|
2351
|
+
if (typeof chart.alt === 'string') context.chartAlts.set(objectName, chart.alt);
|
|
2220
2352
|
const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
|
|
2221
|
-
const palette =
|
|
2222
|
-
|
|
2353
|
+
const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
|
|
2354
|
+
// FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
|
|
2355
|
+
// contrast with those colours (src/chart-highlight.js).
|
|
2356
|
+
const highlight = chartData.chartex ? undefined : chartHighlightPlan({options: chartOptions, data: chartData.resolved, kind: chartTargetFor(chart.type)?.kind, panelFill, labelColor, primary: `#${context.colors.accent}`,
|
|
2357
|
+
schemeFor: hex => schemeColorValue('primary', hex, context)});
|
|
2358
|
+
context.chartPalettes.set(objectName,highlight ? highlight.seriesFills : palette);
|
|
2359
|
+
if (highlight) context.chartHeadings.get(objectName).highlight = highlight;
|
|
2223
2360
|
if (chartData.chartex) {
|
|
2224
2361
|
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
2225
2362
|
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
@@ -2235,7 +2372,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2235
2372
|
// bar chart with one series whenever chartColors is a custom array of more than one colour, so PowerPoint drew each
|
|
2236
2373
|
// column of a single-series column or bar chart in a different colour. One colour for that series writes no c:dPt.
|
|
2237
2374
|
const chartColors = chartData.type === 'bar' && chartData.series.length === 1 ? palette.slice(0, 1) : palette;
|
|
2238
|
-
|
|
2375
|
+
const chartOptionsXml = {
|
|
2239
2376
|
objectName,
|
|
2240
2377
|
x: region.x,
|
|
2241
2378
|
y: region.y,
|
|
@@ -2271,7 +2408,37 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2271
2408
|
...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
|
|
2272
2409
|
// ScatterWithMarkers: markers only, no connecting line.
|
|
2273
2410
|
...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
|
|
2274
|
-
}
|
|
2411
|
+
};
|
|
2412
|
+
if (chartData.combo?.length && chartData.combo.length === chartData.series.length) {
|
|
2413
|
+
slide.addChart(...comboChart(chartData, chartOptionsXml, {palette, chartOptions, labelColor, font: context.fonts.body, textSize}));
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
slide.addChart(chartData.type, chartData.series, chartOptionsXml);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// FA-15: a combo chart is one PptxGenJS multi-type chart: a clustered column c:barChart on the primary axes, a c:lineChart with
|
|
2420
|
+
// markers for the primary-axis lines, and one for the secondary-axis lines on a second c:valAx (at the right, crossing at the
|
|
2421
|
+
// maximum of a deleted second c:catAx). Each series keeps its palette colour by its index in the whole chart (PptxGenJS restarts
|
|
2422
|
+
// the colour cycle per chart type), and the embedded workbook holds every series. The secondary axis has no gridlines and only
|
|
2423
|
+
// its own title; applyComboConstruct puts the line groups into schema order afterwards.
|
|
2424
|
+
function comboChart(chartData, options, {palette, chartOptions, labelColor, font, textSize}) {
|
|
2425
|
+
const series = chartData.series.map((entry, index) => ({...entry, color: palette[index % palette.length]}));
|
|
2426
|
+
const part = (role, axis) => series.filter((_, index) => chartData.combo[index].role === role && (role === 'bar' || chartData.combo[index].axis === axis));
|
|
2427
|
+
const bars = part('bar'), primaryLines = part('line', 'primary'), secondaryLines = part('line', 'secondary');
|
|
2428
|
+
const lineOptions = {lineDataSymbol: 'circle'};
|
|
2429
|
+
const types = [
|
|
2430
|
+
// The column group's own colour list: with one column series, a longer list would make PptxGenJS colour each column (c:dPt, RR-36).
|
|
2431
|
+
...(bars.length ? [{type: 'bar', data: bars, options: {barDir: 'col', barGrouping: 'clustered', chartColors: bars.map((entry) => entry.color)}}] : []),
|
|
2432
|
+
...(primaryLines.length ? [{type: 'line', data: primaryLines, options: lineOptions}] : []),
|
|
2433
|
+
...(secondaryLines.length ? [{type: 'line', data: secondaryLines, options: {...lineOptions, secondaryValAxis: true, secondaryCatAxis: true}}] : []),
|
|
2434
|
+
];
|
|
2435
|
+
// A gap in a line series breaks the line, as the preview draws it (PptxGenJS defaults to span).
|
|
2436
|
+
const {barGrouping: _grouping, ...rest} = options;
|
|
2437
|
+
const shared = {...rest, displayBlanksAs: 'gap'};
|
|
2438
|
+
if (!secondaryLines.length) return [types, {...shared, barDir: 'col'}];
|
|
2439
|
+
const title = chartOptions?.axisTitles?.secondary;
|
|
2440
|
+
const secondaryValue = {valGridLine: {style: 'none'}, showValAxisTitle: Boolean(title), ...(title ? {valAxisTitle: title, valAxisTitleColor: labelColor, valAxisTitleFontFace: font, valAxisTitleFontSize: textSize / 100} : {})};
|
|
2441
|
+
return [types, {...shared, barDir: 'col', valAxes: [{}, secondaryValue], catAxes: [{}, {catAxisHidden: true, showCatAxisTitle: false}]}];
|
|
2275
2442
|
}
|
|
2276
2443
|
|
|
2277
2444
|
// RR-54: a chart or table that uses a dataset, DataColumn objects, a mapping, a data source or number formats records its
|
|
@@ -2330,9 +2497,9 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2330
2497
|
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
2498
|
const runOptions = {
|
|
2332
2499
|
...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,
|
|
2500
|
+
underline:run.underline ? {style:'sng',color:linkColor(run,color,context)} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
2501
|
+
color:linkColor(run,color,context),transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
|
|
2502
|
+
hyperlink:run.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? {url:run.link} : undefined,
|
|
2336
2503
|
};
|
|
2337
2504
|
// PptxGenJS marks every part of a newline-containing run as a paragraph
|
|
2338
2505
|
// break, including its final part. Split explicitly so the next styled
|
|
@@ -2394,6 +2561,39 @@ function pixelBox(region) {
|
|
|
2394
2561
|
return {x: region.x * 96, y: region.y * 96, width: region.w * 96, height: region.h * 96};
|
|
2395
2562
|
}
|
|
2396
2563
|
|
|
2564
|
+
// One native text box per laid-out rich line (core's richLines), each carrying its runs: run color, size, weight, underline, strike,
|
|
2565
|
+
// super/subscript (a citation marker is a baseline run) and http(s)/mailto links. The body's lines stay untagged; a heading or quote
|
|
2566
|
+
// body passes `objectName` and `tag`, which name and tag every line with the boundary between wrapped lines (a soft wrap inserts
|
|
2567
|
+
// nothing, a hard line break keeps its separator), recovered from the flattened text. `fallback` is the color of runs without one.
|
|
2568
|
+
function addRichFitLines(slide, fit, box, context, {value, alignment: logicalAlignment, fallback, objectName, tag}) {
|
|
2569
|
+
const region = {x: box.x / 96, y: box.y / 96, w: box.width / 96, h: box.height / 96};
|
|
2570
|
+
const runText = run => typeof run === 'string' ? run : run.text;
|
|
2571
|
+
const whole = typeof value === 'string' ? value : value.map(runText).join('');
|
|
2572
|
+
const count = fit.richLines.length;
|
|
2573
|
+
let cursor = 0;
|
|
2574
|
+
for (const [index, line] of fit.richLines.entries()) {
|
|
2575
|
+
const runs = line.fragments.map(fragment => {
|
|
2576
|
+
const runColor = exportColor(fragment.run.color, context, context.colors.text);
|
|
2577
|
+
const color = nativeColor(fragment.run.color, runColor, context, fallback);
|
|
2578
|
+
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)}};
|
|
2579
|
+
});
|
|
2580
|
+
// Logical alignment (RR-05): a right-to-left paragraph starts at the right edge; every wrapped line shares its paragraph's direction.
|
|
2581
|
+
const placed = fit.placement?.lines[index], alignment = placed?.alignment ?? physicalAlignment(logicalAlignment, fit.directions?.[index]), factor = alignment === 'right' ? 1 : alignment === 'center' ? .5 : 0;
|
|
2582
|
+
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};
|
|
2583
|
+
// PptxGenJS reads the paragraph direction from the first run's options, not from the shape options.
|
|
2584
|
+
if (fit.directions?.[index] === 'rtl') for (const run of runs) run.options.rtlMode = true;
|
|
2585
|
+
let name;
|
|
2586
|
+
if (tag) {
|
|
2587
|
+
const text = fit.lines[index] ?? '', end = cursor + text.length;
|
|
2588
|
+
const newline = index < count - 1 && whole.startsWith(text, cursor) ? /^(\r\n|\r|\n)/.exec(whole.slice(end)) : null;
|
|
2589
|
+
name = objectName(index);
|
|
2590
|
+
tag(name, index, count, {boundary: index === count - 1 ? 'end' : newline ? 'hard' : 'soft', separator: newline ? newline[0] : ''});
|
|
2591
|
+
cursor = end + (newline ? newline[0].length : 0);
|
|
2592
|
+
}
|
|
2593
|
+
if (runs.length) slide.addText(runs, {...textBoxOptions(area, context, fit.fontSize * .75), align: alignment, fit: 'none', wrap: false, lineSpacingMultiple: 1, ...(name ? {objectName: name} : {})});
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2397
2597
|
// Match the published renderer's payload geometry and shared core text fitting.
|
|
2398
2598
|
// Each fitted line remains native editable text, without PowerPoint rewrapping it.
|
|
2399
2599
|
function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
@@ -2405,7 +2605,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
2405
2605
|
if (fit.overflow && !config.diagnosticsHandled) {
|
|
2406
2606
|
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
2607
|
options.onDiagnostic?.(diagnostic);
|
|
2408
|
-
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path,
|
|
2608
|
+
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, diagnostics: [diagnostic]});
|
|
2409
2609
|
}
|
|
2410
2610
|
// Generated socials carry one link per explicit source line; wrapped lines share it.
|
|
2411
2611
|
let sourceLineIndex = 0;
|
|
@@ -2511,52 +2711,76 @@ function addCodePayload(slide, value, layout, region, context, path, options) {
|
|
|
2511
2711
|
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
2712
|
}
|
|
2513
2713
|
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
|
-
|
|
2714
|
+
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});
|
|
2715
|
+
// FA-13: code.highlight is one native rectangle per run of marked lines, between the panel and the line text boxes; marked
|
|
2716
|
+
// lines keep the syntax colours, the others are dimmed, from the same core helpers the preview calls.
|
|
2717
|
+
const highlight = codeHighlight(value, layout, context);
|
|
2718
|
+
context.codeTags.set(panelName,codeManifest(value,layout,group,highlight?.bands.length));
|
|
2516
2719
|
const syntax = codeSyntax(value, layout, context, path, options);
|
|
2517
2720
|
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
|
|
2721
|
+
if (highlight) {
|
|
2722
|
+
const bodyPart = layout.parts.find(part => part.role === 'body');
|
|
2723
|
+
highlight.bands.forEach((band, bandIndex) => {
|
|
2724
|
+
const lineHeight = bodyPart.fit.lineHeight;
|
|
2725
|
+
const bandName = `OPF code ${group} highlight ${bandIndex + 1}`;
|
|
2726
|
+
context.codeTags.set(bandName, {v:1, group, role:'highlight', index:bandIndex});
|
|
2727
|
+
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,
|
|
2728
|
+
fill: {color: highlight.colors.band.slice(1)}, line: {type: 'none'}, objectName: bandName});
|
|
2729
|
+
});
|
|
2730
|
+
}
|
|
2518
2731
|
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,
|
|
2732
|
+
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
2733
|
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
2521
2734
|
const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
|
|
2522
2735
|
const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
|
|
2523
2736
|
context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
|
|
2524
2737
|
// 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' ?
|
|
2738
|
+
const runs = syntax && part.role==='body' ? codeLineRuns(syntax.tokens,line.start,line.end,part.text) : undefined;
|
|
2739
|
+
const lineColors = highlight && part.role==='body' ? (highlight.marked.has(highlight.numbers[index]) ? highlight.colors.lit : highlight.colors.dim) : undefined;
|
|
2526
2740
|
const lineText = part.text.slice(line.start,line.end);
|
|
2527
2741
|
const lineOptions = {
|
|
2528
2742
|
...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
2743
|
...nativeFontOptions(part.style),
|
|
2530
|
-
color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2744
|
+
color:part.role==='body'?(lineColors?.plain.slice(1)??'E5E7EB'):'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
|
|
2531
2745
|
tabStops:tabStops.length?tabStops:undefined,objectName,
|
|
2532
2746
|
};
|
|
2533
2747
|
// 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);
|
|
2748
|
+
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
2749
|
}
|
|
2536
2750
|
}
|
|
2537
2751
|
}
|
|
2538
2752
|
|
|
2753
|
+
// FA-13: the marked lines of code.highlight, their bands (runs of displayed lines) and the lit/dimmed colours, from core (the preview
|
|
2754
|
+
// calls the same functions); undefined when the code marks no line.
|
|
2755
|
+
function codeHighlight(value, layout, context) {
|
|
2756
|
+
if (!value || typeof value !== 'object' || !Array.isArray(value.highlight)) return undefined;
|
|
2757
|
+
const body = layout.parts.find(part => part.role === 'body');
|
|
2758
|
+
if (!body?.fit) return undefined;
|
|
2759
|
+
const lines = codeHighlightLines(value.highlight, body.text).lines;
|
|
2760
|
+
if (!lines.length) return undefined;
|
|
2761
|
+
return {marked: new Set(lines), numbers: codeLineNumbers(body.fit.sourceLines), bands: codeHighlightBands(body.fit.sourceLines, lines), colors: codeHighlightColors(context.colorScheme)};
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2539
2764
|
// Token ranges and palette for the code body, from core (the preview calls the same functions); undefined for plain code.
|
|
2540
2765
|
function codeSyntax(value, layout, context, path, options) {
|
|
2541
2766
|
const body = layout.parts.find(part => part.role === 'body');
|
|
2542
2767
|
const language = typeof value?.language === 'string' ? value.language : layout.parts.find(part => part.role === 'language')?.text;
|
|
2543
2768
|
if (!body || !language) return undefined;
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
return tokens.length ? {tokens, palette: opfCore.codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
|
|
2769
|
+
const tokens = tokenizeCode(body.text, language);
|
|
2770
|
+
return tokens.length ? {tokens, palette: codeSyntaxPaletteForScheme(context.colorScheme)} : undefined;
|
|
2547
2771
|
}
|
|
2548
2772
|
|
|
2549
2773
|
function addMetricPayload(slide,value,layout,context,path,options) {
|
|
2550
2774
|
if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
|
|
2551
2775
|
const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
|
|
2552
2776
|
// RR-07: a trend colours the trend and delta text and adds one native arrow shape, from core's accepted geometry.
|
|
2553
|
-
const trendMark=
|
|
2777
|
+
const trendMark=metricTrendMark(layout,{background:`#${normalizeHex(context.colors.background)}`});
|
|
2554
2778
|
const partColor=part=>trendMark&&(part.role==='trend'||part.role==='delta')?trendMark.color.slice(1):part.role==='value'?context.colors.accent:context.textColor;
|
|
2555
2779
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
2556
2780
|
const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
|
|
2557
2781
|
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
2782
|
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,
|
|
2783
|
+
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
2784
|
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
2561
2785
|
const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
|
|
2562
2786
|
context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
|
|
@@ -2582,37 +2806,80 @@ function addMetricPayload(slide,value,layout,context,path,options) {
|
|
|
2582
2806
|
}
|
|
2583
2807
|
}
|
|
2584
2808
|
|
|
2585
|
-
function addQuotePayload(slide, value, layout, context, options, path) {
|
|
2809
|
+
async function addQuotePayload(slide, presentation, value, layout, context, options, path) {
|
|
2586
2810
|
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,
|
|
2811
|
+
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
2812
|
// Every line shape is tagged (OPF_QUOTE_V1) so an unchanged export re-imports as a quote payload (FF-57).
|
|
2589
2813
|
const group=String(context.quoteTags.size),anchor=quoteManifest(value,layout);
|
|
2590
2814
|
for (const [index,part] of layout.parts.entries()) {
|
|
2815
|
+
// 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.
|
|
2816
|
+
if (part.runs && part.fit.richLines) {
|
|
2817
|
+
addRichFitLines(slide, part.fit, part.box, context, {
|
|
2818
|
+
value: part.text, alignment: part.fit.placement?.alignment ?? context.contentAlignment ?? 'left', fallback: context.textColor,
|
|
2819
|
+
objectName: line => `OPF quote ${group} part ${index} line ${line}`,
|
|
2820
|
+
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} : {})})
|
|
2821
|
+
});
|
|
2822
|
+
continue;
|
|
2823
|
+
}
|
|
2591
2824
|
addMeasuredPayloadText(slide,part.text,part.box,context,options,{
|
|
2592
2825
|
path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
|
|
2593
2826
|
color:part.role==='footer'?context.mutedColor:context.textColor,
|
|
2594
2827
|
quote:{group,part:index,anchor},
|
|
2595
2828
|
});
|
|
2596
2829
|
}
|
|
2830
|
+
if (layout.photo) await addQuotePhoto(slide, presentation, layout.photo, group, context, options);
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
// 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
|
|
2834
|
+
// are embedded) with the `ellipse` preset geometry, the alt text as its description, tagged into the quote's provenance group.
|
|
2835
|
+
async function addQuotePhoto(slide, presentation, photo, group, context, options) {
|
|
2836
|
+
const box = { x: photo.box.x / 96, y: photo.box.y / 96, w: photo.box.width / 96, h: photo.box.height / 96 };
|
|
2837
|
+
const outcome = { box, cover: true };
|
|
2838
|
+
const resolved = await resolveImage(photo.value, presentation, options, photo.path, outcome);
|
|
2839
|
+
if (!resolved) {
|
|
2840
|
+
addImagePlaceholder(slide, presentation, photo.value, box, photo.path, context, options);
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
const objectName = quotePhotoName(group);
|
|
2844
|
+
// 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).
|
|
2845
|
+
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.' });
|
|
2846
|
+
context.quotePhotos.set(objectName, { region: box, mode: 'crop', path: photo.path, shape: photo.shape });
|
|
2847
|
+
context.quoteTags.set(objectName, { v: 1, role: 'photo', group });
|
|
2848
|
+
const alt = assetAlt(photo.value, presentation);
|
|
2849
|
+
context.pictureText.set(objectName, pictureText(alt, photo.value, presentation));
|
|
2850
|
+
slide.addImage({ ...resolved, objectName, ...box, altText: alt });
|
|
2597
2851
|
}
|
|
2598
2852
|
|
|
2599
2853
|
function addTimelinePayload(slide, value, layout, context, options, path) {
|
|
2600
2854
|
if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
|
|
2601
2855
|
// Validate optional field fits before the provenance manifest dereferences
|
|
2602
2856
|
// 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,
|
|
2857
|
+
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
2858
|
const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
|
|
2605
2859
|
const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
|
|
2606
2860
|
const {x1,y1,x2,y2}=layout.connector;
|
|
2607
2861
|
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
2862
|
context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
|
|
2863
|
+
// FA-11: a status draws from the deck's colors through core's shared shapes: native ellipses (solid fill, or the
|
|
2864
|
+
// background fill plus a line for a hollow marker or a ring) and a muted text color for a planned event.
|
|
2865
|
+
const statusColors={background:`#${normalizeHex(context.colors.background)}`,primary:`#${context.colors.accent}`,text:`#${normalizeHex(context.colors.text)}`,mutedText:`#${context.colors.mutedText}`};
|
|
2609
2866
|
for(const marker of layout.markers){
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2867
|
+
if(!marker.status){
|
|
2868
|
+
const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
|
|
2869
|
+
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}});
|
|
2870
|
+
context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
|
|
2871
|
+
continue;
|
|
2872
|
+
}
|
|
2873
|
+
for(const shape of timelineMarkerShapes(marker,statusColors)){
|
|
2874
|
+
const objectName=`OPF timeline ${group} ${shape.role} ${marker.eventIndex}`;
|
|
2875
|
+
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,
|
|
2876
|
+
fill:{color:normalizeHex(shape.fill)},line:shape.stroke?{color:normalizeHex(shape.stroke.color),width:shape.stroke.width*.75}:{transparency:100}});
|
|
2877
|
+
context.timelineTags.set(objectName,{v:1,group,role:shape.role,eventIndex:marker.eventIndex,...(shape.role==='marker'?{status:marker.status}:{})});
|
|
2878
|
+
}
|
|
2613
2879
|
}
|
|
2614
2880
|
for(const [index,part]of layout.parts.entries()){
|
|
2615
|
-
|
|
2881
|
+
const statusColor=part.status==='planned'?normalizeHex(timelineTextColor(part,statusColors)):undefined;
|
|
2882
|
+
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
2883
|
}
|
|
2617
2884
|
}
|
|
2618
2885
|
|
|
@@ -2777,6 +3044,12 @@ function chosenFamilyMeasurement(measurement) {
|
|
|
2777
3044
|
return wrapped;
|
|
2778
3045
|
}
|
|
2779
3046
|
|
|
3047
|
+
// FA-13: TextRun.lang as the run's own proofing language (a:rPr lang), when it differs from the deck language.
|
|
3048
|
+
function langOptions(context, tag) {
|
|
3049
|
+
const lang = runLanguageTag(context.scriptFonts, tag);
|
|
3050
|
+
return lang ? {lang} : {};
|
|
3051
|
+
}
|
|
3052
|
+
|
|
2780
3053
|
function nativeFontOptions(style) {
|
|
2781
3054
|
const face=style.fontFace;
|
|
2782
3055
|
if(face!==undefined) {
|
|
@@ -2826,14 +3099,15 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2826
3099
|
options: {
|
|
2827
3100
|
bold: run?.bold,
|
|
2828
3101
|
italic: run?.italic,
|
|
2829
|
-
underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
|
|
3102
|
+
underline: run?.underline ? { style: "sng", color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context) } : undefined,
|
|
2830
3103
|
strike: run?.strikethrough ? "sngStrike" : undefined,
|
|
2831
|
-
color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
|
|
3104
|
+
color: linkColor(run, nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor), context),
|
|
2832
3105
|
fontFace: run?.fontFamily ?? context.fonts.body,
|
|
2833
3106
|
fontSize: run?.fontSize ?? fallbackFontSize,
|
|
2834
3107
|
superscript: run?.superscript,
|
|
2835
3108
|
subscript: !run?.superscript && run?.subscript,
|
|
2836
|
-
hyperlink: run?.link && /^(https?:|mailto:)/i.test(run.link) ? { url: run.link } : undefined
|
|
3109
|
+
hyperlink: run?.link && /^(https?:|mailto:|tel:)/i.test(run.link) ? { url: run.link } : undefined,
|
|
3110
|
+
...langOptions(context, run?.lang)
|
|
2837
3111
|
}
|
|
2838
3112
|
};
|
|
2839
3113
|
});
|
|
@@ -2860,7 +3134,7 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
2860
3134
|
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
3135
|
}
|
|
2862
3136
|
if (!authored || !Array.isArray(authored.columns) || !Array.isArray(authored.rows)) {
|
|
2863
|
-
return unplottable("
|
|
3137
|
+
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
3138
|
}
|
|
2865
3139
|
if (authored.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2866
3140
|
if (authored.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
@@ -2887,14 +3161,16 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
2887
3161
|
: 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
|
|
2888
3162
|
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
3163
|
}
|
|
2890
|
-
|
|
3164
|
+
// FA-14: the resolved table (category, X, series columns), which chart.highlight names its series and categories in.
|
|
3165
|
+
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,
|
|
3166
|
+
// FA-15: core's combo plan (column series, then the primary-axis lines, then the secondary-axis lines), aligned with the series.
|
|
3167
|
+
...(spec.family === 'combo' ? {combo: comboPlanOf(resolvedData, data.columns.length - 1)} : {})};
|
|
2891
3168
|
// Number formats only when a column has one: a chart without formats carries no `formats` and is written as before.
|
|
2892
3169
|
const withFormats = (result, formats) => formats.series.some((code) => code !== undefined) || formats.x !== undefined ? {...result, formats} : result;
|
|
2893
3170
|
if (data.columns.length === 1) {
|
|
2894
3171
|
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.
|
|
3172
|
+
// The lone column holds the chart's values. Each cell is read with chartNumber and each non-numeric cell is counted
|
|
3173
|
+
// as rejected. A cell that holds no number is skipped, never plotted as 0.
|
|
2898
3174
|
const points = data.rows.map((row, index) => ({row: index + 1, value: chartNumber(row?.[0]), cell: row?.[0]}));
|
|
2899
3175
|
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
3176
|
const plottedPoints = points.filter((point) => point.value !== null);
|
|
@@ -2964,6 +3240,12 @@ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
|
|
|
2964
3240
|
return withFormats({...mapped, series, hasCategories: true, adaptations, notNumeric}, formats);
|
|
2965
3241
|
}
|
|
2966
3242
|
|
|
3243
|
+
/** 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). */
|
|
3244
|
+
function comboPlanOf(resolved, count) {
|
|
3245
|
+
if (Array.isArray(resolved.combo) && resolved.combo.length === count) return resolved.combo;
|
|
3246
|
+
return Array.from({length: count}, (_, index) => ({role: count > 1 && index === count - 1 ? 'line' : 'bar', axis: 'primary'}));
|
|
3247
|
+
}
|
|
3248
|
+
|
|
2967
3249
|
/** A chart column's name: the string, or a DataColumn's `name`. */
|
|
2968
3250
|
function dataColumnName(column) {
|
|
2969
3251
|
return typeof column === 'string' ? column : column !== null && typeof column === 'object' && typeof column.name === 'string' ? column.name : undefined;
|
|
@@ -3193,23 +3475,6 @@ function assetAlt(asset, presentation) {
|
|
|
3193
3475
|
return undefined;
|
|
3194
3476
|
}
|
|
3195
3477
|
|
|
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
3478
|
function referenceId(reference) {
|
|
3214
3479
|
if (typeof reference === "string") return reference;
|
|
3215
3480
|
if (isPlainObject(reference) && typeof reference.id === "string") return reference.id;
|
|
@@ -3249,15 +3514,6 @@ function findById(records, id) {
|
|
|
3249
3514
|
return records.find((record) => record?.id === id) ?? null;
|
|
3250
3515
|
}
|
|
3251
3516
|
|
|
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
3517
|
// A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
|
|
3262
3518
|
// as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
|
|
3263
3519
|
function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
|
|
@@ -3269,29 +3525,18 @@ function resolveBackground(value, colorScheme, variables = {}) {
|
|
|
3269
3525
|
const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
|
|
3270
3526
|
if (typeof value === "string") {
|
|
3271
3527
|
if (value.startsWith("#")) return normalizeHex(value, fallback);
|
|
3272
|
-
return normalizeHex(colorScheme[value] ?? colorScheme
|
|
3528
|
+
return normalizeHex(colorScheme[value] ?? defaultSlideBackground(colorScheme), fallback);
|
|
3273
3529
|
}
|
|
3274
3530
|
if (isPlainObject(value)) {
|
|
3275
3531
|
if (value.type === "solid" && value.color) return reference(value.color);
|
|
3276
3532
|
if (value.type === "theme" && value.slot) {
|
|
3277
|
-
return normalizeHex(colorScheme[value.slot] ?? colorScheme
|
|
3533
|
+
return normalizeHex(colorScheme[value.slot] ?? defaultSlideBackground(colorScheme), fallback);
|
|
3278
3534
|
}
|
|
3279
3535
|
// Like the SVG preview, text contrast follows a pattern's background color.
|
|
3280
3536
|
if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
|
|
3281
3537
|
if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
|
|
3282
3538
|
}
|
|
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");
|
|
3539
|
+
return normalizeHex(defaultSlideBackground(colorScheme), fallback);
|
|
3295
3540
|
}
|
|
3296
3541
|
|
|
3297
3542
|
function normalizeHex(value, fallback = "000000") {
|
|
@@ -3306,15 +3551,6 @@ function normalizeHex(value, fallback = "000000") {
|
|
|
3306
3551
|
return fallback.replace(/^#/, "").toUpperCase().slice(0, 6);
|
|
3307
3552
|
}
|
|
3308
3553
|
|
|
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
3554
|
// The master, layout and notes master half of the native header/footer (RR-11): placeholders and p:hf on the finished parts.
|
|
3319
3555
|
// 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
3556
|
function writeNativeFurnitureMasters(output, context) {
|
|
@@ -3373,13 +3609,18 @@ async function normalizePptxZip(raw, context) {
|
|
|
3373
3609
|
const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
|
|
3374
3610
|
if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
|
|
3375
3611
|
entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
|
|
3612
|
+
const highlight=context.chartHeadings.get(name).highlight;
|
|
3613
|
+
if(highlight){
|
|
3614
|
+
try{entries[chartPart]=encodeText(applyChartHighlight(decodeText(entries[chartPart]),highlight));}
|
|
3615
|
+
catch(error){throw new OPFPptxError('packaging-failed',`Chart highlight could not be written: ${errorMessage(error)}`);}
|
|
3616
|
+
}
|
|
3376
3617
|
// PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
|
|
3377
3618
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
3378
3619
|
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
3620
|
entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
|
|
3380
3621
|
// RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
|
|
3381
3622
|
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}));
|
|
3623
|
+
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
3624
|
// RR-54: the columns' number formats, after the labels: caches, label and value-axis formats, then the workbook cells.
|
|
3384
3625
|
const numberFormats=context.chartHeadings.get(name).numberFormats;
|
|
3385
3626
|
if(numberFormats){
|
|
@@ -3390,6 +3631,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
3390
3631
|
}
|
|
3391
3632
|
}
|
|
3392
3633
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
3634
|
+
if (context.chartAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyChartAlt(xml, context.chartAlts); if (next !== xml) entries[part] = encodeText(next); }
|
|
3393
3635
|
// Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
|
|
3394
3636
|
try {
|
|
3395
3637
|
attachChartexParts(entries, context.chartex, parseRelationships);
|
|
@@ -3408,7 +3650,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
3408
3650
|
const relationships = parseRelationships(entries, part);
|
|
3409
3651
|
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
3410
3652
|
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
3411
|
-
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
|
|
3653
|
+
const placement = context.imagePlacements.get(name) ?? context.quotePhotos.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
|
|
3412
3654
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
3413
3655
|
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
3414
3656
|
}
|
|
@@ -3442,6 +3684,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
3442
3684
|
placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
3443
3685
|
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
3444
3686
|
});
|
|
3687
|
+
tagTextWatermarks(entries, context.textWatermarks);
|
|
3445
3688
|
placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
3446
3689
|
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
3690
|
});
|
|
@@ -3685,7 +3928,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3685
3928
|
}
|
|
3686
3929
|
const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
|
|
3687
3930
|
if (text) picture = writePictureText(picture, text);
|
|
3688
|
-
const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
|
|
3931
|
+
const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]) ?? context.quotePhotos.get(picture.match(/name="(OPF quote photo \d+)"/)?.[1]);
|
|
3689
3932
|
if (!placement) return picture;
|
|
3690
3933
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
3691
3934
|
const dimensions = imageMetadata.get(relationships.get(id)?.path);
|
|
@@ -3698,6 +3941,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3698
3941
|
const attrs = Object.entries(fitted.crop).map(([key, value]) => `${key}="${value}"`).join(' ');
|
|
3699
3942
|
picture = picture.replace('<a:stretch>', `<a:srcRect ${attrs}/><a:stretch>`);
|
|
3700
3943
|
}
|
|
3944
|
+
// The circular headshot mask: the core frame's DrawingML preset (ellipse) instead of the rectangle.
|
|
3945
|
+
if (placement.shape) picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, `<a:prstGeom prst="${placement.shape.preset}"><a:avLst/></a:prstGeom>`);
|
|
3701
3946
|
return picture;
|
|
3702
3947
|
});
|
|
3703
3948
|
// Native bullets otherwise inherit the first rich run's size, font and
|
|
@@ -3733,6 +3978,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
3733
3978
|
if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
|
|
3734
3979
|
else if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) xml = themeEastAsianFromLatin(xml);
|
|
3735
3980
|
if (/^ppt\/(?:slides\/slide|notesSlides\/notesSlide)\d+\.xml$/.test(path)) xml = stripRunScriptFonts(xml);
|
|
3981
|
+
if (context.scriptFonts && /^ppt\/slides\/slide\d+\.xml$/.test(path)) xml = runLanguageFonts(xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
|
|
3736
3982
|
return encodeText(normalizePartReferences(xml, renameMaps));
|
|
3737
3983
|
}
|
|
3738
3984
|
return bytes;
|