@openpresentation/opf-pptx 0.9.1 → 0.11.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 +108 -17
- package/dist/background-import.js +59 -3
- package/dist/background.js +86 -1
- package/dist/body-text-import.js +86 -0
- package/dist/code-provenance.js +9 -3
- package/dist/document-provenance.js +998 -0
- package/dist/font-weights.js +63 -0
- package/dist/furniture-fields.js +176 -0
- package/dist/furniture-provenance.js +138 -18
- package/dist/image-geometry.js +77 -5
- package/dist/index.d.ts +112 -2
- package/dist/index.js +873 -110
- package/dist/media-provenance.js +237 -0
- package/dist/native-text-style.js +71 -0
- package/dist/package-fonts.js +161 -0
- package/dist/script-fonts.js +290 -0
- package/dist/slide-image-provenance.js +169 -0
- package/dist/table-import.js +1 -53
- package/dist/theme-colors.js +204 -0
- package/dist/typeface-inventory.js +227 -0
- package/package.json +15 -7
package/dist/index.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
|
+
import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
|
|
2
|
+
import {isFaceStyleSuffix} from './font-weights.js';
|
|
1
3
|
import {importTableFrames} from './table-import.js';
|
|
4
|
+
import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
|
|
2
5
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
3
6
|
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
4
7
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
5
8
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
9
|
+
import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
|
|
6
10
|
import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
7
11
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
8
12
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
9
|
-
import {attachFurnitureTags, furnitureManifest, importFurniture} from './furniture-provenance.js';
|
|
13
|
+
import {attachFurnitureTags, furnitureManifest, importFurniture, staticDateFallback} from './furniture-provenance.js';
|
|
14
|
+
import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, restoreDocumentProvenance} from './document-provenance.js';
|
|
15
|
+
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
10
16
|
import {importImageOrientation} from './image-import.js';
|
|
11
|
-
import {
|
|
17
|
+
import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
|
|
18
|
+
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
12
19
|
import {importBackground} from './background-import.js';
|
|
20
|
+
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
21
|
+
import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
13
22
|
import { webpToPng } from '#image-fallback';
|
|
14
23
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
15
24
|
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
@@ -22,6 +31,8 @@ import {
|
|
|
22
31
|
validatePresentation
|
|
23
32
|
} from "@openpresentation/opf";
|
|
24
33
|
|
|
34
|
+
export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
|
|
35
|
+
|
|
25
36
|
export const packageName = "@openpresentation/opf-pptx";
|
|
26
37
|
|
|
27
38
|
export const releaseLane = Object.freeze({
|
|
@@ -57,6 +68,9 @@ const FIXED_TIMESTAMP = "1980-01-01T00:00:00Z";
|
|
|
57
68
|
// local components: a UTC instant rolls back to 1979 west of UTC (below
|
|
58
69
|
// fflate's 1980 floor) and would otherwise yield timezone-dependent bytes.
|
|
59
70
|
const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0);
|
|
71
|
+
// Explicit stamps overwrite these fields after ZIP generation. Keep the
|
|
72
|
+
// temporary instant well inside fflate's range even if the host changes TZ.
|
|
73
|
+
const EXPLICIT_ZIP_SENTINEL = new Date('2000-01-01T12:00:00Z');
|
|
60
74
|
const DEFAULT_SEED = 0x4f504658;
|
|
61
75
|
const CANONICAL_SCHEMA = "https://openpresentation.org/schema/opf/v1";
|
|
62
76
|
const EMUS_PER_INCH = 914400;
|
|
@@ -69,6 +83,12 @@ const xmlParser = new XMLParser({
|
|
|
69
83
|
parseTagValue: false,
|
|
70
84
|
trimValues: false
|
|
71
85
|
});
|
|
86
|
+
// Keep numeric character references (notably CR) in native core text. This is
|
|
87
|
+
// scoped to properties; the ordinary slide/relationship parser is unchanged.
|
|
88
|
+
const coreTextParser = new XMLParser({
|
|
89
|
+
ignoreAttributes: false, attributeNamePrefix: "", textNodeName: "#text",
|
|
90
|
+
parseAttributeValue: false, parseTagValue: false, trimValues: false, htmlEntities: true
|
|
91
|
+
});
|
|
72
92
|
|
|
73
93
|
const ROOT_PAYLOAD_FIELDS = [
|
|
74
94
|
"text",
|
|
@@ -148,6 +168,7 @@ const DIMENSION_PRESETS = Object.freeze({
|
|
|
148
168
|
const DEFAULTS = Object.freeze({
|
|
149
169
|
theme: "minimal",
|
|
150
170
|
colorScheme: "cool-horizon",
|
|
171
|
+
// Shared last-resort font scheme (core DEFAULT_FONT_SCHEME): every engine uses aptos.
|
|
151
172
|
fontScheme: "aptos"
|
|
152
173
|
});
|
|
153
174
|
|
|
@@ -170,25 +191,45 @@ export async function toPptx(input, options = {}) {
|
|
|
170
191
|
if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
|
|
171
192
|
throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
|
|
172
193
|
}
|
|
194
|
+
if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
|
|
195
|
+
throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
|
|
196
|
+
}
|
|
173
197
|
const presentation = parseInput(input);
|
|
174
198
|
assertValidBoundary(presentation);
|
|
199
|
+
options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement)};
|
|
175
200
|
|
|
176
201
|
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
177
202
|
context.listMarkers = new Map();
|
|
203
|
+
context.imagePlaceholders = new Map();
|
|
178
204
|
context.tableHeaders = new Map();
|
|
179
205
|
context.tableCells = new Map();
|
|
180
206
|
context.imagePlacements = new Map();
|
|
207
|
+
context.slideImages = new Map();
|
|
181
208
|
context.backgroundFills = new Map();
|
|
209
|
+
context.notesWithCarriageReturns = new Map();
|
|
182
210
|
context.cardTags = new Map();
|
|
211
|
+
context.mediaTags = new Map();
|
|
212
|
+
context.provenanceMode = options.provenance ?? "full";
|
|
183
213
|
context.headingTags = new Map();
|
|
184
214
|
context.plainTextTags = new Map();
|
|
185
215
|
context.timelineTags = new Map();
|
|
186
216
|
context.furnitureTags = new Map();
|
|
217
|
+
context.furnitureFields = new Map();
|
|
187
218
|
context.furnitureManifests = new Map();
|
|
188
219
|
context.codeTags = new Map();
|
|
189
220
|
context.metricTags = new Map();
|
|
190
221
|
context.chartHeadings = new Map();
|
|
222
|
+
context.chartFonts = new Map();
|
|
191
223
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
224
|
+
Object.assign(context, exportTheme(presentation, context));
|
|
225
|
+
context.reportedFontSchemes = new Set();
|
|
226
|
+
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
227
|
+
context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
|
|
228
|
+
mode: options.provenance ?? "full",
|
|
229
|
+
isCatalogId: (kind, id) => !!(findById(normalizeRecords(presentation.catalogs?.[kind]), id) ?? findById(defaultCatalog(kind), id)),
|
|
230
|
+
report: diagnostic => options.onDiagnostic?.(diagnostic)
|
|
231
|
+
});
|
|
232
|
+
context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
|
|
192
233
|
const pptx = new PptxGenJS();
|
|
193
234
|
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
194
235
|
|
|
@@ -196,6 +237,10 @@ export async function toPptx(input, options = {}) {
|
|
|
196
237
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
197
238
|
}
|
|
198
239
|
|
|
240
|
+
// FF-08: pitchFamily per exported family, from each slide's resolved scheme.
|
|
241
|
+
context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => resolveSlideContext(presentation, slide, context, options, index).fonts),
|
|
242
|
+
[...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
|
|
243
|
+
|
|
199
244
|
let raw;
|
|
200
245
|
try {
|
|
201
246
|
raw = await withDeterministicRandom(context.seed, () => pptx.write({
|
|
@@ -227,15 +272,24 @@ export async function fromPptx(input, options = {}) {
|
|
|
227
272
|
|
|
228
273
|
const core = readCoreProperties(entries);
|
|
229
274
|
const dimensions = dimensionsFromPresentation(presentationRoot);
|
|
230
|
-
|
|
275
|
+
let imported = {
|
|
231
276
|
$schema: options.schema ?? CANONICAL_SCHEMA,
|
|
232
277
|
name: core.title || options.fallbackName || "Imported PPTX",
|
|
233
278
|
slides: []
|
|
234
279
|
};
|
|
235
280
|
|
|
281
|
+
// FF-07: the language the runs carry. A stored FF-32 reference can still win below.
|
|
282
|
+
const observedLanguage = observeLanguage({
|
|
283
|
+
slides: slidePaths.map(path => decodeText(entries[path])),
|
|
284
|
+
theme: (path => path && entries[path] ? decodeText(entries[path]) : null)(presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries)),
|
|
285
|
+
catalogs: bundledCatalogs
|
|
286
|
+
});
|
|
287
|
+
if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
|
|
236
288
|
if (core.description) imported.description = core.description;
|
|
237
289
|
if (core.author) imported.author = core.author;
|
|
238
|
-
|
|
290
|
+
const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
|
|
291
|
+
const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
|
|
292
|
+
if (Object.keys(design).length) imported.design = design;
|
|
239
293
|
|
|
240
294
|
const furnitureContexts = slidePaths.map((slidePath, index) => {
|
|
241
295
|
const root = parseRequiredXml(entries, slidePath)['p:sld'];
|
|
@@ -250,9 +304,42 @@ export async function fromPptx(input, options = {}) {
|
|
|
250
304
|
if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
|
|
251
305
|
if (furniture.organization) imported.organization = furniture.organization;
|
|
252
306
|
|
|
307
|
+
const mediaRegistry = Object.create(null);
|
|
253
308
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
309
|
+
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
254
310
|
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
255
311
|
}
|
|
312
|
+
// Conflicting media asset IDs fall back to their current native URLs during import.
|
|
313
|
+
for (const context of furnitureContexts) for (const [id, asset] of Object.entries(context.mediaAssets ?? {})) if (!Object.hasOwn(imported.assets ?? {}, id)) imported.assets = {...imported.assets, [id]: asset};
|
|
314
|
+
// Report after the slides so slide diagnostics keep their established order.
|
|
315
|
+
// A theme name FF-24 could not verify is not reported when the stored reference restores the theme.
|
|
316
|
+
for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code !== "theme-unverified") options.onDiagnostic?.(diagnostic);
|
|
317
|
+
|
|
318
|
+
// Catalog references, layout ids and authoring metadata recorded at export
|
|
319
|
+
// (FF-32). Stored references win while the package still matches them;
|
|
320
|
+
// otherwise the observed values (including FF-24's theme recovery) stay and
|
|
321
|
+
// a diagnostic names the reference. A restored field that does not validate
|
|
322
|
+
// is dropped on its own.
|
|
323
|
+
const report = diagnostic => options.onDiagnostic?.(diagnostic);
|
|
324
|
+
// Per slide: {layout, structure: 'match' | 'changed' | 'untagged', record, catalogRecord}.
|
|
325
|
+
// Layout-structure recovery (FF-29) reads the stored OPF_SLIDE_V1 record from
|
|
326
|
+
// here instead of writing a second slide tag.
|
|
327
|
+
let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
|
|
328
|
+
try {
|
|
329
|
+
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
|
|
330
|
+
// Host catalogs format stored socials exactly as export did (FF-34).
|
|
331
|
+
socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
|
|
332
|
+
slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships}))}, report);
|
|
333
|
+
// The stored language wins while the runs still carry its tag (FF-07).
|
|
334
|
+
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
335
|
+
imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
|
|
336
|
+
slideProvenance = restored.slides;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
339
|
+
}
|
|
340
|
+
if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
|
|
341
|
+
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
342
|
+
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
256
343
|
|
|
257
344
|
const result = validatePresentation(imported);
|
|
258
345
|
if (!result.valid) {
|
|
@@ -364,7 +451,7 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
|
|
|
364
451
|
}
|
|
365
452
|
|
|
366
453
|
function compareSlidePaths(left, right) {
|
|
367
|
-
return slideNumber(left) - slideNumber(right) || left
|
|
454
|
+
return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
|
|
368
455
|
}
|
|
369
456
|
|
|
370
457
|
function slideNumber(path) {
|
|
@@ -372,12 +459,12 @@ function slideNumber(path) {
|
|
|
372
459
|
}
|
|
373
460
|
|
|
374
461
|
function readCoreProperties(entries) {
|
|
375
|
-
const doc =
|
|
462
|
+
const doc = entries["docProps/core.xml"] ? parseRequiredXml(entries, "docProps/core.xml", coreTextParser) : null;
|
|
376
463
|
const core = doc?.["cp:coreProperties"] ?? {};
|
|
377
464
|
return {
|
|
378
|
-
title: scalarText(core["dc:title"])
|
|
379
|
-
description: scalarText(core["dc:description"] ?? core["dc:subject"])
|
|
380
|
-
author: scalarText(core["dc:creator"])
|
|
465
|
+
title: scalarText(core["dc:title"]),
|
|
466
|
+
description: scalarText(core["dc:description"] ?? core["dc:subject"]),
|
|
467
|
+
author: scalarText(core["dc:creator"])
|
|
381
468
|
};
|
|
382
469
|
}
|
|
383
470
|
|
|
@@ -405,6 +492,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
405
492
|
if (background) slide.design = {background};
|
|
406
493
|
if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
|
|
407
494
|
if (furniture.section !== undefined) slide.section = furniture.section;
|
|
495
|
+
const slideImagePath = `slides.${slideIndex}.design.slideImage`;
|
|
496
|
+
const slideImage = importSlideImage(nativeContext.pictures, nativeContext.shapes, relationships, entries, slideIndex,
|
|
497
|
+
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
498
|
+
// The recorded crop/fit is re-derived from design.slideImage.
|
|
499
|
+
if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: slideImagePath});
|
|
500
|
+
}),
|
|
501
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
|
|
502
|
+
if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
|
|
503
|
+
nativeContext.slideImagePictures = slideImage.consumed;
|
|
504
|
+
nativeContext.slideImageShapes = slideImage.consumedShapes;
|
|
408
505
|
|
|
409
506
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
|
|
410
507
|
.sort(comparePositionedItems);
|
|
@@ -419,6 +516,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
419
516
|
const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
|
|
420
517
|
if (subtitleItem) slide.subtitle = subtitleItem.text;
|
|
421
518
|
|
|
519
|
+
// Heading inference keeps its existing scalar/native metrics. Only remaining
|
|
520
|
+
// ordinary body shapes gain current rich values; tagged recovery is separate.
|
|
521
|
+
for (const item of items) if (item.readNativeBody) {
|
|
522
|
+
const current = item.readNativeBody();
|
|
523
|
+
if (current) {
|
|
524
|
+
item.paragraphs = current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph}));
|
|
525
|
+
item.text = current.map(paragraph => paragraph.text).join('\n');
|
|
526
|
+
}
|
|
527
|
+
}
|
|
422
528
|
const blocks = mergeAdjacentBulletShapes(items)
|
|
423
529
|
.map((item) => payloadFromSlideItem(item))
|
|
424
530
|
.filter(Boolean);
|
|
@@ -438,9 +544,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
438
544
|
const shapes = nativeContext.shapes;
|
|
439
545
|
if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
|
|
440
546
|
const paragraphs = nativeContext.paragraphs;
|
|
547
|
+
const readBody = nativeBodyReader(slidePath, {
|
|
548
|
+
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
549
|
+
}, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
|
|
441
550
|
const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
|
|
442
551
|
const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
|
|
443
552
|
const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
553
|
+
const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
|
|
554
|
+
nativeContext.mediaAssets = media.assets;
|
|
444
555
|
const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
445
556
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
446
557
|
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
@@ -459,13 +570,16 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
459
570
|
}
|
|
460
571
|
for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
461
572
|
for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
573
|
+
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});
|
|
462
574
|
for (const [index,shape] of shapes.entries()) {
|
|
463
|
-
if (furniture.text.has(index)) continue;
|
|
464
|
-
if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)) continue;
|
|
465
|
-
const
|
|
575
|
+
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index)) continue;
|
|
576
|
+
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)) continue;
|
|
577
|
+
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
578
|
+
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
579
|
+
if (item && ordinaryBody) item.readNativeBody = () => readBody(index);
|
|
466
580
|
// A damaged/edited furniture group falls back to current native text,
|
|
467
581
|
// including cleared text boxes, without inventing a title or shape label.
|
|
468
|
-
if (item && furniture.taggedText.has(index)) item.sourceText = true;
|
|
582
|
+
if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
|
|
469
583
|
if (item) items.push(item);
|
|
470
584
|
}
|
|
471
585
|
|
|
@@ -480,7 +594,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
480
594
|
}
|
|
481
595
|
|
|
482
596
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
483
|
-
if (furniture.pictures.has(index)) continue;
|
|
597
|
+
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index)) continue;
|
|
484
598
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
485
599
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
486
600
|
if (item) items.push(item);
|
|
@@ -691,12 +805,12 @@ function payloadFromSlideItem(item) {
|
|
|
691
805
|
type: "list",
|
|
692
806
|
items: item.paragraphs.map((paragraph) => (
|
|
693
807
|
paragraph.level > 0
|
|
694
|
-
? { text: paragraph.text, level: paragraph.level }
|
|
695
|
-
: paragraph.text
|
|
808
|
+
? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
|
|
809
|
+
: (paragraph.richText ?? paragraph.text)
|
|
696
810
|
))
|
|
697
811
|
};
|
|
698
812
|
}
|
|
699
|
-
return { type: "text", text: item.
|
|
813
|
+
return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
|
|
700
814
|
}
|
|
701
815
|
if (item.kind === "unknown" && item.text) return { type: "text", text: item.text };
|
|
702
816
|
return null;
|
|
@@ -713,18 +827,29 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
713
827
|
if (!chartNode) return null;
|
|
714
828
|
const series = asArray(chartNode.node["c:ser"]);
|
|
715
829
|
if (series.length === 0) return null;
|
|
830
|
+
if (series.length > MAX_CHART_CACHE_POINTS) {
|
|
831
|
+
throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 100,000-series import limit; reduce its series before importing.', {path: relationship.path});
|
|
832
|
+
}
|
|
716
833
|
|
|
717
|
-
const
|
|
718
|
-
const
|
|
719
|
-
const
|
|
720
|
-
const
|
|
834
|
+
const budget = { cells: 0 };
|
|
835
|
+
const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
|
|
836
|
+
const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
|
|
837
|
+
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
|
|
838
|
+
const values = series.map((entry, index) => {
|
|
839
|
+
const role = entry?.["c:val"] !== undefined ? 'c:val' : 'c:yVal';
|
|
840
|
+
return cachedValues(entry?.[role], cachePath(index, role), budget, numericCacheValue);
|
|
841
|
+
});
|
|
842
|
+
const rowCount = values.reduce((count, row) => Math.max(count, row.length), labels.length);
|
|
721
843
|
if (rowCount === 0) return null;
|
|
844
|
+
if (rowCount * (series.length + 1) > MAX_CHART_CACHE_CELLS) {
|
|
845
|
+
throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 1,000,000-cell import limit; reduce its rows or series before importing.', {path: relationship.path});
|
|
846
|
+
}
|
|
722
847
|
|
|
723
848
|
const rows = [];
|
|
724
849
|
for (let index = 0; index < rowCount; index += 1) {
|
|
725
850
|
rows.push([
|
|
726
|
-
labels[index] ??
|
|
727
|
-
...values.map((row) => row[index] ??
|
|
851
|
+
labels[index] ?? null,
|
|
852
|
+
...values.map((row) => row[index] ?? null)
|
|
728
853
|
]);
|
|
729
854
|
}
|
|
730
855
|
|
|
@@ -754,39 +879,102 @@ function firstChartNode(plotArea) {
|
|
|
754
879
|
return null;
|
|
755
880
|
}
|
|
756
881
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
882
|
+
const MAX_CHART_CACHE_POINTS = 100_000;
|
|
883
|
+
const MAX_CHART_CACHE_CELLS = 1_000_000;
|
|
884
|
+
|
|
885
|
+
// Import complete decimal exponent tokens without the exporter's legacy
|
|
886
|
+
// character stripping. Other numeric strings deliberately retain that policy.
|
|
887
|
+
function numericCacheValue(value, path) {
|
|
888
|
+
if (value === null || value.trim() === '') return null;
|
|
889
|
+
const token = value.trim();
|
|
890
|
+
const exponent = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))[eE][+-]?\d+$/.exec(token);
|
|
891
|
+
if (!exponent) return numericValue(value);
|
|
892
|
+
const parsed = Number(token);
|
|
893
|
+
if (!Number.isFinite(parsed) || (parsed === 0 && /[1-9]/.test(exponent[1]))) {
|
|
894
|
+
const reason = Number.isFinite(parsed) ? 'underflows to zero' : 'overflows';
|
|
895
|
+
throw new OPFPptxError('unsupported-chart-cache', `Scientific-notation chart value is outside the supported finite numeric range (${reason}); use a representable value before importing.`, {path});
|
|
896
|
+
}
|
|
897
|
+
return parsed;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
// c:pt is sparse and its XML order does not determine the data row. Keep
|
|
901
|
+
// explicit empty strings distinct from missing points, and bound native
|
|
902
|
+
// metadata before allocating a dense OPF table. Only numeric roles convert values.
|
|
903
|
+
function cachedValues(node, path, budget, readValue) {
|
|
904
|
+
const invalid = (message, suffix = '') => {
|
|
905
|
+
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: path + suffix});
|
|
906
|
+
};
|
|
907
|
+
const single = (value, location) => {
|
|
908
|
+
if (value !== undefined && value !== '' && (!value || typeof value !== 'object' || Array.isArray(value))) {
|
|
909
|
+
invalid('cache container must be a single element', location);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
single(node, '');
|
|
913
|
+
for (const reference of ['c:strRef', 'c:numRef', 'c:multiLvlStrRef']) single(node?.[reference], `/${reference}`);
|
|
914
|
+
const candidates = [
|
|
915
|
+
['c:strRef/c:strCache', node?.['c:strRef']?.['c:strCache']],
|
|
916
|
+
['c:numRef/c:numCache', node?.['c:numRef']?.['c:numCache']],
|
|
917
|
+
['c:multiLvlStrRef/c:multiLvlStrCache', node?.['c:multiLvlStrRef']?.['c:multiLvlStrCache']],
|
|
918
|
+
['c:numLit', node?.['c:numLit']],
|
|
919
|
+
['c:strLit', node?.['c:strLit']],
|
|
920
|
+
].filter(([, cache]) => cache !== undefined);
|
|
921
|
+
if (candidates.length === 0) return [];
|
|
922
|
+
if (candidates.length > 1) invalid('multiple competing cache sources');
|
|
923
|
+
const [kind, cache] = candidates[0];
|
|
924
|
+
path += `/${kind}`;
|
|
925
|
+
if (cache !== '' && (!cache || typeof cache !== 'object' || Array.isArray(cache))) invalid('cache must be a single element');
|
|
926
|
+
const integer = (raw, limit, location) => {
|
|
927
|
+
// XML Schema unsigned integers allow an optional sign and whitespace;
|
|
928
|
+
// their value must still be nonnegative, integral and bounded.
|
|
929
|
+
if (typeof raw !== 'string' || !/^[+-]?\d+$/.test(raw.trim())) invalid('expected an unsigned integer', location);
|
|
930
|
+
const value = Number(raw.trim());
|
|
931
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > limit) invalid(`integer exceeds the supported range 0–${limit}`, location);
|
|
932
|
+
return value;
|
|
933
|
+
};
|
|
934
|
+
const count = cache?.['c:ptCount'] === undefined ? undefined
|
|
935
|
+
: integer(cache['c:ptCount']?.val, MAX_CHART_CACHE_POINTS, '/c:ptCount@val');
|
|
936
|
+
let data = cache;
|
|
937
|
+
if (kind.startsWith('c:multiLvlStrRef')) {
|
|
938
|
+
const levels = asArray(cache?.['c:lvl']);
|
|
939
|
+
if (levels.length > 1) {
|
|
940
|
+
throw new OPFPptxError('unsupported-chart-cache', 'Hierarchical chart category caches cannot be flattened without losing labels; use a single category level before importing.', {path});
|
|
941
|
+
}
|
|
942
|
+
data = levels[0];
|
|
943
|
+
}
|
|
944
|
+
const points = asArray(data?.['c:pt']);
|
|
945
|
+
if (points.length > MAX_CHART_CACHE_POINTS) invalid('too many points');
|
|
946
|
+
const indexed = new Map();
|
|
947
|
+
let extent = count ?? 0;
|
|
948
|
+
for (const [position, point] of points.entries()) {
|
|
949
|
+
const location = `/c:pt[${position}]@idx`;
|
|
950
|
+
const index = integer(point?.idx, MAX_CHART_CACHE_POINTS - 1, location);
|
|
951
|
+
if (count !== undefined && index >= count) invalid('point index is outside its declared count', location);
|
|
952
|
+
if (indexed.has(index)) invalid('duplicate point index', location);
|
|
953
|
+
if (Array.isArray(point?.['c:v'])) invalid('point has multiple values', `/c:pt[${position}]/c:v`);
|
|
954
|
+
indexed.set(index, point?.['c:v'] === undefined ? null : scalarText(point['c:v']));
|
|
955
|
+
extent = Math.max(extent, index + 1);
|
|
956
|
+
}
|
|
957
|
+
if (budget.cells + extent > MAX_CHART_CACHE_CELLS) invalid('combined caches exceed the 1,000,000-cell import limit');
|
|
958
|
+
budget.cells += extent;
|
|
959
|
+
const result = new Array(extent).fill(null);
|
|
960
|
+
for (const [index, value] of indexed) {
|
|
961
|
+
result[index] = readValue ? readValue(value, `${path}/c:pt[@idx="${index}"]/c:v`) : value;
|
|
962
|
+
}
|
|
963
|
+
return result;
|
|
769
964
|
}
|
|
770
965
|
|
|
771
|
-
function firstCachedValue(node) {
|
|
772
|
-
return cachedValues(node).find(
|
|
966
|
+
function firstCachedValue(node, path, budget) {
|
|
967
|
+
return cachedValues(node, path, budget).find(value => value !== null);
|
|
773
968
|
}
|
|
774
969
|
|
|
775
970
|
function readSlideNotes(entries, relationships) {
|
|
776
971
|
const notesRel = [...relationships.values()].find((relationship) => relationship.type.endsWith("/notesSlide"));
|
|
777
972
|
if (!notesRel?.path || !entries[notesRel.path]) return "";
|
|
778
973
|
const doc = parseRequiredXml(entries, notesRel.path);
|
|
779
|
-
const shapes =
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
.map(
|
|
783
|
-
.filter(Boolean);
|
|
784
|
-
return bodyNotes.join("\n").trim();
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
function textFromTextBody(txBody) {
|
|
789
|
-
return readParagraphs(txBody).map((paragraph) => paragraph.text).filter(Boolean).join("\n").trim();
|
|
974
|
+
const shapes = nativeTextShapes(doc["p:notes"]?.["p:cSld"]?.["p:spTree"]);
|
|
975
|
+
const paragraphs = nativeShapeParagraphs(decodeText(entries[notesRel.path]), 'p:notes');
|
|
976
|
+
return shapes.flatMap((shape, index) => shapePlaceholderType(shape) === "body"
|
|
977
|
+
? paragraphs[index].map(paragraph => paragraph.text) : []).join("\n");
|
|
790
978
|
}
|
|
791
979
|
|
|
792
980
|
function comparePositionedItems(left, right) {
|
|
@@ -885,6 +1073,10 @@ function resolvePresentationContext(presentation, options) {
|
|
|
885
1073
|
design.fontScheme ?? theme?.fontScheme,
|
|
886
1074
|
DEFAULTS.fontScheme
|
|
887
1075
|
);
|
|
1076
|
+
// Shared rule (core resolveFontSchemeReference): an unresolved id falls back to the
|
|
1077
|
+
// DEFAULTS.fontScheme record as the base; resolveSlideContext reports it once per path.
|
|
1078
|
+
const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
|
|
1079
|
+
const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
|
|
888
1080
|
const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
|
|
889
1081
|
const background = resolveBackground(design.background ?? theme?.background, colorScheme);
|
|
890
1082
|
const fonts = resolveFonts(fontScheme);
|
|
@@ -895,13 +1087,15 @@ function resolvePresentationContext(presentation, options) {
|
|
|
895
1087
|
return {
|
|
896
1088
|
seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
|
|
897
1089
|
timestamp: options.timestamp ?? FIXED_TIMESTAMP,
|
|
898
|
-
zipDate: options.zipDate ?
|
|
1090
|
+
zipDate: options.zipDate === undefined ? FIXED_ZIP_DATE : EXPLICIT_ZIP_SENTINEL,
|
|
1091
|
+
zipDateStamp: resolveZipDateStamp(options.zipDate),
|
|
899
1092
|
compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
|
|
900
1093
|
layoutName: "OPF_CANVAS",
|
|
901
1094
|
dimensions,
|
|
902
1095
|
colorScheme,
|
|
903
1096
|
backgroundDefinition: design.background ?? theme?.background,
|
|
904
1097
|
fonts,
|
|
1098
|
+
unresolvedFontScheme,
|
|
905
1099
|
colors: {
|
|
906
1100
|
background,
|
|
907
1101
|
text: textColor,
|
|
@@ -918,6 +1112,55 @@ function exportColor(entry, context, fallback) {
|
|
|
918
1112
|
return resolveExportColor(entry, colorContext(context, fallback));
|
|
919
1113
|
}
|
|
920
1114
|
|
|
1115
|
+
// PptxGenJS color for a document color reference: a theme scheme value when the
|
|
1116
|
+
// reference names a scheme slot or role that the deck theme holds exactly,
|
|
1117
|
+
// otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
|
|
1118
|
+
function nativeColor(reference, hex, context) {
|
|
1119
|
+
return schemeColorValue(reference, hex, context, {vendor: true}) ?? normalizeHex(hex);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// The theme part carries the deck-level scheme (slide overrides stay literal)
|
|
1123
|
+
// and, for an explicit catalog theme, that theme's name.
|
|
1124
|
+
function exportTheme(presentation, context) {
|
|
1125
|
+
const reference = presentation.design?.theme;
|
|
1126
|
+
const id = referenceId(reference);
|
|
1127
|
+
const theme = id ? resolveDesignRecord(presentation, "themes", reference, DEFAULTS.theme) : null;
|
|
1128
|
+
const scheme = context.colorScheme;
|
|
1129
|
+
return {
|
|
1130
|
+
themeColors: themeSlotColors(scheme),
|
|
1131
|
+
schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
|
|
1132
|
+
themeName: theme?.id === id ? theme.name ?? theme.id : undefined
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function importThemeDesign(entries, presentationRoot, presentationRels) {
|
|
1137
|
+
const diagnostics = [], design = {};
|
|
1138
|
+
const report = (code, path, message) => diagnostics.push({code, path, message});
|
|
1139
|
+
const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
|
|
1140
|
+
const theme = themePath ? parseOptionalXml(entries, themePath)?.["a:theme"] : undefined;
|
|
1141
|
+
const elements = theme?.["a:themeElements"];
|
|
1142
|
+
const clrScheme = elements?.["a:clrScheme"];
|
|
1143
|
+
if (!clrScheme || Array.isArray(clrScheme)) {
|
|
1144
|
+
report("unsupported-theme-colors", "design.colorScheme", "The PPTX has no readable theme color scheme, so design.colorScheme was not imported.");
|
|
1145
|
+
return {design, diagnostics};
|
|
1146
|
+
}
|
|
1147
|
+
const {colors, unreadable} = readThemeSlotColors(clrScheme);
|
|
1148
|
+
const records = defaultCatalog("colorSchemes");
|
|
1149
|
+
if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
|
|
1150
|
+
if (unreadable.length) report("unsupported-theme-colors", "design.colorScheme", `Theme color slot(s) ${unreadable.join(", ")} do not resolve to an opaque sRGB color and were not imported.`);
|
|
1151
|
+
const latin = font => scalarText(elements?.["a:fontScheme"]?.[font]?.["a:latin"]?.typeface);
|
|
1152
|
+
const recoveredTheme = recoverTheme({themeName: scalarText(theme?.name), colors, majorFont: latin("a:majorFont"), minorFont: latin("a:minorFont")}, {
|
|
1153
|
+
themes: defaultCatalog("themes"),
|
|
1154
|
+
colorSchemes: records,
|
|
1155
|
+
fontFamilies: id => {
|
|
1156
|
+
const record = findById(defaultCatalog("fontSchemes"), id);
|
|
1157
|
+
return record ? resolveFontFamilies(record) : null;
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
1160
|
+
if (recoveredTheme.unverified) report("theme-unverified", "design.theme", `The native theme has the name of OPF catalog theme '${recoveredTheme.unverified}', but neither its color scheme nor its heading/body fonts match that theme, so design.theme was not set.`);
|
|
1161
|
+
return {design: recoveredTheme.id ? {theme: recoveredTheme.id, ...design} : design, diagnostics};
|
|
1162
|
+
}
|
|
1163
|
+
|
|
921
1164
|
function configurePresentation(pptx, presentation, context) {
|
|
922
1165
|
pptx.defineLayout({
|
|
923
1166
|
name: context.layoutName,
|
|
@@ -938,21 +1181,51 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
938
1181
|
|
|
939
1182
|
async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
|
|
940
1183
|
const slide = pptx.addSlide();
|
|
941
|
-
const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
|
|
1184
|
+
const slideContext = resolveSlideContext(presentation, opfSlide, context, options, slideIndex);
|
|
942
1185
|
slide.background = { color: slideContext.colors.background };
|
|
943
|
-
const
|
|
1186
|
+
const backgroundDefinition = slideContext.backgroundDefinition;
|
|
1187
|
+
const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
|
|
1188
|
+
const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
|
|
944
1189
|
width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
|
|
945
|
-
}, slideContext.colors.background);
|
|
1190
|
+
}, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext));
|
|
946
1191
|
if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
|
|
1192
|
+
if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
|
|
1193
|
+
options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
|
|
1194
|
+
}
|
|
1195
|
+
if (backgroundDefinition?.type === 'image') {
|
|
1196
|
+
const imagePath = `${backgroundPath}.image`;
|
|
1197
|
+
const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath);
|
|
1198
|
+
if (resolved) {
|
|
1199
|
+
// PptxGenJS embeds the raster and its relationship; packaging replaces
|
|
1200
|
+
// its stretched fill with the fitted native picture fill.
|
|
1201
|
+
slide.background = { ...resolved };
|
|
1202
|
+
context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, { image: {
|
|
1203
|
+
fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
|
|
1204
|
+
width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
|
|
1205
|
+
} });
|
|
1206
|
+
} else {
|
|
1207
|
+
options.onDiagnostic?.({code: 'unresolved-asset', path: imagePath, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
947
1210
|
slide.color = slideContext.colors.text;
|
|
948
1211
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
949
1212
|
|
|
950
1213
|
const { widthInches, heightInches } = slideContext.dimensions;
|
|
951
1214
|
const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
|
|
952
1215
|
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` });
|
|
953
|
-
|
|
1216
|
+
// design.titleAlignment/contentAlignment are Design properties (slide design
|
|
1217
|
+
// overrides the deck). Core only returns an accepted `placement.alignment`
|
|
1218
|
+
// with outline measurement, so the same effective values must also drive
|
|
1219
|
+
// text without placement, exactly as the renderer's preview does.
|
|
1220
|
+
const titleAlignment=opfSlide.design?.titleAlignment??presentation.design?.titleAlignment;
|
|
1221
|
+
const contentAlignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment;
|
|
1222
|
+
const fieldAlignment=field=>field==='title'?titleAlignment:contentAlignment;
|
|
1223
|
+
// Core composition resolves one alignment per composed item for every engine
|
|
1224
|
+
// (FF-29); the design fallback keeps cores published before item.alignment working.
|
|
1225
|
+
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1226
|
+
const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
|
|
954
1227
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
955
|
-
await
|
|
1228
|
+
if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
|
|
956
1229
|
for (const item of geometry.items) {
|
|
957
1230
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
958
1231
|
if (item.frameBox) {
|
|
@@ -962,10 +1235,10 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
962
1235
|
const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
|
|
963
1236
|
slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
|
|
964
1237
|
rectRadius:8*Math.min(widthInches,heightInches)/720,
|
|
965
|
-
fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),
|
|
1238
|
+
fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
|
|
966
1239
|
}
|
|
967
1240
|
if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
|
|
968
|
-
addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
|
|
1241
|
+
addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:alignmentFor(item),diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
|
|
969
1242
|
} else if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
970
1243
|
// Estimated-font headings use one native text box, which still needs a
|
|
971
1244
|
// role tag. Role recovery must not depend on outline measurement support.
|
|
@@ -975,35 +1248,55 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
975
1248
|
...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
|
|
976
1249
|
...nativeFontOptions(item.textStyle),
|
|
977
1250
|
color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
|
|
1251
|
+
align: alignmentFor(item),
|
|
978
1252
|
objectName,
|
|
979
1253
|
breakLine: false
|
|
980
1254
|
});
|
|
981
1255
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
982
|
-
addMeasuredList(slide,item.text,slideContext);
|
|
1256
|
+
addMeasuredList(slide,item.text,slideContext,item.path);
|
|
983
1257
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
984
|
-
const alignment=item.text.placement?.alignment??
|
|
1258
|
+
const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
985
1259
|
for(const [index,line] of item.text.richLines.entries()){
|
|
986
1260
|
const runs=line.fragments.map(fragment=>{
|
|
987
1261
|
const runColor=exportColor(fragment.run.color,slideContext,slideContext.colors.text);
|
|
988
|
-
|
|
1262
|
+
const color=nativeColor(fragment.run.color,runColor,slideContext);
|
|
1263
|
+
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
|
|
989
1264
|
});
|
|
990
1265
|
const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
991
1266
|
const area=placed?{...region,x:(placed.x+line.width*factor-item.box.width*factor)/96,y:placed.y/96,h:placed.height/96}:{...region,y:region.y+line.y/96,h:line.height/96};
|
|
992
1267
|
if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
993
1268
|
}
|
|
994
1269
|
} else if (item.field === "text" && typeof item.value === "string") {
|
|
995
|
-
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle)});
|
|
1270
|
+
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
|
|
996
1271
|
} else {
|
|
997
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment:
|
|
1272
|
+
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
|
|
998
1273
|
}
|
|
999
1274
|
}
|
|
1275
|
+
// Core composes furniture above all content and opf-render paints it last, so
|
|
1276
|
+
// spTree order (PowerPoint's z-order) matches: header/footer parts come after
|
|
1277
|
+
// every content item, and an overlapping footer stays visible over content.
|
|
1278
|
+
await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
|
|
1000
1279
|
|
|
1001
|
-
if (opfSlide.notes)
|
|
1280
|
+
if (opfSlide.notes) {
|
|
1281
|
+
const notes = String(opfSlide.notes);
|
|
1282
|
+
slide.addNotes(notes);
|
|
1283
|
+
if (notes.includes('\r')) context.notesWithCarriageReturns.set(`ppt/notesSlides/notesSlide${slideIndex + 1}.xml`, notes);
|
|
1284
|
+
}
|
|
1002
1285
|
}
|
|
1003
1286
|
|
|
1004
|
-
function resolveSlideContext(presentation, slide, baseContext, options) {
|
|
1287
|
+
function resolveSlideContext(presentation, slide, baseContext, options, slideIndex = 0) {
|
|
1005
1288
|
const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
|
|
1006
1289
|
const resolved = resolvePresentationContext(effective, options);
|
|
1290
|
+
if (resolved.unresolvedFontScheme) {
|
|
1291
|
+
const path = slide.design?.fontScheme !== undefined ? `slides.${slideIndex}.design.fontScheme`
|
|
1292
|
+
: presentation.design?.fontScheme !== undefined ? "design.fontScheme"
|
|
1293
|
+
: slide.design?.theme !== undefined ? `slides.${slideIndex}.design.theme` : "design.theme";
|
|
1294
|
+
if (!baseContext.reportedFontSchemes?.has(path)) {
|
|
1295
|
+
baseContext.reportedFontSchemes?.add(path);
|
|
1296
|
+
const id = resolved.unresolvedFontScheme;
|
|
1297
|
+
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}'.` });
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1007
1300
|
if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
|
|
1008
1301
|
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
1009
1302
|
throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
|
|
@@ -1028,10 +1321,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1028
1321
|
await addImagePayload(slide, presentation, payload.image, region, path, context, options);
|
|
1029
1322
|
break;
|
|
1030
1323
|
case "video":
|
|
1031
|
-
|
|
1324
|
+
addMediaPayload(slide, presentation, payload.video, region, path, context, options);
|
|
1032
1325
|
break;
|
|
1033
1326
|
case "chart":
|
|
1034
|
-
addChartPayload(slide, payload.chart, region, context);
|
|
1327
|
+
addChartPayload(slide, payload.chart, region, context, options, path);
|
|
1035
1328
|
break;
|
|
1036
1329
|
case "table":
|
|
1037
1330
|
addTablePayload(slide, payload.table, region, context, options, path);
|
|
@@ -1049,7 +1342,8 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1049
1342
|
addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
|
|
1050
1343
|
break;
|
|
1051
1344
|
default:
|
|
1052
|
-
|
|
1345
|
+
options.onDiagnostic?.({code: "content-placeholder", path, reason: "unsupported-payload", message: "This content has no PowerPoint export; a placeholder frame stands in for it."});
|
|
1346
|
+
addPlaceholderPayload(slide, "Unsupported content", "This content has no PowerPoint export.", region, context);
|
|
1053
1347
|
}
|
|
1054
1348
|
}
|
|
1055
1349
|
|
|
@@ -1078,17 +1372,23 @@ function addTextPayload(slide, value, region, context) {
|
|
|
1078
1372
|
function richLineRuns(line,color,context) {
|
|
1079
1373
|
const fallback=color.replace(/^#/,'');
|
|
1080
1374
|
return line.fragments.map(fragment=>{
|
|
1081
|
-
const runColor=exportColor(fragment.run.color,context,fallback);
|
|
1082
|
-
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color
|
|
1375
|
+
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
|
|
1376
|
+
return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',color}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}};
|
|
1083
1377
|
});
|
|
1084
1378
|
}
|
|
1085
|
-
|
|
1379
|
+
// Every native line of a list, marker paragraph or not, is named for the list's
|
|
1380
|
+
// composed path (`OPF list <path> line N`, N counting across the whole list) like
|
|
1381
|
+
// the other measured text shapes. A reader maps lines to their list by name, not
|
|
1382
|
+
// by geometry, so lines of an overflowing list that fall below its box still
|
|
1383
|
+
// belong to it.
|
|
1384
|
+
function addMeasuredList(slide,fit,context,path) {
|
|
1385
|
+
let lineNumber=0;
|
|
1086
1386
|
for(const entry of fit.listEntries){
|
|
1087
1387
|
const addLines=(text,box,color,withBullet)=>{
|
|
1088
1388
|
text.richLines.forEach((line,index)=>{
|
|
1089
1389
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1090
1390
|
const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
1091
|
-
const objectName
|
|
1391
|
+
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1092
1392
|
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
|
|
1093
1393
|
const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
1094
1394
|
const runs=richLineRuns(line,color,context);
|
|
@@ -1146,7 +1446,7 @@ function addListPayload(slide, items, region, context) {
|
|
|
1146
1446
|
async function addImagePayload(slide, presentation, asset, region, path, context, options) {
|
|
1147
1447
|
const resolved = await resolveImage(asset, presentation, options, path);
|
|
1148
1448
|
if (!resolved) {
|
|
1149
|
-
|
|
1449
|
+
addImagePlaceholder(slide, presentation, asset, region, path, context, options);
|
|
1150
1450
|
return;
|
|
1151
1451
|
}
|
|
1152
1452
|
const objectName = `OPF image ${context.imagePlacements.size + 1}`;
|
|
@@ -1163,12 +1463,50 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
1163
1463
|
return objectName;
|
|
1164
1464
|
}
|
|
1165
1465
|
|
|
1166
|
-
|
|
1466
|
+
// design.slideImage: one native picture at the shared frame, beneath content.
|
|
1467
|
+
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
1468
|
+
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
1469
|
+
const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
|
|
1470
|
+
const resolved = await resolveImage(image.value, presentation, options, image.sourcePath);
|
|
1471
|
+
if (!resolved) {
|
|
1472
|
+
addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const objectName = slideImageName(`slides.${slideIndex}`);
|
|
1476
|
+
const configured = image.path === 'design.slideImage' ? presentation.design?.slideImage : presentation.slides[slideIndex].design?.slideImage;
|
|
1477
|
+
const { src: _source, ...treatment } = isPlainObject(configured) && 'position' in configured ? configured : {};
|
|
1478
|
+
const paint = (entry, fallback) => {
|
|
1479
|
+
const hex = normalizeHex(exportColor(entry, context, fallback), fallback);
|
|
1480
|
+
const raw = exportColor(entry, context, fallback).replace(/^#/, '');
|
|
1481
|
+
return { hex, alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
|
|
1482
|
+
};
|
|
1483
|
+
// Native effects are resolved here, in the slide's color context.
|
|
1484
|
+
const effects = {
|
|
1485
|
+
shape: image.shape ?? null,
|
|
1486
|
+
border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
|
|
1487
|
+
opacity: image.opacity ?? null,
|
|
1488
|
+
recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
|
|
1489
|
+
: image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
|
|
1490
|
+
overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
|
|
1491
|
+
};
|
|
1492
|
+
context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects });
|
|
1493
|
+
slide.addImage({ ...resolved, objectName, ...box, altText: image.alt ?? assetAlt(image.value, presentation) });
|
|
1494
|
+
// The overlay scrim is a separate native shape directly above the picture.
|
|
1495
|
+
if (effects.overlay) {
|
|
1496
|
+
const overlay = effects.overlay.box;
|
|
1497
|
+
slide.addShape('rect', { x: overlay.x / 96, y: overlay.y / 96, w: overlay.width / 96, h: overlay.height / 96, objectName: slideImageOverlayName(`slides.${slideIndex}`), fill: { color: effects.overlay.hex } });
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
|
|
1167
1502
|
const chartData = toPptxChartData(chart);
|
|
1168
|
-
if (!chartData) {
|
|
1169
|
-
|
|
1503
|
+
if (!chartData.series) {
|
|
1504
|
+
// Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
|
|
1505
|
+
options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
|
|
1506
|
+
addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
|
|
1170
1507
|
return;
|
|
1171
1508
|
}
|
|
1509
|
+
if (chartData.adapted) options.onDiagnostic?.({code: "chart-data-adapted", path, message: chartData.message, adaptation: chartData.adapted});
|
|
1172
1510
|
|
|
1173
1511
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1174
1512
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
@@ -1178,7 +1516,8 @@ function addChartPayload(slide, chart, region, context) {
|
|
|
1178
1516
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
1179
1517
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1180
1518
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1181
|
-
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
|
|
1519
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
|
|
1520
|
+
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1182
1521
|
slide.addChart(chartData.type, chartData.series, {
|
|
1183
1522
|
objectName,
|
|
1184
1523
|
x: region.x,
|
|
@@ -1212,7 +1551,8 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1212
1551
|
const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
|
|
1213
1552
|
const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
|
|
1214
1553
|
if (sourceRows.length === 0) {
|
|
1215
|
-
|
|
1554
|
+
options?.onDiagnostic?.({code: "content-placeholder", path, reason: "table-has-no-rows", message: "The table has no rows and no header; a placeholder frame stands in for it."});
|
|
1555
|
+
addPlaceholderPayload(slide, "Table", "The table has no rows.", region, context);
|
|
1216
1556
|
return;
|
|
1217
1557
|
}
|
|
1218
1558
|
|
|
@@ -1235,10 +1575,10 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1235
1575
|
const fragment = fragments.find(item => item.runIndex === index);
|
|
1236
1576
|
const runStyle = fragment?.style ?? resolveTextStyle({...style,fontFamily:run.fontFamily ?? style.fontFamily,fontWeight:run.bold === undefined ? style.fontWeight : run.bold ? 700 : 400,italic:run.italic ?? style.italic}, options.textMeasurement);
|
|
1237
1577
|
const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
|
|
1238
|
-
const color =
|
|
1578
|
+
const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
|
|
1239
1579
|
const runOptions = {
|
|
1240
1580
|
...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
1241
|
-
underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
1581
|
+
underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
1242
1582
|
color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
|
|
1243
1583
|
hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
|
|
1244
1584
|
};
|
|
@@ -1265,11 +1605,11 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1265
1605
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
1266
1606
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
1267
1607
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
1268
|
-
color:
|
|
1608
|
+
color: nativeColor(cellStyle.color, baseColor, context),
|
|
1269
1609
|
// Rich runs carry resolved alpha. A translucent cell default would
|
|
1270
1610
|
// overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
|
|
1271
1611
|
...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
|
|
1272
|
-
fill: { color:
|
|
1612
|
+
fill: { color: nativeColor(cellStyle.fill, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
|
|
1273
1613
|
},
|
|
1274
1614
|
};
|
|
1275
1615
|
}));
|
|
@@ -1281,6 +1621,9 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1281
1621
|
x: region.x,
|
|
1282
1622
|
y: region.y,
|
|
1283
1623
|
w: region.w,
|
|
1624
|
+
// PowerPoint derives a table's height from its rows, and the preview draws
|
|
1625
|
+
// the rows (layout.height, never more than the composed box). The declared
|
|
1626
|
+
// frame height is the row total so the XML matches what both engines draw.
|
|
1284
1627
|
h: layout.height / 96,
|
|
1285
1628
|
rowH: layout.rows.map(row => row.box.height / 96),
|
|
1286
1629
|
colW: Array(columnCount).fill(region.w / columnCount),
|
|
@@ -1311,20 +1654,34 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
1311
1654
|
options.onDiagnostic?.(diagnostic);
|
|
1312
1655
|
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
|
|
1313
1656
|
}
|
|
1657
|
+
// Generated socials carry one link per explicit source line; wrapped lines share it.
|
|
1658
|
+
let sourceLineIndex = 0;
|
|
1314
1659
|
for (const [index, line] of fit.lines.entries()) {
|
|
1660
|
+
const link = config.links?.[sourceLineIndex];
|
|
1661
|
+
if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
|
|
1315
1662
|
if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
|
|
1316
1663
|
const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1317
1664
|
const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
|
|
1318
1665
|
const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:config.objectName?`${config.objectName} line ${index}`:undefined;
|
|
1319
1666
|
if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
|
|
1320
1667
|
else if(config.timeline)context.timelineTags.set(objectName,{v:1,role:'text',group:config.timeline.group,part:config.timeline.part,line:index,count:fit.lines.length,...boundary,...(config.timeline.part===0&&index===0?{anchor:config.timeline.anchor}:{})});
|
|
1321
|
-
else if(config.furniture)
|
|
1668
|
+
else if(config.furniture){
|
|
1669
|
+
context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
|
|
1670
|
+
if(config.liveFields?.[index]?.length)context.furnitureFields.set(objectName,{text:line,fields:config.liveFields[index]});
|
|
1671
|
+
}
|
|
1672
|
+
else if(config.media&&context.provenanceMode!==false)context.mediaTags.set(objectName,{v:1,role:'caption',path:config.path,line:index,count:fit.lines.length,boundary:sourceLine?.boundary??'end',...(context.provenanceMode==='full'?{fingerprint:mediaTextFingerprint(line),...boundary}:{})});
|
|
1322
1673
|
else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
|
|
1323
1674
|
const area=placed?{x:(placed.x+placed.width*factor-box.width*factor)/96,y:(placed.baseline-fit.fontSize)/96,w:box.width/96,h:placed.height/96}:{x:box.x/96,y:(box.y+index*fit.lineHeight)/96,w:box.width/96,h:fit.lineHeight/96};
|
|
1324
|
-
|
|
1675
|
+
// A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
|
|
1676
|
+
const lineColor = normalizeHex(config.color ?? context.colors.text);
|
|
1677
|
+
slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
|
|
1325
1678
|
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
1326
1679
|
...nativeFontOptions(style),
|
|
1327
|
-
color:
|
|
1680
|
+
color: lineColor, align: alignment,
|
|
1681
|
+
// Reuse the frame's existing relationship to cover blank caption boxes.
|
|
1682
|
+
// PptxGenJS also inherits it into runs; keep their native text styling.
|
|
1683
|
+
hyperlink: config.hyperlink,
|
|
1684
|
+
underline: config.hyperlink ? {style: 'none'} : undefined,
|
|
1328
1685
|
tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
|
|
1329
1686
|
objectName,
|
|
1330
1687
|
fit: 'none', wrap: false, lineSpacingMultiple: 1,
|
|
@@ -1339,6 +1696,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1339
1696
|
if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
|
|
1340
1697
|
return;
|
|
1341
1698
|
}
|
|
1699
|
+
const staticDates = new Map();
|
|
1342
1700
|
for(const [index,part]of layout.parts.entries()){
|
|
1343
1701
|
if(part.type==='image'){
|
|
1344
1702
|
const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
|
|
@@ -1346,10 +1704,27 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1346
1704
|
if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
|
|
1347
1705
|
}else{
|
|
1348
1706
|
if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
|
|
1349
|
-
|
|
1707
|
+
// Slide numbers and current dates become native PowerPoint fields; {total}
|
|
1708
|
+
// and fixed dates stay fixed text. A current date whose pattern has no
|
|
1709
|
+
// en-US field type is written as its laid-out text.
|
|
1710
|
+
const fields=[];
|
|
1711
|
+
for(const field of furniturePartFields(part)){
|
|
1712
|
+
const nativeType=nativeFieldType(field);
|
|
1713
|
+
if(nativeType)fields.push({...field,nativeType});
|
|
1714
|
+
else options.onDiagnostic?.({code:'furniture-date-fixed',path:part.path,message:`dateFormat '${field.format}' has no PowerPoint en-US date field; the current date is exported as fixed text that PowerPoint will not update.`});
|
|
1715
|
+
}
|
|
1716
|
+
const liveFields=fields.length?lineFields(part.text,fields,part.fit.sourceLines,part.fit.lines):undefined;
|
|
1717
|
+
for(const field of fields.filter(field => !lineFields(part.text,[field],part.fit.sourceLines,part.fit.lines).some(line => line.length))) {
|
|
1718
|
+
options.onDiagnostic?.({code:'furniture-field-fixed',path:part.path,message:`The ${field.type} field range ${field.start}..${field.end} does not fit one accepted text line. It is exported as static text that PowerPoint will not update; native compatibility remains a separate gate.`});
|
|
1719
|
+
if(context.provenanceMode === 'full') {
|
|
1720
|
+
const marker = staticDateFallback(part,field);
|
|
1721
|
+
if(marker) staticDates.set(index,marker);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.colors.mutedText,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index},liveFields,links:part.links});
|
|
1350
1725
|
}
|
|
1351
1726
|
}
|
|
1352
|
-
const manifest = furnitureManifest(presentation, source, layout, slideIndex);
|
|
1727
|
+
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
|
|
1353
1728
|
if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
|
|
1354
1729
|
}
|
|
1355
1730
|
|
|
@@ -1364,7 +1739,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
|
|
|
1364
1739
|
const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
|
|
1365
1740
|
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,issues:layout.diagnostics});
|
|
1366
1741
|
context.codeTags.set(panelName,codeManifest(value,layout,group));
|
|
1367
|
-
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155',
|
|
1742
|
+
slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', width: .75}, objectName:panelName});
|
|
1368
1743
|
for (const [partIndex,part] of layout.parts.entries()) {
|
|
1369
1744
|
if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
|
|
1370
1745
|
for (const [index,line] of part.fit.sourceLines.entries()) {
|
|
@@ -1427,7 +1802,7 @@ function addTimelinePayload(slide, value, layout, context, options, path) {
|
|
|
1427
1802
|
const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
|
|
1428
1803
|
const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
|
|
1429
1804
|
const {x1,y1,x2,y2}=layout.connector;
|
|
1430
|
-
slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,
|
|
1805
|
+
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}});
|
|
1431
1806
|
context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
|
|
1432
1807
|
for(const marker of layout.markers){
|
|
1433
1808
|
const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
|
|
@@ -1439,16 +1814,110 @@ function addTimelinePayload(slide, value, layout, context, options, path) {
|
|
|
1439
1814
|
}
|
|
1440
1815
|
}
|
|
1441
1816
|
|
|
1442
|
-
|
|
1817
|
+
// Mirror the renderer's media placeholder: a bordered surface, a centred 72px
|
|
1818
|
+
// play badge and a centred caption (title, then source). Native video embedding
|
|
1819
|
+
// is not attempted; the caption keeps the reference editable.
|
|
1820
|
+
function addMediaPayload(slide, presentation, value, region, path, context, options) {
|
|
1821
|
+
const box = pixelBox(region);
|
|
1822
|
+
const icon = {x: box.x + (box.width - 72) / 2, y: box.y + (box.height - 72) / 2};
|
|
1823
|
+
// The frame links to a web source (after asset dereferencing), so the
|
|
1824
|
+
// reference stays usable in PowerPoint; OPF_MEDIA_V1 keeps the OPF value.
|
|
1825
|
+
const resolved = dereferenceAsset(value, presentation, path), source = typeof resolved === "string" ? resolved : resolved?.src;
|
|
1826
|
+
const hyperlink = typeof source === "string" && /^https?:\/\//i.test(source) ? {url: source} : undefined;
|
|
1827
|
+
const names = {frame: `OPF media ${path} frame`, badge: `OPF media ${path} badge`, play: `OPF media ${path} play`};
|
|
1828
|
+
if (context.provenanceMode !== false) {
|
|
1829
|
+
context.mediaTags.set(names.frame, mediaFrameRecord(value, path, presentation, context.provenanceMode, options.onDiagnostic));
|
|
1830
|
+
context.mediaTags.set(names.badge, {v: 1, role: "badge", path});
|
|
1831
|
+
context.mediaTags.set(names.play, {v: 1, role: "play", path});
|
|
1832
|
+
}
|
|
1833
|
+
slide.addShape("rect", {x: region.x, y: region.y, w: region.w, h: region.h, fill: {color: context.colors.surface}, line: {color: context.colors.border, width: 0.75}, hyperlink, objectName: names.frame});
|
|
1834
|
+
slide.addShape("ellipse", {x: icon.x / 96, y: icon.y / 96, w: 72 / 96, h: 72 / 96, fill: {color: context.colors.accent}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.badge});
|
|
1835
|
+
slide.addShape("triangle", {x: (icon.x + 28) / 96, y: (icon.y + 22) / 96, w: 28 / 96, h: 28 / 96, rotate: 90, fill: {color: "FFFFFF"}, line: {transparency: 100}, hyperlink: hyperlink && {url: source}, objectName: names.play});
|
|
1836
|
+
addMeasuredPayloadText(slide, mediaCaption(value), {...box, y: icon.y + 72 + 20, height: 50}, context, options,
|
|
1837
|
+
{path, fontSize: 18, fontFamily: context.fonts.body, fontWeight: 600, align: "center", color: context.colors.mutedText, objectName: `OPF media ${path} caption`, media: true, keepEmpty: true, hyperlink});
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// The asset an image block names, layered as the preview layers it: fields on the
|
|
1841
|
+
// block win over the registry entry it references, and an unknown reference keeps
|
|
1842
|
+
// the block's own fields.
|
|
1843
|
+
function placeholderAsset(asset, presentation) {
|
|
1844
|
+
const plain = value => typeof value === "string" ? { src: value } : isPlainObject(value) ? value : { src: "" };
|
|
1845
|
+
let current = plain(asset);
|
|
1846
|
+
const seen = new Set();
|
|
1847
|
+
while (typeof current.src === "string" && current.src.startsWith("asset:")) {
|
|
1848
|
+
const id = current.src.slice(6);
|
|
1849
|
+
if (seen.has(id) || !Object.hasOwn(presentation.assets ?? {}, id)) break;
|
|
1850
|
+
seen.add(id);
|
|
1851
|
+
const { src: _source, ...overrides } = current;
|
|
1852
|
+
current = { ...plain(presentation.assets[id]), ...overrides };
|
|
1853
|
+
}
|
|
1854
|
+
return current;
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
// An image that cannot be embedded exports the preview's placeholder, not a second
|
|
1858
|
+
// design: a dashed panel with a centered "Image unavailable" over the asset's alt
|
|
1859
|
+
// text (else title, else "Image"), set bold at 20 px (shrinking to the composition
|
|
1860
|
+
// minimum) in the readable text colour for the panel, or a cross when the label
|
|
1861
|
+
// cannot fit even at that minimum. The panel carries the accessible name the
|
|
1862
|
+
// preview gives its group, "Image unavailable: <description>".
|
|
1863
|
+
function addImagePlaceholder(slide, presentation, asset, region, path, context, options) {
|
|
1864
|
+
const layered = placeholderAsset(asset, presentation);
|
|
1865
|
+
const description = String(layered.alt ?? layered.title ?? "Image");
|
|
1866
|
+
const label = `Image unavailable\n${description}`;
|
|
1867
|
+
// The preview rejects characters DrawingML/SVG XML cannot represent when it draws the label.
|
|
1868
|
+
// The cross and the accessible name would otherwise carry them into the slide XML, so check
|
|
1869
|
+
// up front and fail the same way whichever form the placeholder takes.
|
|
1870
|
+
const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF]/u.exec(label);
|
|
1871
|
+
if (invalid) throw new OPFPptxError("invalid-text", `Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4, "0")} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`, { path });
|
|
1872
|
+
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1873
|
+
const box = pixelBox(region);
|
|
1874
|
+
const padding = Math.min(24 * scale, box.width * .06, box.height * .1);
|
|
1875
|
+
const inner = { x: box.x + padding, y: box.y + padding, width: Math.max(1, box.width - padding * 2), height: Math.max(1, box.height - padding * 2) };
|
|
1876
|
+
const surface = normalizeHex(context.colors.surface);
|
|
1877
|
+
const textColor = normalizeHex(textColorForFill(`#${surface}`, `#${context.colors.text}`));
|
|
1878
|
+
const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: 600, path }, options.textMeasurement);
|
|
1879
|
+
const measurer = textWidthMeasurer(style, options.textMeasurement);
|
|
1880
|
+
const minimum = (context.composition?.minFontSize ?? 16) * scale;
|
|
1881
|
+
const fit = fitText(label, inner, 20 * scale, minimum, measurer);
|
|
1882
|
+
const objectName = `OPF image placeholder ${context.imagePlaceholders.size + 1}`;
|
|
1883
|
+
context.imagePlaceholders.set(objectName, `Image unavailable: ${description}`);
|
|
1884
|
+
slide.addShape("rect", {
|
|
1885
|
+
x: region.x, y: region.y, w: region.w, h: region.h,
|
|
1886
|
+
fill: { color: surface },
|
|
1887
|
+
line: { color: context.colors.border, width: .75, dashType: "dash" },
|
|
1888
|
+
objectName
|
|
1889
|
+
});
|
|
1890
|
+
if (!fit.overflow) {
|
|
1891
|
+
// The lines are centered vertically in the padded box. Fit against the shifted
|
|
1892
|
+
// box so any per-line placement carries the same offset.
|
|
1893
|
+
const offset = Math.max(0, (inner.height - fit.lines.length * fit.lineHeight) / 2);
|
|
1894
|
+
const centered = { ...inner, y: inner.y + offset };
|
|
1895
|
+
addMeasuredPayloadText(slide, label, centered, context, options, {
|
|
1896
|
+
path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
|
|
1897
|
+
diagnosticsHandled: true, align: "center", color: textColor
|
|
1898
|
+
});
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
// A status indicator, not shortened authored content: the full description stays
|
|
1902
|
+
// in the panel's accessible name.
|
|
1903
|
+
const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
|
|
1904
|
+
if (!size) return;
|
|
1905
|
+
const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
|
|
1906
|
+
const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
|
|
1907
|
+
for (const flipV of [false, true]) slide.addShape("line", { x: icon.x / 96, y: icon.y / 96, w: size / 96, h: size / 96, line, flipV });
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
|
|
1911
|
+
function addPlaceholderPayload(slide, label, description, region, context) {
|
|
1443
1912
|
slide.addShape("rect", {
|
|
1444
1913
|
x: region.x,
|
|
1445
1914
|
y: region.y,
|
|
1446
1915
|
w: region.w,
|
|
1447
1916
|
h: region.h,
|
|
1448
1917
|
fill: { color: context.colors.surface, transparency: 10 },
|
|
1449
|
-
line: { color: context.colors.border,
|
|
1918
|
+
line: { color: context.colors.border, width: 0.75 }
|
|
1450
1919
|
});
|
|
1451
|
-
slide.addText(`${label}\n${
|
|
1920
|
+
slide.addText(`${label}\n${description}`, {
|
|
1452
1921
|
x: region.x + 0.12,
|
|
1453
1922
|
y: region.y + 0.12,
|
|
1454
1923
|
w: Math.max(0.2, region.w - 0.24),
|
|
@@ -1463,6 +1932,46 @@ function addPlaceholderPayload(slide, label, value, region, context) {
|
|
|
1463
1932
|
});
|
|
1464
1933
|
}
|
|
1465
1934
|
|
|
1935
|
+
// FF-31: a measurement provider may preview a chosen family with another face
|
|
1936
|
+
// (Carlito for Aptos or Calibri, Gelasio for Georgia, a caller alias, a generic
|
|
1937
|
+
// fallback). That substitute changes measurement and drawing only. The package
|
|
1938
|
+
// always names the developer's chosen family. Measurement still uses the
|
|
1939
|
+
// substitute, because the provider resolves the chosen family again on every
|
|
1940
|
+
// measure/outline call, so layout is unchanged.
|
|
1941
|
+
function sameTypeface(requested, resolved) {
|
|
1942
|
+
if (typeof resolved !== 'string') return false;
|
|
1943
|
+
const want = requested.trim().toLowerCase(), got = resolved.trim().toLowerCase();
|
|
1944
|
+
// A legacy four-style family such as "Roboto Medium" is the chosen typeface.
|
|
1945
|
+
return got === want || got.startsWith(`${want} `) && isFaceStyleSuffix(got.slice(want.length + 1));
|
|
1946
|
+
}
|
|
1947
|
+
function chosenFamilyMeasurement(measurement) {
|
|
1948
|
+
if (!measurement || typeof measurement.resolveStyle !== 'function') return measurement;
|
|
1949
|
+
const resolveFont = typeof measurement.resolveFont === 'function' ? style => measurement.resolveFont(style) : undefined;
|
|
1950
|
+
const wrapped = {
|
|
1951
|
+
measure: (text, size, style) => measurement.measure(text, size, style),
|
|
1952
|
+
resolveStyle: style => {
|
|
1953
|
+
const resolved = measurement.resolveStyle(style);
|
|
1954
|
+
const requested = style?.fontFamily;
|
|
1955
|
+
// Theme tokens are provider business; the exporter always passes concrete families.
|
|
1956
|
+
if (typeof requested !== 'string' || requested.startsWith('+')) return resolved;
|
|
1957
|
+
// opf-render reports whether a face came from the chosen family or a substitute;
|
|
1958
|
+
// other providers are judged by family name.
|
|
1959
|
+
const resolution = resolveFont?.(style);
|
|
1960
|
+
const faceFamily = resolved?.fontFace?.family;
|
|
1961
|
+
const substituted = typeof resolution?.substitute === 'boolean'
|
|
1962
|
+
? resolution.substitute
|
|
1963
|
+
: !sameTypeface(requested, resolved?.fontFamily) || typeof faceFamily === 'string' && !sameTypeface(requested, faceFamily);
|
|
1964
|
+
if (!substituted) return resolved;
|
|
1965
|
+
const chosen = {...style};
|
|
1966
|
+
delete chosen.fontFace;
|
|
1967
|
+
return chosen;
|
|
1968
|
+
},
|
|
1969
|
+
};
|
|
1970
|
+
if (typeof measurement.outlineBounds === 'function') wrapped.outlineBounds = (text, size, style) => measurement.outlineBounds(text, size, style);
|
|
1971
|
+
if (resolveFont) wrapped.resolveFont = resolveFont;
|
|
1972
|
+
return wrapped;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1466
1975
|
function nativeFontOptions(style) {
|
|
1467
1976
|
const face=style.fontFace;
|
|
1468
1977
|
if(face!==undefined) {
|
|
@@ -1509,9 +2018,9 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
1509
2018
|
options: {
|
|
1510
2019
|
bold: run?.bold,
|
|
1511
2020
|
italic: run?.italic,
|
|
1512
|
-
underline: run?.underline ? { color:
|
|
2021
|
+
underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context) } : undefined,
|
|
1513
2022
|
strike: run?.strikethrough ? "sngStrike" : undefined,
|
|
1514
|
-
color:
|
|
2023
|
+
color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context),
|
|
1515
2024
|
fontFace: run?.fontFamily ?? context.fonts.body,
|
|
1516
2025
|
fontSize: run?.fontSize ?? fallbackFontSize,
|
|
1517
2026
|
superscript: run?.superscript,
|
|
@@ -1522,10 +2031,52 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
1522
2031
|
});
|
|
1523
2032
|
}
|
|
1524
2033
|
|
|
2034
|
+
/**
|
|
2035
|
+
* Series for a native chart, or `{reason, message}` when the data cannot be plotted (the caller reports it).
|
|
2036
|
+
* A first column that holds categories is the usual shape. Chart types whose data is one column of values
|
|
2037
|
+
* (histogram, dot plot) have no category column, so their values are plotted directly: a histogram is binned
|
|
2038
|
+
* into equal-width bins and exported as a column chart of the counts, every other type plots the values against
|
|
2039
|
+
* their row numbers. `adapted` names that transformation so it is reported, never silent.
|
|
2040
|
+
*/
|
|
1525
2041
|
function toPptxChartData(chart) {
|
|
1526
2042
|
const data = chart?.data;
|
|
1527
|
-
|
|
1528
|
-
if (data.columns
|
|
2043
|
+
const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
|
|
2044
|
+
if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
|
|
2045
|
+
return unplottable("data-not-inline", "Chart data is not inline, so it cannot be drawn here.", "The chart data is not inline columns and rows (for example an external data source). Supply inline columns and rows.");
|
|
2046
|
+
}
|
|
2047
|
+
if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2048
|
+
if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
2049
|
+
const mapped = mapChartType(chart.type);
|
|
2050
|
+
if (data.columns.length === 1) {
|
|
2051
|
+
const heading = stringifyText(data.columns[0]);
|
|
2052
|
+
const cell = (row) => Array.isArray(row) ? row[0] : row;
|
|
2053
|
+
// The same parsing as multi-column charts (`numericValue`: "12%", "$5", "1,234"); a cell that holds no number is skipped, never plotted as 0.
|
|
2054
|
+
const points = data.rows.map((row, index) => ({row: index + 1, value: parsedNumber(cell(row))})).filter((point) => point.value !== null);
|
|
2055
|
+
if (points.length === 0) {
|
|
2056
|
+
return unplottable("single-column-not-numeric", "The chart's data column has no numbers.", `The only chart data column '${heading}' holds no numbers, and a chart needs values to plot.`);
|
|
2057
|
+
}
|
|
2058
|
+
const skipped = data.rows.length - points.length;
|
|
2059
|
+
const skippedNote = skipped ? ` (${skipped} non-numeric ${skipped === 1 ? "cell was" : "cells were"} skipped)` : "";
|
|
2060
|
+
if (String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2061
|
+
const bins = histogramBins(points.map((point) => point.value));
|
|
2062
|
+
return {
|
|
2063
|
+
type: "bar", barDir: "col", barGrouping: "clustered", heading: "Bin",
|
|
2064
|
+
series: [{name: "Frequency", labels: bins.map((bin) => bin.label), values: bins.map((bin) => bin.count)}],
|
|
2065
|
+
adapted: "histogram-binned",
|
|
2066
|
+
message: `The histogram's single data column '${heading}' (${points.length} values${skippedNote}) was binned into ${bins.length} equal-width bins and exported as a column chart of the counts; PowerPoint's own histogram chart is not exported, and the binned counts do not restore the raw values on re-import.`
|
|
2067
|
+
};
|
|
2068
|
+
}
|
|
2069
|
+
// Rows keep their own row numbers, so a skipped cell leaves a gap in the numbering.
|
|
2070
|
+
const labels = points.map((point) => String(point.row));
|
|
2071
|
+
const numbers = points.map((point) => point.value);
|
|
2072
|
+
const series = mapped.type === "scatter"
|
|
2073
|
+
? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
|
|
2074
|
+
: [{name: heading, labels, values: numbers}];
|
|
2075
|
+
return {
|
|
2076
|
+
...mapped, series, heading: "Row", adapted: "row-numbers",
|
|
2077
|
+
message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
1529
2080
|
|
|
1530
2081
|
const labels = data.rows.map((row) => stringifyText(row?.[0]));
|
|
1531
2082
|
const series = data.columns.slice(1).map((name, seriesIndex) => ({
|
|
@@ -1533,11 +2084,35 @@ function toPptxChartData(chart) {
|
|
|
1533
2084
|
labels,
|
|
1534
2085
|
values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
|
|
1535
2086
|
}));
|
|
1536
|
-
const mapped = mapChartType(chart.type);
|
|
1537
2087
|
|
|
1538
2088
|
return { ...mapped, series };
|
|
1539
2089
|
}
|
|
1540
2090
|
|
|
2091
|
+
/**
|
|
2092
|
+
* Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
|
|
2093
|
+
* Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
|
|
2094
|
+
* computed by interpolating the edges rather than from a width, and a value is placed by comparing it with the edges.
|
|
2095
|
+
*/
|
|
2096
|
+
function histogramBins(values) {
|
|
2097
|
+
const min = values.reduce((a, b) => Math.min(a, b)), max = values.reduce((a, b) => Math.max(a, b));
|
|
2098
|
+
const count = min === max ? 1 : Math.min(50, Math.ceil(Math.log2(values.length)) + 1);
|
|
2099
|
+
const edge = (index) => index === 0 ? min : index === count ? max : min / count * (count - index) + max / count * index;
|
|
2100
|
+
const bins = Array.from({length: count}, (_, index) => ({low: edge(index), high: edge(index + 1), count: 0}));
|
|
2101
|
+
// A value belongs to the last bin whose lower edge it reaches, so counts always agree with the labelled edges.
|
|
2102
|
+
for (const value of values) {
|
|
2103
|
+
let index = 0;
|
|
2104
|
+
while (index + 1 < count && value >= bins[index + 1].low) index++;
|
|
2105
|
+
bins[index].count++;
|
|
2106
|
+
}
|
|
2107
|
+
// Labels carry six significant digits, and more when that would make two bins read alike.
|
|
2108
|
+
for (let precision = 6; ; precision++) {
|
|
2109
|
+
const format = (value) => String(Number(value.toPrecision(precision)));
|
|
2110
|
+
const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}\u2013${format(bin.high)}`);
|
|
2111
|
+
if (new Set(labels).size === labels.length) return bins.map((bin, index) => ({...bin, label: labels[index]}));
|
|
2112
|
+
if (precision === 17) return bins.map((bin, index) => ({...bin, label: `${labels[index]} (bin ${index + 1})`}));
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
1541
2116
|
function mapChartType(type) {
|
|
1542
2117
|
const normalized = String(type ?? "").toLowerCase();
|
|
1543
2118
|
if (normalized.includes("pie")) return { type: "pie" };
|
|
@@ -1662,6 +2237,21 @@ function referenceId(reference) {
|
|
|
1662
2237
|
return null;
|
|
1663
2238
|
}
|
|
1664
2239
|
|
|
2240
|
+
const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
2241
|
+
|
|
2242
|
+
// Same order as opf-render's socialPlatformRecords so preview and export format
|
|
2243
|
+
// socials identically. Core applies inline document records first; then the
|
|
2244
|
+
// document catalog source (host-supplied options.catalogSources, or the bundled
|
|
2245
|
+
// catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
|
|
2246
|
+
// bundled catalog (the engine default source also resolves to it).
|
|
2247
|
+
function socialPlatformRecords(presentation, options) {
|
|
2248
|
+
const kind = "socialPlatforms", source = presentation.catalogs?.[kind]?.source;
|
|
2249
|
+
const bySource = typeof source === "string" ? options.catalogSources?.[source] : undefined;
|
|
2250
|
+
const sourceRecords = bySource ? normalizeRecords(bySource)
|
|
2251
|
+
: typeof source === "string" && (source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/")) ? defaultCatalog(kind) : [];
|
|
2252
|
+
return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
|
|
2253
|
+
}
|
|
2254
|
+
|
|
1665
2255
|
function defaultCatalog(kind) {
|
|
1666
2256
|
return Array.isArray(bundledCatalogs[kind]) ? bundledCatalogs[kind] : [];
|
|
1667
2257
|
}
|
|
@@ -1697,13 +2287,15 @@ function resolveBackground(value, colorScheme) {
|
|
|
1697
2287
|
if (value.type === "theme" && value.slot) {
|
|
1698
2288
|
return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
1699
2289
|
}
|
|
2290
|
+
// Like the SVG preview, text contrast follows a pattern's background color.
|
|
2291
|
+
if (value.type === "pattern") return normalizeHex(value.pattern?.backgroundColor ?? "#FFFFFF", fallback);
|
|
1700
2292
|
if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
|
|
1701
2293
|
}
|
|
1702
2294
|
return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
1703
2295
|
}
|
|
1704
2296
|
|
|
1705
2297
|
function resolveFonts(fontScheme) {
|
|
1706
|
-
return {id:fontScheme.id,...resolveFontFamilies(fontScheme)};
|
|
2298
|
+
return {id:fontScheme.id,...resolveFontFamilies(fontScheme),scheme:{type:fontScheme.type,major:fontScheme.major,minor:fontScheme.minor}};
|
|
1707
2299
|
}
|
|
1708
2300
|
|
|
1709
2301
|
|
|
@@ -1747,9 +2339,11 @@ async function normalizePptxZip(raw, context) {
|
|
|
1747
2339
|
attachCodeTags(entries, context.codeTags);
|
|
1748
2340
|
attachMetricTags(entries,context.metricTags);
|
|
1749
2341
|
attachCardTags(entries,context.cardTags);
|
|
2342
|
+
attachMediaTags(entries,context.mediaTags);
|
|
1750
2343
|
attachHeadingTags(entries,context.headingTags);
|
|
1751
2344
|
attachPlainTextTags(entries,context.plainTextTags);
|
|
1752
2345
|
attachTimelineTags(entries,context.timelineTags);
|
|
2346
|
+
attachFurnitureFields(entries,context.furnitureFields);
|
|
1753
2347
|
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
|
|
1754
2348
|
for(const [part,bytes]of Object.entries(entries)){
|
|
1755
2349
|
if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
|
|
@@ -1766,15 +2360,21 @@ async function normalizePptxZip(raw, context) {
|
|
|
1766
2360
|
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>`)));
|
|
1767
2361
|
}
|
|
1768
2362
|
}
|
|
2363
|
+
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
2364
|
+
applyPitchFamilies(entries,context.fontPitch);
|
|
1769
2365
|
const imageSources = new Map();
|
|
1770
2366
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
1771
2367
|
if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
|
|
1772
2368
|
const relationships = parseRelationships(entries, part);
|
|
1773
2369
|
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
1774
|
-
const
|
|
2370
|
+
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
2371
|
+
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name);
|
|
1775
2372
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
1776
2373
|
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
1777
2374
|
}
|
|
2375
|
+
const background = context.backgroundFills.get(part)?.image;
|
|
2376
|
+
const backgroundId = background && decodeText(bytes).match(/<p:bg>[\s\S]*?<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
2377
|
+
if (backgroundId) imageSources.set(relationships.get(backgroundId)?.path, background.path);
|
|
1778
2378
|
}
|
|
1779
2379
|
const imageMetadata = new Map();
|
|
1780
2380
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
@@ -1793,6 +2393,21 @@ async function normalizePptxZip(raw, context) {
|
|
|
1793
2393
|
}
|
|
1794
2394
|
imageMetadata.set(part, metadata);
|
|
1795
2395
|
}
|
|
2396
|
+
// Slide images need the embedded dimensions; document provenance (FF-32)
|
|
2397
|
+
// later records the placed frames as slide geometry evidence.
|
|
2398
|
+
placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2399
|
+
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
2400
|
+
});
|
|
2401
|
+
// Charts and notes follow the language and fonts of the slide they belong to.
|
|
2402
|
+
context.partSlides = new Map();
|
|
2403
|
+
for (const part of Object.keys(entries)) {
|
|
2404
|
+
const slide = /^ppt\/slides\/slide(\d+)\.xml$/.exec(part);
|
|
2405
|
+
if (!slide) continue;
|
|
2406
|
+
context.partSlides.set(part, Number(slide[1]) - 1);
|
|
2407
|
+
for (const relationship of parseRelationships(entries, part).values()) {
|
|
2408
|
+
if (/\/(?:chart|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
1796
2411
|
const output = {};
|
|
1797
2412
|
const renameMaps = buildRenameMaps(Object.keys(entries));
|
|
1798
2413
|
// The host may transform assets or supply a filename/MIME hint that no
|
|
@@ -1816,16 +2431,55 @@ async function normalizePptxZip(raw, context) {
|
|
|
1816
2431
|
}];
|
|
1817
2432
|
}
|
|
1818
2433
|
|
|
2434
|
+
finalizeFontsUsed(output);
|
|
2435
|
+
// Document references record evidence from the final normalized parts.
|
|
2436
|
+
if (context.documentProvenance) {
|
|
2437
|
+
const parts = Object.fromEntries(Object.entries(output).map(([path, [bytes]]) => [path, bytes]));
|
|
2438
|
+
try {
|
|
2439
|
+
attachDocumentProvenance(parts, context.documentProvenance);
|
|
2440
|
+
} catch (error) {
|
|
2441
|
+
throw new OPFPptxError("packaging-failed", "Document provenance tags could not be attached.", {cause: errorMessage(error)});
|
|
2442
|
+
}
|
|
2443
|
+
for (const [path, bytes] of Object.entries(parts)) output[path] = [bytes, {level: context.compressionLevel, mtime: context.zipDate}];
|
|
2444
|
+
}
|
|
2445
|
+
|
|
1819
2446
|
// Sort after chart/worksheet renaming; source counters can cross digit widths.
|
|
1820
2447
|
const sortedOutput = Object.fromEntries(Object.keys(output).sort().map(path => [path, output[path]]));
|
|
1821
|
-
return zipSync(sortedOutput, {
|
|
2448
|
+
return stampGeneratedZip(zipSync(sortedOutput, {
|
|
1822
2449
|
level: context.compressionLevel,
|
|
1823
2450
|
mtime: context.zipDate
|
|
2451
|
+
}), context.zipDateStamp);
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
function preserveCarriageReturns(xml, textElements) {
|
|
2455
|
+
// Only our generated text content is rewritten. Escaped literal entity text
|
|
2456
|
+
// remains escaped, and XML formatting outside these elements is untouched.
|
|
2457
|
+
return xml.replace(/(<([\w:]+)\b[^>]*>)([^<]*)(<\/\2>)/g, (element, open, name, text, close) =>
|
|
2458
|
+
textElements.has(name) ? open + text.replace(/\r/g, ' ') + close : element);
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
const CORE_TEXT_ELEMENTS = new Set(['dc:title', 'dc:subject', 'dc:description', 'dc:creator']);
|
|
2462
|
+
|
|
2463
|
+
function preserveGeneratedNotes(xml, notes, path) {
|
|
2464
|
+
// PptxGenJS changes LF to CRLF. For authored CR, write the original text into
|
|
2465
|
+
// its one generated native notes body; nothing is retained in hidden tags.
|
|
2466
|
+
const text = notes.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
2467
|
+
.replace(/"/g, '"').replace(/'/g, ''').replace(/\r/g, ' ');
|
|
2468
|
+
let bodies = 0, texts = 0;
|
|
2469
|
+
const output = xml.replace(/<p:sp\b[^>]*>[\s\S]*?<\/p:sp>/g, shape => {
|
|
2470
|
+
if (!/<p:ph\b[^>]*\btype="body"/.test(shape)) return shape;
|
|
2471
|
+
bodies++;
|
|
2472
|
+
return shape.replace(/(<a:t\b[^>]*>)[^<]*(<\/a:t>)/g, (_, open, close) => {
|
|
2473
|
+
texts++;
|
|
2474
|
+
return open + text + close;
|
|
2475
|
+
});
|
|
1824
2476
|
});
|
|
2477
|
+
if (bodies !== 1 || texts !== 1) throw new OPFPptxError('packaging-failed', 'Generated notes must have one body text element.', {path});
|
|
2478
|
+
return output;
|
|
1825
2479
|
}
|
|
1826
2480
|
|
|
1827
2481
|
function normalizeCoreProperties(xml, timestamp) {
|
|
1828
|
-
return xml
|
|
2482
|
+
return preserveCarriageReturns(xml, CORE_TEXT_ELEMENTS)
|
|
1829
2483
|
.replace(/<dcterms:created xsi:type="dcterms:W3CDTF">[^<]*<\/dcterms:created>/g, `<dcterms:created xsi:type="dcterms:W3CDTF">${timestamp}</dcterms:created>`)
|
|
1830
2484
|
.replace(/<dcterms:modified xsi:type="dcterms:W3CDTF">[^<]*<\/dcterms:modified>/g, `<dcterms:modified xsi:type="dcterms:W3CDTF">${timestamp}</dcterms:modified>`);
|
|
1831
2485
|
}
|
|
@@ -1840,6 +2494,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
1840
2494
|
}
|
|
1841
2495
|
if (isXmlPart(path)) {
|
|
1842
2496
|
let xml=decodeText(bytes);
|
|
2497
|
+
if (context.notesWithCarriageReturns.has(path)) xml = preserveGeneratedNotes(xml, context.notesWithCarriageReturns.get(path), path);
|
|
1843
2498
|
if (path === '[Content_Types].xml') {
|
|
1844
2499
|
// PptxGenJS 4.0.1 emits one slide-master override per slide even
|
|
1845
2500
|
// though it creates only the actual master parts. Omit phantom master
|
|
@@ -1851,9 +2506,22 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
1851
2506
|
`<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
|
|
1852
2507
|
xml = xml.replace('</Types>', `${overrides}</Types>`);
|
|
1853
2508
|
}
|
|
2509
|
+
if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
|
|
2510
|
+
if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
|
|
1854
2511
|
if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
|
|
1855
2512
|
const fill = context.backgroundFills.get(path);
|
|
1856
|
-
if (fill) xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
|
|
2513
|
+
if (typeof fill === 'string') xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
|
|
2514
|
+
else if (fill?.image) {
|
|
2515
|
+
// Fit the exact raster PptxGenJS embedded for this slide background.
|
|
2516
|
+
const backgroundRelationships = parseRelationships(entries, path);
|
|
2517
|
+
xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, background => {
|
|
2518
|
+
const id = background.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
2519
|
+
const metadata = imageMetadata.get(backgroundRelationships.get(id)?.path);
|
|
2520
|
+
if (!id || !metadata) throw new OPFPptxError("unsupported-image-dimensions", "Background image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path: fill.image.path });
|
|
2521
|
+
if ((metadata.orientation ?? 1) !== 1) fill.image.report?.({code: 'unsupported-background-image-orientation', path: fill.image.path, message: 'A slide background picture fill cannot rotate or mirror its image; the JPEG EXIF orientation is not applied in the native background.'});
|
|
2522
|
+
return `<p:bg><p:bgPr>${nativeImageBackgroundFill(id, metadata, fill.image)}<a:effectLst/></p:bgPr></p:bg>`;
|
|
2523
|
+
});
|
|
2524
|
+
}
|
|
1857
2525
|
// PptxGenJS table IDs can collide with other objects on the same slide.
|
|
1858
2526
|
// Preserve existing IDs and allocate unused IDs only for duplicates. This
|
|
1859
2527
|
// export path creates no connector attachments or animation ID references.
|
|
@@ -1913,7 +2581,8 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
1913
2581
|
if (!border) continue;
|
|
1914
2582
|
const borderHex = exportColor(border.color, context, context.colors.border);
|
|
1915
2583
|
const borderOpacity = borderHex.length === 8 ? parseInt(borderHex.slice(6), 16) / 255 : 1;
|
|
1916
|
-
const
|
|
2584
|
+
const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, context) : undefined;
|
|
2585
|
+
const fill = border.width === 0 ? '<a:noFill/>' : borderScheme ? `<a:solidFill><a:schemeClr val="${borderScheme}"/></a:solidFill>` : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1}, context.colors.border);
|
|
1917
2586
|
const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
|
|
1918
2587
|
const line = `<a:${native} w="${Math.round(border.width * table.scale * 9525)}" cap="flat" cmpd="sng" algn="ctr">${fill}<a:prstDash val="${dash}"/></a:${native}>`;
|
|
1919
2588
|
const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
|
|
@@ -1946,11 +2615,19 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
1946
2615
|
// Native bullets otherwise inherit the first rich run's size, font and
|
|
1947
2616
|
// color, which can differ from the measured list marker.
|
|
1948
2617
|
xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
|
|
1949
|
-
const marker=context.listMarkers.get(shape.match(/name="(OPF list
|
|
2618
|
+
const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
|
|
1950
2619
|
if(!marker)return shape;
|
|
1951
2620
|
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
1952
2621
|
return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr><a:srgbClr val="${marker.color}"/></a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
|
|
1953
2622
|
});
|
|
2623
|
+
// PptxGenJS gives shapes no alternative text. An unavailable image's panel
|
|
2624
|
+
// carries the accessible name the preview gives its group.
|
|
2625
|
+
xml=xml.replace(/<p:cNvPr id="(\d+)" name="(OPF image placeholder \d+)">/g,(node,id,name)=>{
|
|
2626
|
+
const description=context.imagePlaceholders.get(name);
|
|
2627
|
+
if(description===undefined)return node;
|
|
2628
|
+
const escapes={'&':'&','<':'<','>':'>','"':'"',"'":''','\r':' ','\n':' ','\t':'	'};
|
|
2629
|
+
return `<p:cNvPr id="${id}" name="${name}" descr="${description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char])}">`;
|
|
2630
|
+
});
|
|
1954
2631
|
// PptxGenJS 4 emits pPr before each rich run. OOXML allows one pPr,
|
|
1955
2632
|
// before all runs. Paragraph options belong to the first run.
|
|
1956
2633
|
xml=xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g,(_,body)=>{
|
|
@@ -1959,11 +2636,26 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
1959
2636
|
return `<a:p>${properties}${content}</a:p>`;
|
|
1960
2637
|
});
|
|
1961
2638
|
}
|
|
2639
|
+
// theme1.xml: FF-24 colors and names above, then FF-07 fonts here; they touch disjoint elements.
|
|
2640
|
+
if (context.scriptFonts) xml = partScriptFonts(path, xml, context.scriptFonts, context.partSlides.get(path) ?? 0);
|
|
1962
2641
|
return encodeText(normalizePartReferences(xml, renameMaps));
|
|
1963
2642
|
}
|
|
1964
2643
|
return bytes;
|
|
1965
2644
|
}
|
|
1966
2645
|
|
|
2646
|
+
// The vendored PptxGenJS 4.0.1 master hard-codes Arial as the bullet font on
|
|
2647
|
+
// all nine bodyStyle levels, while each level's text already uses +mn-lt.
|
|
2648
|
+
// Point those bullets at the same theme minor (body) font so a document's
|
|
2649
|
+
// fontScheme also governs master bullets. a:buFont is CT_TextFont, like
|
|
2650
|
+
// a:latin, so the theme reference is schema-valid. Slide list markers keep
|
|
2651
|
+
// their explicit per-shape buFont; only the master bodyStyle is touched.
|
|
2652
|
+
const VENDOR_MASTER_BULLET_FONT = '<a:buFont typeface="Arial" pitchFamily="34" charset="0"/>';
|
|
2653
|
+
const THEME_MINOR_BULLET_FONT = '<a:buFont typeface="+mn-lt"/>';
|
|
2654
|
+
function themeMasterBulletFonts(xml) {
|
|
2655
|
+
return xml.replace(/<p:bodyStyle>[\s\S]*?<\/p:bodyStyle>/, bodyStyle =>
|
|
2656
|
+
bodyStyle.split(VENDOR_MASTER_BULLET_FONT).join(THEME_MINOR_BULLET_FONT));
|
|
2657
|
+
}
|
|
2658
|
+
|
|
1967
2659
|
function isXmlPart(path) {
|
|
1968
2660
|
return path.endsWith(".xml") || path.endsWith(".rels") || path === "[Content_Types].xml";
|
|
1969
2661
|
}
|
|
@@ -2021,10 +2713,82 @@ function normalizeNestedZip(bytes, context) {
|
|
|
2021
2713
|
mtime: context.zipDate
|
|
2022
2714
|
}];
|
|
2023
2715
|
}
|
|
2024
|
-
return zipSync(output, {
|
|
2716
|
+
return stampGeneratedZip(zipSync(output, {
|
|
2025
2717
|
level: context.compressionLevel,
|
|
2026
2718
|
mtime: context.zipDate
|
|
2027
|
-
});
|
|
2719
|
+
}), context.zipDateStamp);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// Explicit ZIP dates use UTC calendar fields. fflate clones mtime and reads
|
|
2723
|
+
// local fields, so passing a Date cannot express this reliably (including DST
|
|
2724
|
+
// gaps and skipped civil days). Leave the established default path unchanged.
|
|
2725
|
+
function resolveZipDateStamp(value) {
|
|
2726
|
+
if (value === undefined) return null;
|
|
2727
|
+
const invalid = () => {
|
|
2728
|
+
throw new OPFPptxError('invalid-zip-date', 'zipDate must be a valid Date, finite epoch milliseconds, an ISO date, or an ISO datetime with Z/offset, within UTC years 1980–2099.', {path: 'options.zipDate'});
|
|
2729
|
+
};
|
|
2730
|
+
let time;
|
|
2731
|
+
if (typeof value === 'string') {
|
|
2732
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|[+-]\d{2}:\d{2}))?$/.exec(value);
|
|
2733
|
+
if (!match) invalid();
|
|
2734
|
+
const [, year, month, day, hour = '00', minute = '00', second = '00', fraction = '', zone = 'Z'] = match;
|
|
2735
|
+
const parts = [year, month, day, hour, minute, second].map(Number);
|
|
2736
|
+
if (parts[1] < 1 || parts[1] > 12 || parts[2] < 1 || parts[3] > 23 || parts[4] > 59 || parts[5] > 59) invalid();
|
|
2737
|
+
const date = new Date(0);
|
|
2738
|
+
date.setUTCFullYear(parts[0], parts[1] - 1, parts[2]);
|
|
2739
|
+
date.setUTCHours(parts[3], parts[4], parts[5], Number(fraction.slice(0, 3).padEnd(3, '0')));
|
|
2740
|
+
if (date.getUTCFullYear() !== parts[0] || date.getUTCMonth() !== parts[1] - 1 || date.getUTCDate() !== parts[2]) invalid();
|
|
2741
|
+
const offsetHours = zone === 'Z' ? 0 : Number(zone.slice(1, 3));
|
|
2742
|
+
const offsetMinutes = zone === 'Z' ? 0 : Number(zone.slice(4, 6));
|
|
2743
|
+
if (offsetHours > 23 || offsetMinutes > 59) invalid();
|
|
2744
|
+
const offset = (offsetHours * 60 + offsetMinutes) * (zone[0] === '-' ? -1 : 1);
|
|
2745
|
+
time = date.getTime() - offset * 60000;
|
|
2746
|
+
} else if (typeof value === 'number') {
|
|
2747
|
+
time = value;
|
|
2748
|
+
} else {
|
|
2749
|
+
// The native brand check accepts cross-realm Dates without coercing objects.
|
|
2750
|
+
try { time = Date.prototype.getTime.call(value); } catch { invalid(); }
|
|
2751
|
+
}
|
|
2752
|
+
const date = new Date(time);
|
|
2753
|
+
const year = date.getUTCFullYear();
|
|
2754
|
+
if (!Number.isFinite(time) || !Number.isFinite(year) || year < 1980 || year > 2099) invalid();
|
|
2755
|
+
return (((year - 1980) << 25) | ((date.getUTCMonth() + 1) << 21) | (date.getUTCDate() << 16) |
|
|
2756
|
+
(date.getUTCHours() << 11) | (date.getUTCMinutes() << 5) | (date.getUTCSeconds() >> 1)) >>> 0;
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
function stampGeneratedZip(bytes, stamp) {
|
|
2760
|
+
if (stamp === null) return bytes;
|
|
2761
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
2762
|
+
const fail = () => { throw new OPFPptxError('packaging-failed', 'Generated ZIP headers could not be timestamped safely.'); };
|
|
2763
|
+
const end = bytes.length - 22;
|
|
2764
|
+
if (end < 0 || view.getUint32(end, true) !== 0x06054b50 || view.getUint16(end + 4, true) !== 0 ||
|
|
2765
|
+
view.getUint16(end + 6, true) !== 0 || view.getUint16(end + 20, true) !== 0) fail();
|
|
2766
|
+
const count = view.getUint16(end + 10, true);
|
|
2767
|
+
const start = view.getUint32(end + 16, true);
|
|
2768
|
+
if (count !== view.getUint16(end + 8, true) || start + view.getUint32(end + 12, true) !== end) fail();
|
|
2769
|
+
const positions = [];
|
|
2770
|
+
let central = start, localEnd = 0;
|
|
2771
|
+
for (let i = 0; i < count; i++) {
|
|
2772
|
+
if (central + 46 > end || view.getUint32(central, true) !== 0x02014b50) fail();
|
|
2773
|
+
const nameSize = view.getUint16(central + 28, true);
|
|
2774
|
+
const next = central + 46 + nameSize + view.getUint16(central + 30, true) + view.getUint16(central + 32, true);
|
|
2775
|
+
const local = view.getUint32(central + 42, true);
|
|
2776
|
+
if (next > end || local !== localEnd || local + 30 > start || view.getUint32(local, true) !== 0x04034b50 ||
|
|
2777
|
+
view.getUint16(local + 26, true) !== nameSize || view.getUint16(central + 34, true) !== 0) fail();
|
|
2778
|
+
const content = local + 30 + nameSize + view.getUint16(local + 28, true);
|
|
2779
|
+
localEnd = content + view.getUint32(central + 20, true);
|
|
2780
|
+
if (content > start || localEnd > start || view.getUint32(local + 18, true) !== view.getUint32(central + 20, true) ||
|
|
2781
|
+
view.getUint32(local + 14, true) !== view.getUint32(central + 16, true) ||
|
|
2782
|
+
view.getUint32(local + 22, true) !== view.getUint32(central + 24, true) ||
|
|
2783
|
+
view.getUint16(local + 6, true) !== view.getUint16(central + 8, true) ||
|
|
2784
|
+
view.getUint16(local + 8, true) !== view.getUint16(central + 10, true)) fail();
|
|
2785
|
+
for (let j = 0; j < nameSize; j++) if (bytes[local + 30 + j] !== bytes[central + 46 + j]) fail();
|
|
2786
|
+
positions.push(local + 10, central + 12);
|
|
2787
|
+
central = next;
|
|
2788
|
+
}
|
|
2789
|
+
if (central !== end || localEnd !== start) fail();
|
|
2790
|
+
for (const position of positions) view.setUint32(position, stamp, true);
|
|
2791
|
+
return bytes;
|
|
2028
2792
|
}
|
|
2029
2793
|
|
|
2030
2794
|
function escapeRegExp(value) {
|
|
@@ -2063,16 +2827,15 @@ function stringifyText(value) {
|
|
|
2063
2827
|
return String(value);
|
|
2064
2828
|
}
|
|
2065
2829
|
|
|
2066
|
-
|
|
2830
|
+
/** The number a chart cell holds ("12%", "$5" and "1,234" parse), or null when it holds none. */
|
|
2831
|
+
function parsedNumber(value) {
|
|
2067
2832
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
2068
2833
|
const parsed = Number.parseFloat(String(value ?? "").replace(/[^0-9.-]/g, ""));
|
|
2069
|
-
return Number.isFinite(parsed) ? parsed :
|
|
2834
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
2070
2835
|
}
|
|
2071
2836
|
|
|
2072
|
-
function
|
|
2073
|
-
|
|
2074
|
-
if (text.length > 160) return `${text.slice(0, 157)}...`;
|
|
2075
|
-
return text;
|
|
2837
|
+
function numericValue(value) {
|
|
2838
|
+
return parsedNumber(value) ?? 0;
|
|
2076
2839
|
}
|
|
2077
2840
|
|
|
2078
2841
|
function normalizeAuthor(author) {
|