@openpresentation/opf-pptx 0.9.1 → 0.10.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/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 +869 -106
- package/dist/media-provenance.js +237 -0
- package/dist/native-text-style.js +57 -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 +14 -7
package/dist/index.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
import {nativeBodyReader, joinNativeParagraphs} from './body-text-import.js';
|
|
1
2
|
import {importTableFrames} from './table-import.js';
|
|
3
|
+
import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
|
|
2
4
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
3
5
|
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
4
6
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
5
7
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
8
|
+
import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
|
|
6
9
|
import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
7
10
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
8
11
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
9
|
-
import {attachFurnitureTags, furnitureManifest, importFurniture} from './furniture-provenance.js';
|
|
12
|
+
import {attachFurnitureTags, furnitureManifest, importFurniture, staticDateFallback} from './furniture-provenance.js';
|
|
13
|
+
import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, restoreDocumentProvenance} from './document-provenance.js';
|
|
14
|
+
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
10
15
|
import {importImageOrientation} from './image-import.js';
|
|
11
|
-
import {
|
|
16
|
+
import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
|
|
17
|
+
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
12
18
|
import {importBackground} from './background-import.js';
|
|
19
|
+
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
20
|
+
import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
|
|
13
21
|
import { webpToPng } from '#image-fallback';
|
|
14
22
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
15
23
|
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
@@ -22,6 +30,8 @@ import {
|
|
|
22
30
|
validatePresentation
|
|
23
31
|
} from "@openpresentation/opf";
|
|
24
32
|
|
|
33
|
+
export {checkPptxTypefaces, inventoryPptxTypefaces, packageFontsUsed, THEME_SCRIPT_SUPPLEMENTS} from './typeface-inventory.js';
|
|
34
|
+
|
|
25
35
|
export const packageName = "@openpresentation/opf-pptx";
|
|
26
36
|
|
|
27
37
|
export const releaseLane = Object.freeze({
|
|
@@ -57,6 +67,9 @@ const FIXED_TIMESTAMP = "1980-01-01T00:00:00Z";
|
|
|
57
67
|
// local components: a UTC instant rolls back to 1979 west of UTC (below
|
|
58
68
|
// fflate's 1980 floor) and would otherwise yield timezone-dependent bytes.
|
|
59
69
|
const FIXED_ZIP_DATE = new Date(1980, 0, 1, 0, 0, 0);
|
|
70
|
+
// Explicit stamps overwrite these fields after ZIP generation. Keep the
|
|
71
|
+
// temporary instant well inside fflate's range even if the host changes TZ.
|
|
72
|
+
const EXPLICIT_ZIP_SENTINEL = new Date('2000-01-01T12:00:00Z');
|
|
60
73
|
const DEFAULT_SEED = 0x4f504658;
|
|
61
74
|
const CANONICAL_SCHEMA = "https://openpresentation.org/schema/opf/v1";
|
|
62
75
|
const EMUS_PER_INCH = 914400;
|
|
@@ -69,6 +82,12 @@ const xmlParser = new XMLParser({
|
|
|
69
82
|
parseTagValue: false,
|
|
70
83
|
trimValues: false
|
|
71
84
|
});
|
|
85
|
+
// Keep numeric character references (notably CR) in native core text. This is
|
|
86
|
+
// scoped to properties; the ordinary slide/relationship parser is unchanged.
|
|
87
|
+
const coreTextParser = new XMLParser({
|
|
88
|
+
ignoreAttributes: false, attributeNamePrefix: "", textNodeName: "#text",
|
|
89
|
+
parseAttributeValue: false, parseTagValue: false, trimValues: false, htmlEntities: true
|
|
90
|
+
});
|
|
72
91
|
|
|
73
92
|
const ROOT_PAYLOAD_FIELDS = [
|
|
74
93
|
"text",
|
|
@@ -148,6 +167,7 @@ const DIMENSION_PRESETS = Object.freeze({
|
|
|
148
167
|
const DEFAULTS = Object.freeze({
|
|
149
168
|
theme: "minimal",
|
|
150
169
|
colorScheme: "cool-horizon",
|
|
170
|
+
// Shared last-resort font scheme (core DEFAULT_FONT_SCHEME): every engine uses aptos.
|
|
151
171
|
fontScheme: "aptos"
|
|
152
172
|
});
|
|
153
173
|
|
|
@@ -170,25 +190,45 @@ export async function toPptx(input, options = {}) {
|
|
|
170
190
|
if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
|
|
171
191
|
throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
|
|
172
192
|
}
|
|
193
|
+
if (options.provenance !== undefined && !['full', 'references-only', false].includes(options.provenance)) {
|
|
194
|
+
throw new OPFPptxError('invalid-provenance-option', "provenance must be 'full', 'references-only' or false.", {path: 'options.provenance'});
|
|
195
|
+
}
|
|
173
196
|
const presentation = parseInput(input);
|
|
174
197
|
assertValidBoundary(presentation);
|
|
198
|
+
options = {...options, textMeasurement: chosenFamilyMeasurement(options.textMeasurement)};
|
|
175
199
|
|
|
176
200
|
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
177
201
|
context.listMarkers = new Map();
|
|
202
|
+
context.imagePlaceholders = new Map();
|
|
178
203
|
context.tableHeaders = new Map();
|
|
179
204
|
context.tableCells = new Map();
|
|
180
205
|
context.imagePlacements = new Map();
|
|
206
|
+
context.slideImages = new Map();
|
|
181
207
|
context.backgroundFills = new Map();
|
|
208
|
+
context.notesWithCarriageReturns = new Map();
|
|
182
209
|
context.cardTags = new Map();
|
|
210
|
+
context.mediaTags = new Map();
|
|
211
|
+
context.provenanceMode = options.provenance ?? "full";
|
|
183
212
|
context.headingTags = new Map();
|
|
184
213
|
context.plainTextTags = new Map();
|
|
185
214
|
context.timelineTags = new Map();
|
|
186
215
|
context.furnitureTags = new Map();
|
|
216
|
+
context.furnitureFields = new Map();
|
|
187
217
|
context.furnitureManifests = new Map();
|
|
188
218
|
context.codeTags = new Map();
|
|
189
219
|
context.metricTags = new Map();
|
|
190
220
|
context.chartHeadings = new Map();
|
|
221
|
+
context.chartFonts = new Map();
|
|
191
222
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
223
|
+
Object.assign(context, exportTheme(presentation, context));
|
|
224
|
+
context.reportedFontSchemes = new Set();
|
|
225
|
+
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
226
|
+
context.documentProvenance = options.provenance === false ? null : documentProvenance(presentation, {
|
|
227
|
+
mode: options.provenance ?? "full",
|
|
228
|
+
isCatalogId: (kind, id) => !!(findById(normalizeRecords(presentation.catalogs?.[kind]), id) ?? findById(defaultCatalog(kind), id)),
|
|
229
|
+
report: diagnostic => options.onDiagnostic?.(diagnostic)
|
|
230
|
+
});
|
|
231
|
+
context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
|
|
192
232
|
const pptx = new PptxGenJS();
|
|
193
233
|
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
194
234
|
|
|
@@ -196,6 +236,10 @@ export async function toPptx(input, options = {}) {
|
|
|
196
236
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
197
237
|
}
|
|
198
238
|
|
|
239
|
+
// FF-08: pitchFamily per exported family, from each slide's resolved scheme.
|
|
240
|
+
context.fontPitch = fontPitchFamilies(presentation.slides.map((slide, index) => resolveSlideContext(presentation, slide, context, options, index).fonts),
|
|
241
|
+
[...normalizeRecords(presentation.catalogs?.fontSchemes), ...defaultCatalog("fontSchemes")]);
|
|
242
|
+
|
|
199
243
|
let raw;
|
|
200
244
|
try {
|
|
201
245
|
raw = await withDeterministicRandom(context.seed, () => pptx.write({
|
|
@@ -227,15 +271,24 @@ export async function fromPptx(input, options = {}) {
|
|
|
227
271
|
|
|
228
272
|
const core = readCoreProperties(entries);
|
|
229
273
|
const dimensions = dimensionsFromPresentation(presentationRoot);
|
|
230
|
-
|
|
274
|
+
let imported = {
|
|
231
275
|
$schema: options.schema ?? CANONICAL_SCHEMA,
|
|
232
276
|
name: core.title || options.fallbackName || "Imported PPTX",
|
|
233
277
|
slides: []
|
|
234
278
|
};
|
|
235
279
|
|
|
280
|
+
// FF-07: the language the runs carry. A stored FF-32 reference can still win below.
|
|
281
|
+
const observedLanguage = observeLanguage({
|
|
282
|
+
slides: slidePaths.map(path => decodeText(entries[path])),
|
|
283
|
+
theme: (path => path && entries[path] ? decodeText(entries[path]) : null)(presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries)),
|
|
284
|
+
catalogs: bundledCatalogs
|
|
285
|
+
});
|
|
286
|
+
if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
|
|
236
287
|
if (core.description) imported.description = core.description;
|
|
237
288
|
if (core.author) imported.author = core.author;
|
|
238
|
-
|
|
289
|
+
const themeDesign = importThemeDesign(entries, presentationRoot, presentationRels);
|
|
290
|
+
const design = {...themeDesign.design, ...(dimensions ? {dimensions} : {})};
|
|
291
|
+
if (Object.keys(design).length) imported.design = design;
|
|
239
292
|
|
|
240
293
|
const furnitureContexts = slidePaths.map((slidePath, index) => {
|
|
241
294
|
const root = parseRequiredXml(entries, slidePath)['p:sld'];
|
|
@@ -250,9 +303,42 @@ export async function fromPptx(input, options = {}) {
|
|
|
250
303
|
if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
|
|
251
304
|
if (furniture.organization) imported.organization = furniture.organization;
|
|
252
305
|
|
|
306
|
+
const mediaRegistry = Object.create(null);
|
|
253
307
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
308
|
+
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
254
309
|
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
255
310
|
}
|
|
311
|
+
// Conflicting media asset IDs fall back to their current native URLs during import.
|
|
312
|
+
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};
|
|
313
|
+
// Report after the slides so slide diagnostics keep their established order.
|
|
314
|
+
// A theme name FF-24 could not verify is not reported when the stored reference restores the theme.
|
|
315
|
+
for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code !== "theme-unverified") options.onDiagnostic?.(diagnostic);
|
|
316
|
+
|
|
317
|
+
// Catalog references, layout ids and authoring metadata recorded at export
|
|
318
|
+
// (FF-32). Stored references win while the package still matches them;
|
|
319
|
+
// otherwise the observed values (including FF-24's theme recovery) stay and
|
|
320
|
+
// a diagnostic names the reference. A restored field that does not validate
|
|
321
|
+
// is dropped on its own.
|
|
322
|
+
const report = diagnostic => options.onDiagnostic?.(diagnostic);
|
|
323
|
+
// Per slide: {layout, structure: 'match' | 'changed' | 'untagged', record, catalogRecord}.
|
|
324
|
+
// Layout-structure recovery (FF-29) reads the stored OPF_SLIDE_V1 record from
|
|
325
|
+
// here instead of writing a second slide tag.
|
|
326
|
+
let slideProvenance = slidePaths.map(() => ({structure: "untagged"}));
|
|
327
|
+
try {
|
|
328
|
+
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
|
|
329
|
+
// Host catalogs format stored socials exactly as export did (FF-34).
|
|
330
|
+
socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
|
|
331
|
+
slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships}))}, report);
|
|
332
|
+
// The stored language wins while the runs still carry its tag (FF-07).
|
|
333
|
+
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
334
|
+
imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
|
|
335
|
+
slideProvenance = restored.slides;
|
|
336
|
+
} catch (error) {
|
|
337
|
+
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
338
|
+
}
|
|
339
|
+
if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
|
|
340
|
+
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
341
|
+
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
256
342
|
|
|
257
343
|
const result = validatePresentation(imported);
|
|
258
344
|
if (!result.valid) {
|
|
@@ -364,7 +450,7 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
|
|
|
364
450
|
}
|
|
365
451
|
|
|
366
452
|
function compareSlidePaths(left, right) {
|
|
367
|
-
return slideNumber(left) - slideNumber(right) || left
|
|
453
|
+
return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
|
|
368
454
|
}
|
|
369
455
|
|
|
370
456
|
function slideNumber(path) {
|
|
@@ -372,12 +458,12 @@ function slideNumber(path) {
|
|
|
372
458
|
}
|
|
373
459
|
|
|
374
460
|
function readCoreProperties(entries) {
|
|
375
|
-
const doc =
|
|
461
|
+
const doc = entries["docProps/core.xml"] ? parseRequiredXml(entries, "docProps/core.xml", coreTextParser) : null;
|
|
376
462
|
const core = doc?.["cp:coreProperties"] ?? {};
|
|
377
463
|
return {
|
|
378
|
-
title: scalarText(core["dc:title"])
|
|
379
|
-
description: scalarText(core["dc:description"] ?? core["dc:subject"])
|
|
380
|
-
author: scalarText(core["dc:creator"])
|
|
464
|
+
title: scalarText(core["dc:title"]),
|
|
465
|
+
description: scalarText(core["dc:description"] ?? core["dc:subject"]),
|
|
466
|
+
author: scalarText(core["dc:creator"])
|
|
381
467
|
};
|
|
382
468
|
}
|
|
383
469
|
|
|
@@ -405,6 +491,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
405
491
|
if (background) slide.design = {background};
|
|
406
492
|
if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
|
|
407
493
|
if (furniture.section !== undefined) slide.section = furniture.section;
|
|
494
|
+
const slideImagePath = `slides.${slideIndex}.design.slideImage`;
|
|
495
|
+
const slideImage = importSlideImage(nativeContext.pictures, nativeContext.shapes, relationships, entries, slideIndex,
|
|
496
|
+
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
497
|
+
// The recorded crop/fit is re-derived from design.slideImage.
|
|
498
|
+
if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: slideImagePath});
|
|
499
|
+
}),
|
|
500
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: slideImagePath}));
|
|
501
|
+
if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
|
|
502
|
+
nativeContext.slideImagePictures = slideImage.consumed;
|
|
503
|
+
nativeContext.slideImageShapes = slideImage.consumedShapes;
|
|
408
504
|
|
|
409
505
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
|
|
410
506
|
.sort(comparePositionedItems);
|
|
@@ -419,6 +515,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
419
515
|
const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
|
|
420
516
|
if (subtitleItem) slide.subtitle = subtitleItem.text;
|
|
421
517
|
|
|
518
|
+
// Heading inference keeps its existing scalar/native metrics. Only remaining
|
|
519
|
+
// ordinary body shapes gain current rich values; tagged recovery is separate.
|
|
520
|
+
for (const item of items) if (item.readNativeBody) {
|
|
521
|
+
const current = item.readNativeBody();
|
|
522
|
+
if (current) {
|
|
523
|
+
item.paragraphs = current.map((paragraph, index) => ({...item.paragraphs[index], ...paragraph}));
|
|
524
|
+
item.text = current.map(paragraph => paragraph.text).join('\n');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
422
527
|
const blocks = mergeAdjacentBulletShapes(items)
|
|
423
528
|
.map((item) => payloadFromSlideItem(item))
|
|
424
529
|
.filter(Boolean);
|
|
@@ -438,9 +543,14 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
438
543
|
const shapes = nativeContext.shapes;
|
|
439
544
|
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
545
|
const paragraphs = nativeContext.paragraphs;
|
|
546
|
+
const readBody = nativeBodyReader(slidePath, {
|
|
547
|
+
part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
|
|
548
|
+
}, relationships, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.${diagnostic.path}`}));
|
|
441
549
|
const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
|
|
442
550
|
const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
|
|
443
551
|
const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
552
|
+
const media = importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, diagnostic => options.onDiagnostic?.(diagnostic), nativeContext.mediaRegistry);
|
|
553
|
+
nativeContext.mediaAssets = media.assets;
|
|
444
554
|
const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
445
555
|
const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
446
556
|
const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
|
|
@@ -459,13 +569,16 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
459
569
|
}
|
|
460
570
|
for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
461
571
|
for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
572
|
+
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
573
|
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
|
|
574
|
+
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index)) continue;
|
|
575
|
+
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;
|
|
576
|
+
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
577
|
+
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
578
|
+
if (item && ordinaryBody) item.readNativeBody = () => readBody(index);
|
|
466
579
|
// A damaged/edited furniture group falls back to current native text,
|
|
467
580
|
// including cleared text boxes, without inventing a title or shape label.
|
|
468
|
-
if (item && furniture.taggedText.has(index)) item.sourceText = true;
|
|
581
|
+
if (item && (furniture.taggedText.has(index) || media.captionShapes.has(shape))) item.sourceText = true;
|
|
469
582
|
if (item) items.push(item);
|
|
470
583
|
}
|
|
471
584
|
|
|
@@ -480,7 +593,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
480
593
|
}
|
|
481
594
|
|
|
482
595
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
483
|
-
if (furniture.pictures.has(index)) continue;
|
|
596
|
+
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index)) continue;
|
|
484
597
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
485
598
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
486
599
|
if (item) items.push(item);
|
|
@@ -691,12 +804,12 @@ function payloadFromSlideItem(item) {
|
|
|
691
804
|
type: "list",
|
|
692
805
|
items: item.paragraphs.map((paragraph) => (
|
|
693
806
|
paragraph.level > 0
|
|
694
|
-
? { text: paragraph.text, level: paragraph.level }
|
|
695
|
-
: paragraph.text
|
|
807
|
+
? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
|
|
808
|
+
: (paragraph.richText ?? paragraph.text)
|
|
696
809
|
))
|
|
697
810
|
};
|
|
698
811
|
}
|
|
699
|
-
return { type: "text", text: item.
|
|
812
|
+
return { type: "text", text: joinNativeParagraphs(item.paragraphs) };
|
|
700
813
|
}
|
|
701
814
|
if (item.kind === "unknown" && item.text) return { type: "text", text: item.text };
|
|
702
815
|
return null;
|
|
@@ -713,18 +826,29 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
|
713
826
|
if (!chartNode) return null;
|
|
714
827
|
const series = asArray(chartNode.node["c:ser"]);
|
|
715
828
|
if (series.length === 0) return null;
|
|
829
|
+
if (series.length > MAX_CHART_CACHE_POINTS) {
|
|
830
|
+
throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 100,000-series import limit; reduce its series before importing.', {path: relationship.path});
|
|
831
|
+
}
|
|
716
832
|
|
|
717
|
-
const
|
|
718
|
-
const
|
|
719
|
-
const
|
|
720
|
-
const
|
|
833
|
+
const budget = { cells: 0 };
|
|
834
|
+
const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
|
|
835
|
+
const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
|
|
836
|
+
const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
|
|
837
|
+
const values = series.map((entry, index) => {
|
|
838
|
+
const role = entry?.["c:val"] !== undefined ? 'c:val' : 'c:yVal';
|
|
839
|
+
return cachedValues(entry?.[role], cachePath(index, role), budget, numericCacheValue);
|
|
840
|
+
});
|
|
841
|
+
const rowCount = values.reduce((count, row) => Math.max(count, row.length), labels.length);
|
|
721
842
|
if (rowCount === 0) return null;
|
|
843
|
+
if (rowCount * (series.length + 1) > MAX_CHART_CACHE_CELLS) {
|
|
844
|
+
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});
|
|
845
|
+
}
|
|
722
846
|
|
|
723
847
|
const rows = [];
|
|
724
848
|
for (let index = 0; index < rowCount; index += 1) {
|
|
725
849
|
rows.push([
|
|
726
|
-
labels[index] ??
|
|
727
|
-
...values.map((row) => row[index] ??
|
|
850
|
+
labels[index] ?? null,
|
|
851
|
+
...values.map((row) => row[index] ?? null)
|
|
728
852
|
]);
|
|
729
853
|
}
|
|
730
854
|
|
|
@@ -754,39 +878,102 @@ function firstChartNode(plotArea) {
|
|
|
754
878
|
return null;
|
|
755
879
|
}
|
|
756
880
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
881
|
+
const MAX_CHART_CACHE_POINTS = 100_000;
|
|
882
|
+
const MAX_CHART_CACHE_CELLS = 1_000_000;
|
|
883
|
+
|
|
884
|
+
// Import complete decimal exponent tokens without the exporter's legacy
|
|
885
|
+
// character stripping. Other numeric strings deliberately retain that policy.
|
|
886
|
+
function numericCacheValue(value, path) {
|
|
887
|
+
if (value === null || value.trim() === '') return null;
|
|
888
|
+
const token = value.trim();
|
|
889
|
+
const exponent = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))[eE][+-]?\d+$/.exec(token);
|
|
890
|
+
if (!exponent) return numericValue(value);
|
|
891
|
+
const parsed = Number(token);
|
|
892
|
+
if (!Number.isFinite(parsed) || (parsed === 0 && /[1-9]/.test(exponent[1]))) {
|
|
893
|
+
const reason = Number.isFinite(parsed) ? 'underflows to zero' : 'overflows';
|
|
894
|
+
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});
|
|
895
|
+
}
|
|
896
|
+
return parsed;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
// c:pt is sparse and its XML order does not determine the data row. Keep
|
|
900
|
+
// explicit empty strings distinct from missing points, and bound native
|
|
901
|
+
// metadata before allocating a dense OPF table. Only numeric roles convert values.
|
|
902
|
+
function cachedValues(node, path, budget, readValue) {
|
|
903
|
+
const invalid = (message, suffix = '') => {
|
|
904
|
+
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: path + suffix});
|
|
905
|
+
};
|
|
906
|
+
const single = (value, location) => {
|
|
907
|
+
if (value !== undefined && value !== '' && (!value || typeof value !== 'object' || Array.isArray(value))) {
|
|
908
|
+
invalid('cache container must be a single element', location);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
single(node, '');
|
|
912
|
+
for (const reference of ['c:strRef', 'c:numRef', 'c:multiLvlStrRef']) single(node?.[reference], `/${reference}`);
|
|
913
|
+
const candidates = [
|
|
914
|
+
['c:strRef/c:strCache', node?.['c:strRef']?.['c:strCache']],
|
|
915
|
+
['c:numRef/c:numCache', node?.['c:numRef']?.['c:numCache']],
|
|
916
|
+
['c:multiLvlStrRef/c:multiLvlStrCache', node?.['c:multiLvlStrRef']?.['c:multiLvlStrCache']],
|
|
917
|
+
['c:numLit', node?.['c:numLit']],
|
|
918
|
+
['c:strLit', node?.['c:strLit']],
|
|
919
|
+
].filter(([, cache]) => cache !== undefined);
|
|
920
|
+
if (candidates.length === 0) return [];
|
|
921
|
+
if (candidates.length > 1) invalid('multiple competing cache sources');
|
|
922
|
+
const [kind, cache] = candidates[0];
|
|
923
|
+
path += `/${kind}`;
|
|
924
|
+
if (cache !== '' && (!cache || typeof cache !== 'object' || Array.isArray(cache))) invalid('cache must be a single element');
|
|
925
|
+
const integer = (raw, limit, location) => {
|
|
926
|
+
// XML Schema unsigned integers allow an optional sign and whitespace;
|
|
927
|
+
// their value must still be nonnegative, integral and bounded.
|
|
928
|
+
if (typeof raw !== 'string' || !/^[+-]?\d+$/.test(raw.trim())) invalid('expected an unsigned integer', location);
|
|
929
|
+
const value = Number(raw.trim());
|
|
930
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > limit) invalid(`integer exceeds the supported range 0–${limit}`, location);
|
|
931
|
+
return value;
|
|
932
|
+
};
|
|
933
|
+
const count = cache?.['c:ptCount'] === undefined ? undefined
|
|
934
|
+
: integer(cache['c:ptCount']?.val, MAX_CHART_CACHE_POINTS, '/c:ptCount@val');
|
|
935
|
+
let data = cache;
|
|
936
|
+
if (kind.startsWith('c:multiLvlStrRef')) {
|
|
937
|
+
const levels = asArray(cache?.['c:lvl']);
|
|
938
|
+
if (levels.length > 1) {
|
|
939
|
+
throw new OPFPptxError('unsupported-chart-cache', 'Hierarchical chart category caches cannot be flattened without losing labels; use a single category level before importing.', {path});
|
|
940
|
+
}
|
|
941
|
+
data = levels[0];
|
|
942
|
+
}
|
|
943
|
+
const points = asArray(data?.['c:pt']);
|
|
944
|
+
if (points.length > MAX_CHART_CACHE_POINTS) invalid('too many points');
|
|
945
|
+
const indexed = new Map();
|
|
946
|
+
let extent = count ?? 0;
|
|
947
|
+
for (const [position, point] of points.entries()) {
|
|
948
|
+
const location = `/c:pt[${position}]@idx`;
|
|
949
|
+
const index = integer(point?.idx, MAX_CHART_CACHE_POINTS - 1, location);
|
|
950
|
+
if (count !== undefined && index >= count) invalid('point index is outside its declared count', location);
|
|
951
|
+
if (indexed.has(index)) invalid('duplicate point index', location);
|
|
952
|
+
if (Array.isArray(point?.['c:v'])) invalid('point has multiple values', `/c:pt[${position}]/c:v`);
|
|
953
|
+
indexed.set(index, point?.['c:v'] === undefined ? null : scalarText(point['c:v']));
|
|
954
|
+
extent = Math.max(extent, index + 1);
|
|
955
|
+
}
|
|
956
|
+
if (budget.cells + extent > MAX_CHART_CACHE_CELLS) invalid('combined caches exceed the 1,000,000-cell import limit');
|
|
957
|
+
budget.cells += extent;
|
|
958
|
+
const result = new Array(extent).fill(null);
|
|
959
|
+
for (const [index, value] of indexed) {
|
|
960
|
+
result[index] = readValue ? readValue(value, `${path}/c:pt[@idx="${index}"]/c:v`) : value;
|
|
961
|
+
}
|
|
962
|
+
return result;
|
|
769
963
|
}
|
|
770
964
|
|
|
771
|
-
function firstCachedValue(node) {
|
|
772
|
-
return cachedValues(node).find(
|
|
965
|
+
function firstCachedValue(node, path, budget) {
|
|
966
|
+
return cachedValues(node, path, budget).find(value => value !== null);
|
|
773
967
|
}
|
|
774
968
|
|
|
775
969
|
function readSlideNotes(entries, relationships) {
|
|
776
970
|
const notesRel = [...relationships.values()].find((relationship) => relationship.type.endsWith("/notesSlide"));
|
|
777
971
|
if (!notesRel?.path || !entries[notesRel.path]) return "";
|
|
778
972
|
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();
|
|
973
|
+
const shapes = nativeTextShapes(doc["p:notes"]?.["p:cSld"]?.["p:spTree"]);
|
|
974
|
+
const paragraphs = nativeShapeParagraphs(decodeText(entries[notesRel.path]), 'p:notes');
|
|
975
|
+
return shapes.flatMap((shape, index) => shapePlaceholderType(shape) === "body"
|
|
976
|
+
? paragraphs[index].map(paragraph => paragraph.text) : []).join("\n");
|
|
790
977
|
}
|
|
791
978
|
|
|
792
979
|
function comparePositionedItems(left, right) {
|
|
@@ -885,6 +1072,10 @@ function resolvePresentationContext(presentation, options) {
|
|
|
885
1072
|
design.fontScheme ?? theme?.fontScheme,
|
|
886
1073
|
DEFAULTS.fontScheme
|
|
887
1074
|
);
|
|
1075
|
+
// Shared rule (core resolveFontSchemeReference): an unresolved id falls back to the
|
|
1076
|
+
// DEFAULTS.fontScheme record as the base; resolveSlideContext reports it once per path.
|
|
1077
|
+
const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
|
|
1078
|
+
const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
|
|
888
1079
|
const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
|
|
889
1080
|
const background = resolveBackground(design.background ?? theme?.background, colorScheme);
|
|
890
1081
|
const fonts = resolveFonts(fontScheme);
|
|
@@ -895,13 +1086,15 @@ function resolvePresentationContext(presentation, options) {
|
|
|
895
1086
|
return {
|
|
896
1087
|
seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
|
|
897
1088
|
timestamp: options.timestamp ?? FIXED_TIMESTAMP,
|
|
898
|
-
zipDate: options.zipDate ?
|
|
1089
|
+
zipDate: options.zipDate === undefined ? FIXED_ZIP_DATE : EXPLICIT_ZIP_SENTINEL,
|
|
1090
|
+
zipDateStamp: resolveZipDateStamp(options.zipDate),
|
|
899
1091
|
compressionLevel: Number.isInteger(options.compressionLevel) ? options.compressionLevel : 6,
|
|
900
1092
|
layoutName: "OPF_CANVAS",
|
|
901
1093
|
dimensions,
|
|
902
1094
|
colorScheme,
|
|
903
1095
|
backgroundDefinition: design.background ?? theme?.background,
|
|
904
1096
|
fonts,
|
|
1097
|
+
unresolvedFontScheme,
|
|
905
1098
|
colors: {
|
|
906
1099
|
background,
|
|
907
1100
|
text: textColor,
|
|
@@ -918,6 +1111,55 @@ function exportColor(entry, context, fallback) {
|
|
|
918
1111
|
return resolveExportColor(entry, colorContext(context, fallback));
|
|
919
1112
|
}
|
|
920
1113
|
|
|
1114
|
+
// PptxGenJS color for a document color reference: a theme scheme value when the
|
|
1115
|
+
// reference names a scheme slot or role that the deck theme holds exactly,
|
|
1116
|
+
// otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
|
|
1117
|
+
function nativeColor(reference, hex, context) {
|
|
1118
|
+
return schemeColorValue(reference, hex, context, {vendor: true}) ?? normalizeHex(hex);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// The theme part carries the deck-level scheme (slide overrides stay literal)
|
|
1122
|
+
// and, for an explicit catalog theme, that theme's name.
|
|
1123
|
+
function exportTheme(presentation, context) {
|
|
1124
|
+
const reference = presentation.design?.theme;
|
|
1125
|
+
const id = referenceId(reference);
|
|
1126
|
+
const theme = id ? resolveDesignRecord(presentation, "themes", reference, DEFAULTS.theme) : null;
|
|
1127
|
+
const scheme = context.colorScheme;
|
|
1128
|
+
return {
|
|
1129
|
+
themeColors: themeSlotColors(scheme),
|
|
1130
|
+
schemeName: scheme.name ?? scheme.id ?? "OpenPresentation",
|
|
1131
|
+
themeName: theme?.id === id ? theme.name ?? theme.id : undefined
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function importThemeDesign(entries, presentationRoot, presentationRels) {
|
|
1136
|
+
const diagnostics = [], design = {};
|
|
1137
|
+
const report = (code, path, message) => diagnostics.push({code, path, message});
|
|
1138
|
+
const themePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
|
|
1139
|
+
const theme = themePath ? parseOptionalXml(entries, themePath)?.["a:theme"] : undefined;
|
|
1140
|
+
const elements = theme?.["a:themeElements"];
|
|
1141
|
+
const clrScheme = elements?.["a:clrScheme"];
|
|
1142
|
+
if (!clrScheme || Array.isArray(clrScheme)) {
|
|
1143
|
+
report("unsupported-theme-colors", "design.colorScheme", "The PPTX has no readable theme color scheme, so design.colorScheme was not imported.");
|
|
1144
|
+
return {design, diagnostics};
|
|
1145
|
+
}
|
|
1146
|
+
const {colors, unreadable} = readThemeSlotColors(clrScheme);
|
|
1147
|
+
const records = defaultCatalog("colorSchemes");
|
|
1148
|
+
if (Object.keys(colors).length) design.colorScheme = recoverColorScheme({colors, unreadable, name: scalarText(clrScheme.name)}, records).value;
|
|
1149
|
+
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.`);
|
|
1150
|
+
const latin = font => scalarText(elements?.["a:fontScheme"]?.[font]?.["a:latin"]?.typeface);
|
|
1151
|
+
const recoveredTheme = recoverTheme({themeName: scalarText(theme?.name), colors, majorFont: latin("a:majorFont"), minorFont: latin("a:minorFont")}, {
|
|
1152
|
+
themes: defaultCatalog("themes"),
|
|
1153
|
+
colorSchemes: records,
|
|
1154
|
+
fontFamilies: id => {
|
|
1155
|
+
const record = findById(defaultCatalog("fontSchemes"), id);
|
|
1156
|
+
return record ? resolveFontFamilies(record) : null;
|
|
1157
|
+
}
|
|
1158
|
+
});
|
|
1159
|
+
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.`);
|
|
1160
|
+
return {design: recoveredTheme.id ? {theme: recoveredTheme.id, ...design} : design, diagnostics};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
921
1163
|
function configurePresentation(pptx, presentation, context) {
|
|
922
1164
|
pptx.defineLayout({
|
|
923
1165
|
name: context.layoutName,
|
|
@@ -938,21 +1180,51 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
938
1180
|
|
|
939
1181
|
async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
|
|
940
1182
|
const slide = pptx.addSlide();
|
|
941
|
-
const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
|
|
1183
|
+
const slideContext = resolveSlideContext(presentation, opfSlide, context, options, slideIndex);
|
|
942
1184
|
slide.background = { color: slideContext.colors.background };
|
|
943
|
-
const
|
|
1185
|
+
const backgroundDefinition = slideContext.backgroundDefinition;
|
|
1186
|
+
const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
|
|
1187
|
+
const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
|
|
944
1188
|
width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
|
|
945
|
-
}, slideContext.colors.background);
|
|
1189
|
+
}, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext));
|
|
946
1190
|
if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
|
|
1191
|
+
if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
|
|
1192
|
+
options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
|
|
1193
|
+
}
|
|
1194
|
+
if (backgroundDefinition?.type === 'image') {
|
|
1195
|
+
const imagePath = `${backgroundPath}.image`;
|
|
1196
|
+
const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath);
|
|
1197
|
+
if (resolved) {
|
|
1198
|
+
// PptxGenJS embeds the raster and its relationship; packaging replaces
|
|
1199
|
+
// its stretched fill with the fitted native picture fill.
|
|
1200
|
+
slide.background = { ...resolved };
|
|
1201
|
+
context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, { image: {
|
|
1202
|
+
fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
|
|
1203
|
+
width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
|
|
1204
|
+
} });
|
|
1205
|
+
} else {
|
|
1206
|
+
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.'});
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
947
1209
|
slide.color = slideContext.colors.text;
|
|
948
1210
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
949
1211
|
|
|
950
1212
|
const { widthInches, heightInches } = slideContext.dimensions;
|
|
951
1213
|
const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
|
|
952
1214
|
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
|
-
|
|
1215
|
+
// design.titleAlignment/contentAlignment are Design properties (slide design
|
|
1216
|
+
// overrides the deck). Core only returns an accepted `placement.alignment`
|
|
1217
|
+
// with outline measurement, so the same effective values must also drive
|
|
1218
|
+
// text without placement, exactly as the renderer's preview does.
|
|
1219
|
+
const titleAlignment=opfSlide.design?.titleAlignment??presentation.design?.titleAlignment;
|
|
1220
|
+
const contentAlignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment;
|
|
1221
|
+
const fieldAlignment=field=>field==='title'?titleAlignment:contentAlignment;
|
|
1222
|
+
// Core composition resolves one alignment per composed item for every engine
|
|
1223
|
+
// (FF-29); the design fallback keeps cores published before item.alignment working.
|
|
1224
|
+
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1225
|
+
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
1226
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
955
|
-
await
|
|
1227
|
+
if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
|
|
956
1228
|
for (const item of geometry.items) {
|
|
957
1229
|
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
958
1230
|
if (item.frameBox) {
|
|
@@ -965,7 +1237,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
965
1237
|
fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),pt:.75},objectName:`OPF card ${item.path}`});
|
|
966
1238
|
}
|
|
967
1239
|
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});
|
|
1240
|
+
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
1241
|
} else if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
970
1242
|
// Estimated-font headings use one native text box, which still needs a
|
|
971
1243
|
// role tag. Role recovery must not depend on outline measurement support.
|
|
@@ -975,35 +1247,55 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
975
1247
|
...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
|
|
976
1248
|
...nativeFontOptions(item.textStyle),
|
|
977
1249
|
color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
|
|
1250
|
+
align: alignmentFor(item),
|
|
978
1251
|
objectName,
|
|
979
1252
|
breakLine: false
|
|
980
1253
|
});
|
|
981
1254
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
982
|
-
addMeasuredList(slide,item.text,slideContext);
|
|
1255
|
+
addMeasuredList(slide,item.text,slideContext,item.path);
|
|
983
1256
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
984
|
-
const alignment=item.text.placement?.alignment??
|
|
1257
|
+
const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
985
1258
|
for(const [index,line] of item.text.richLines.entries()){
|
|
986
1259
|
const runs=line.fragments.map(fragment=>{
|
|
987
1260
|
const runColor=exportColor(fragment.run.color,slideContext,slideContext.colors.text);
|
|
988
|
-
|
|
1261
|
+
const color=nativeColor(fragment.run.color,runColor,slideContext);
|
|
1262
|
+
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
1263
|
});
|
|
990
1264
|
const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
991
1265
|
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
1266
|
if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
993
1267
|
}
|
|
994
1268
|
} 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)});
|
|
1269
|
+
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
|
|
996
1270
|
} else {
|
|
997
|
-
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment:
|
|
1271
|
+
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
1272
|
}
|
|
999
1273
|
}
|
|
1274
|
+
// Core composes furniture above all content and opf-render paints it last, so
|
|
1275
|
+
// spTree order (PowerPoint's z-order) matches: header/footer parts come after
|
|
1276
|
+
// every content item, and an overlapping footer stays visible over content.
|
|
1277
|
+
await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
|
|
1000
1278
|
|
|
1001
|
-
if (opfSlide.notes)
|
|
1279
|
+
if (opfSlide.notes) {
|
|
1280
|
+
const notes = String(opfSlide.notes);
|
|
1281
|
+
slide.addNotes(notes);
|
|
1282
|
+
if (notes.includes('\r')) context.notesWithCarriageReturns.set(`ppt/notesSlides/notesSlide${slideIndex + 1}.xml`, notes);
|
|
1283
|
+
}
|
|
1002
1284
|
}
|
|
1003
1285
|
|
|
1004
|
-
function resolveSlideContext(presentation, slide, baseContext, options) {
|
|
1286
|
+
function resolveSlideContext(presentation, slide, baseContext, options, slideIndex = 0) {
|
|
1005
1287
|
const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
|
|
1006
1288
|
const resolved = resolvePresentationContext(effective, options);
|
|
1289
|
+
if (resolved.unresolvedFontScheme) {
|
|
1290
|
+
const path = slide.design?.fontScheme !== undefined ? `slides.${slideIndex}.design.fontScheme`
|
|
1291
|
+
: presentation.design?.fontScheme !== undefined ? "design.fontScheme"
|
|
1292
|
+
: slide.design?.theme !== undefined ? `slides.${slideIndex}.design.theme` : "design.theme";
|
|
1293
|
+
if (!baseContext.reportedFontSchemes?.has(path)) {
|
|
1294
|
+
baseContext.reportedFontSchemes?.add(path);
|
|
1295
|
+
const id = resolved.unresolvedFontScheme;
|
|
1296
|
+
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}'.` });
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1007
1299
|
if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
|
|
1008
1300
|
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
1009
1301
|
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 +1320,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1028
1320
|
await addImagePayload(slide, presentation, payload.image, region, path, context, options);
|
|
1029
1321
|
break;
|
|
1030
1322
|
case "video":
|
|
1031
|
-
|
|
1323
|
+
addMediaPayload(slide, presentation, payload.video, region, path, context, options);
|
|
1032
1324
|
break;
|
|
1033
1325
|
case "chart":
|
|
1034
|
-
addChartPayload(slide, payload.chart, region, context);
|
|
1326
|
+
addChartPayload(slide, payload.chart, region, context, options, path);
|
|
1035
1327
|
break;
|
|
1036
1328
|
case "table":
|
|
1037
1329
|
addTablePayload(slide, payload.table, region, context, options, path);
|
|
@@ -1049,7 +1341,8 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1049
1341
|
addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
|
|
1050
1342
|
break;
|
|
1051
1343
|
default:
|
|
1052
|
-
|
|
1344
|
+
options.onDiagnostic?.({code: "content-placeholder", path, reason: "unsupported-payload", message: "This content has no PowerPoint export; a placeholder frame stands in for it."});
|
|
1345
|
+
addPlaceholderPayload(slide, "Unsupported content", "This content has no PowerPoint export.", region, context);
|
|
1053
1346
|
}
|
|
1054
1347
|
}
|
|
1055
1348
|
|
|
@@ -1078,17 +1371,23 @@ function addTextPayload(slide, value, region, context) {
|
|
|
1078
1371
|
function richLineRuns(line,color,context) {
|
|
1079
1372
|
const fallback=color.replace(/^#/,'');
|
|
1080
1373
|
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
|
|
1374
|
+
const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
|
|
1375
|
+
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
1376
|
});
|
|
1084
1377
|
}
|
|
1085
|
-
|
|
1378
|
+
// Every native line of a list, marker paragraph or not, is named for the list's
|
|
1379
|
+
// composed path (`OPF list <path> line N`, N counting across the whole list) like
|
|
1380
|
+
// the other measured text shapes. A reader maps lines to their list by name, not
|
|
1381
|
+
// by geometry, so lines of an overflowing list that fall below its box still
|
|
1382
|
+
// belong to it.
|
|
1383
|
+
function addMeasuredList(slide,fit,context,path) {
|
|
1384
|
+
let lineNumber=0;
|
|
1086
1385
|
for(const entry of fit.listEntries){
|
|
1087
1386
|
const addLines=(text,box,color,withBullet)=>{
|
|
1088
1387
|
text.richLines.forEach((line,index)=>{
|
|
1089
1388
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1090
1389
|
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
|
|
1390
|
+
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1092
1391
|
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
|
|
1093
1392
|
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
1393
|
const runs=richLineRuns(line,color,context);
|
|
@@ -1146,7 +1445,7 @@ function addListPayload(slide, items, region, context) {
|
|
|
1146
1445
|
async function addImagePayload(slide, presentation, asset, region, path, context, options) {
|
|
1147
1446
|
const resolved = await resolveImage(asset, presentation, options, path);
|
|
1148
1447
|
if (!resolved) {
|
|
1149
|
-
|
|
1448
|
+
addImagePlaceholder(slide, presentation, asset, region, path, context, options);
|
|
1150
1449
|
return;
|
|
1151
1450
|
}
|
|
1152
1451
|
const objectName = `OPF image ${context.imagePlacements.size + 1}`;
|
|
@@ -1163,12 +1462,50 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
1163
1462
|
return objectName;
|
|
1164
1463
|
}
|
|
1165
1464
|
|
|
1166
|
-
|
|
1465
|
+
// design.slideImage: one native picture at the shared frame, beneath content.
|
|
1466
|
+
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
1467
|
+
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
1468
|
+
const box = { x: image.box.x / 96, y: image.box.y / 96, w: image.box.width / 96, h: image.box.height / 96 };
|
|
1469
|
+
const resolved = await resolveImage(image.value, presentation, options, image.sourcePath);
|
|
1470
|
+
if (!resolved) {
|
|
1471
|
+
addImagePlaceholder(slide, presentation, image.value, box, image.sourcePath, context, options);
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
const objectName = slideImageName(`slides.${slideIndex}`);
|
|
1475
|
+
const configured = image.path === 'design.slideImage' ? presentation.design?.slideImage : presentation.slides[slideIndex].design?.slideImage;
|
|
1476
|
+
const { src: _source, ...treatment } = isPlainObject(configured) && 'position' in configured ? configured : {};
|
|
1477
|
+
const paint = (entry, fallback) => {
|
|
1478
|
+
const hex = normalizeHex(exportColor(entry, context, fallback), fallback);
|
|
1479
|
+
const raw = exportColor(entry, context, fallback).replace(/^#/, '');
|
|
1480
|
+
return { hex, alpha: /^[0-9a-f]{8}$/i.test(raw) ? parseInt(raw.slice(6), 16) / 255 : 1 };
|
|
1481
|
+
};
|
|
1482
|
+
// Native effects are resolved here, in the slide's color context.
|
|
1483
|
+
const effects = {
|
|
1484
|
+
shape: image.shape ?? null,
|
|
1485
|
+
border: image.border ? { ...paint(image.border.color, context.colors.border), width: image.border.width } : null,
|
|
1486
|
+
opacity: image.opacity ?? null,
|
|
1487
|
+
recolor: image.recolor?.type === 'grayscale' ? { type: 'grayscale' }
|
|
1488
|
+
: image.recolor?.type === 'duotone' ? { type: 'duotone', dark: paint(image.recolor.dark, '000000'), light: paint(image.recolor.light, 'FFFFFF') } : null,
|
|
1489
|
+
overlay: image.overlay ? { ...paint(image.overlay.color, context.colors.text), opacity: image.overlay.opacity, box: image.overlay.box, shape: image.overlay.shape } : null,
|
|
1490
|
+
};
|
|
1491
|
+
context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects });
|
|
1492
|
+
slide.addImage({ ...resolved, objectName, ...box, altText: image.alt ?? assetAlt(image.value, presentation) });
|
|
1493
|
+
// The overlay scrim is a separate native shape directly above the picture.
|
|
1494
|
+
if (effects.overlay) {
|
|
1495
|
+
const overlay = effects.overlay.box;
|
|
1496
|
+
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 } });
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
|
|
1167
1501
|
const chartData = toPptxChartData(chart);
|
|
1168
|
-
if (!chartData) {
|
|
1169
|
-
|
|
1502
|
+
if (!chartData.series) {
|
|
1503
|
+
// Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
|
|
1504
|
+
options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
|
|
1505
|
+
addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
|
|
1170
1506
|
return;
|
|
1171
1507
|
}
|
|
1508
|
+
if (chartData.adapted) options.onDiagnostic?.({code: "chart-data-adapted", path, message: chartData.message, adaptation: chartData.adapted});
|
|
1172
1509
|
|
|
1173
1510
|
// Keep the resolved palette surface (including alpha) explicit in native
|
|
1174
1511
|
// chart/plot areas, so inherited labels are assessed against their own panel.
|
|
@@ -1178,7 +1515,8 @@ function addChartPayload(slide, chart, region, context) {
|
|
|
1178
1515
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
1179
1516
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1180
1517
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1181
|
-
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
|
|
1518
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
|
|
1519
|
+
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1182
1520
|
slide.addChart(chartData.type, chartData.series, {
|
|
1183
1521
|
objectName,
|
|
1184
1522
|
x: region.x,
|
|
@@ -1212,7 +1550,8 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1212
1550
|
const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
|
|
1213
1551
|
const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
|
|
1214
1552
|
if (sourceRows.length === 0) {
|
|
1215
|
-
|
|
1553
|
+
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."});
|
|
1554
|
+
addPlaceholderPayload(slide, "Table", "The table has no rows.", region, context);
|
|
1216
1555
|
return;
|
|
1217
1556
|
}
|
|
1218
1557
|
|
|
@@ -1235,10 +1574,10 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1235
1574
|
const fragment = fragments.find(item => item.runIndex === index);
|
|
1236
1575
|
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
1576
|
const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
|
|
1238
|
-
const color =
|
|
1577
|
+
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
1578
|
const runOptions = {
|
|
1240
1579
|
...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
|
|
1241
|
-
underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
1580
|
+
underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
|
|
1242
1581
|
color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
|
|
1243
1582
|
hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
|
|
1244
1583
|
};
|
|
@@ -1265,11 +1604,11 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1265
1604
|
valign: cellStyle.verticalAlign ?? 'top',
|
|
1266
1605
|
...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
|
|
1267
1606
|
...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
|
|
1268
|
-
color:
|
|
1607
|
+
color: nativeColor(cellStyle.color, baseColor, context),
|
|
1269
1608
|
// Rich runs carry resolved alpha. A translucent cell default would
|
|
1270
1609
|
// overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
|
|
1271
1610
|
...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
|
|
1272
|
-
fill: { color:
|
|
1611
|
+
fill: { color: nativeColor(cellStyle.fill, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
|
|
1273
1612
|
},
|
|
1274
1613
|
};
|
|
1275
1614
|
}));
|
|
@@ -1281,6 +1620,9 @@ function addTablePayload(slide, table, region, context, options, path) {
|
|
|
1281
1620
|
x: region.x,
|
|
1282
1621
|
y: region.y,
|
|
1283
1622
|
w: region.w,
|
|
1623
|
+
// PowerPoint derives a table's height from its rows, and the preview draws
|
|
1624
|
+
// the rows (layout.height, never more than the composed box). The declared
|
|
1625
|
+
// frame height is the row total so the XML matches what both engines draw.
|
|
1284
1626
|
h: layout.height / 96,
|
|
1285
1627
|
rowH: layout.rows.map(row => row.box.height / 96),
|
|
1286
1628
|
colW: Array(columnCount).fill(region.w / columnCount),
|
|
@@ -1311,20 +1653,34 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
|
|
|
1311
1653
|
options.onDiagnostic?.(diagnostic);
|
|
1312
1654
|
if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
|
|
1313
1655
|
}
|
|
1656
|
+
// Generated socials carry one link per explicit source line; wrapped lines share it.
|
|
1657
|
+
let sourceLineIndex = 0;
|
|
1314
1658
|
for (const [index, line] of fit.lines.entries()) {
|
|
1659
|
+
const link = config.links?.[sourceLineIndex];
|
|
1660
|
+
if (fit.sourceLines?.[index]?.boundary === 'hard') sourceLineIndex += 1;
|
|
1315
1661
|
if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
|
|
1316
1662
|
const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
|
|
1317
1663
|
const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
|
|
1318
1664
|
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
1665
|
if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
|
|
1320
1666
|
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)
|
|
1667
|
+
else if(config.furniture){
|
|
1668
|
+
context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
|
|
1669
|
+
if(config.liveFields?.[index]?.length)context.furnitureFields.set(objectName,{text:line,fields:config.liveFields[index]});
|
|
1670
|
+
}
|
|
1671
|
+
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
1672
|
else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
|
|
1323
1673
|
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
|
-
|
|
1674
|
+
// A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
|
|
1675
|
+
const lineColor = normalizeHex(config.color ?? context.colors.text);
|
|
1676
|
+
slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
|
|
1325
1677
|
...textBoxOptions(area, context, fit.fontSize * .75),
|
|
1326
1678
|
...nativeFontOptions(style),
|
|
1327
|
-
color:
|
|
1679
|
+
color: lineColor, align: alignment,
|
|
1680
|
+
// Reuse the frame's existing relationship to cover blank caption boxes.
|
|
1681
|
+
// PptxGenJS also inherits it into runs; keep their native text styling.
|
|
1682
|
+
hyperlink: config.hyperlink,
|
|
1683
|
+
underline: config.hyperlink ? {style: 'none'} : undefined,
|
|
1328
1684
|
tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
|
|
1329
1685
|
objectName,
|
|
1330
1686
|
fit: 'none', wrap: false, lineSpacingMultiple: 1,
|
|
@@ -1339,6 +1695,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1339
1695
|
if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
|
|
1340
1696
|
return;
|
|
1341
1697
|
}
|
|
1698
|
+
const staticDates = new Map();
|
|
1342
1699
|
for(const [index,part]of layout.parts.entries()){
|
|
1343
1700
|
if(part.type==='image'){
|
|
1344
1701
|
const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
|
|
@@ -1346,10 +1703,27 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1346
1703
|
if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
|
|
1347
1704
|
}else{
|
|
1348
1705
|
if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
|
|
1349
|
-
|
|
1706
|
+
// Slide numbers and current dates become native PowerPoint fields; {total}
|
|
1707
|
+
// and fixed dates stay fixed text. A current date whose pattern has no
|
|
1708
|
+
// en-US field type is written as its laid-out text.
|
|
1709
|
+
const fields=[];
|
|
1710
|
+
for(const field of furniturePartFields(part)){
|
|
1711
|
+
const nativeType=nativeFieldType(field);
|
|
1712
|
+
if(nativeType)fields.push({...field,nativeType});
|
|
1713
|
+
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.`});
|
|
1714
|
+
}
|
|
1715
|
+
const liveFields=fields.length?lineFields(part.text,fields,part.fit.sourceLines,part.fit.lines):undefined;
|
|
1716
|
+
for(const field of fields.filter(field => !lineFields(part.text,[field],part.fit.sourceLines,part.fit.lines).some(line => line.length))) {
|
|
1717
|
+
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.`});
|
|
1718
|
+
if(context.provenanceMode === 'full') {
|
|
1719
|
+
const marker = staticDateFallback(part,field);
|
|
1720
|
+
if(marker) staticDates.set(index,marker);
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
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
1724
|
}
|
|
1351
1725
|
}
|
|
1352
|
-
const manifest = furnitureManifest(presentation, source, layout, slideIndex);
|
|
1726
|
+
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
|
|
1353
1727
|
if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
|
|
1354
1728
|
}
|
|
1355
1729
|
|
|
@@ -1439,7 +1813,101 @@ function addTimelinePayload(slide, value, layout, context, options, path) {
|
|
|
1439
1813
|
}
|
|
1440
1814
|
}
|
|
1441
1815
|
|
|
1442
|
-
|
|
1816
|
+
// Mirror the renderer's media placeholder: a bordered surface, a centred 72px
|
|
1817
|
+
// play badge and a centred caption (title, then source). Native video embedding
|
|
1818
|
+
// is not attempted; the caption keeps the reference editable.
|
|
1819
|
+
function addMediaPayload(slide, presentation, value, region, path, context, options) {
|
|
1820
|
+
const box = pixelBox(region);
|
|
1821
|
+
const icon = {x: box.x + (box.width - 72) / 2, y: box.y + (box.height - 72) / 2};
|
|
1822
|
+
// The frame links to a web source (after asset dereferencing), so the
|
|
1823
|
+
// reference stays usable in PowerPoint; OPF_MEDIA_V1 keeps the OPF value.
|
|
1824
|
+
const resolved = dereferenceAsset(value, presentation, path), source = typeof resolved === "string" ? resolved : resolved?.src;
|
|
1825
|
+
const hyperlink = typeof source === "string" && /^https?:\/\//i.test(source) ? {url: source} : undefined;
|
|
1826
|
+
const names = {frame: `OPF media ${path} frame`, badge: `OPF media ${path} badge`, play: `OPF media ${path} play`};
|
|
1827
|
+
if (context.provenanceMode !== false) {
|
|
1828
|
+
context.mediaTags.set(names.frame, mediaFrameRecord(value, path, presentation, context.provenanceMode, options.onDiagnostic));
|
|
1829
|
+
context.mediaTags.set(names.badge, {v: 1, role: "badge", path});
|
|
1830
|
+
context.mediaTags.set(names.play, {v: 1, role: "play", path});
|
|
1831
|
+
}
|
|
1832
|
+
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, pt: 0.75}, hyperlink, objectName: names.frame});
|
|
1833
|
+
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});
|
|
1834
|
+
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});
|
|
1835
|
+
addMeasuredPayloadText(slide, mediaCaption(value), {...box, y: icon.y + 72 + 20, height: 50}, context, options,
|
|
1836
|
+
{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});
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// The asset an image block names, layered as the preview layers it: fields on the
|
|
1840
|
+
// block win over the registry entry it references, and an unknown reference keeps
|
|
1841
|
+
// the block's own fields.
|
|
1842
|
+
function placeholderAsset(asset, presentation) {
|
|
1843
|
+
const plain = value => typeof value === "string" ? { src: value } : isPlainObject(value) ? value : { src: "" };
|
|
1844
|
+
let current = plain(asset);
|
|
1845
|
+
const seen = new Set();
|
|
1846
|
+
while (typeof current.src === "string" && current.src.startsWith("asset:")) {
|
|
1847
|
+
const id = current.src.slice(6);
|
|
1848
|
+
if (seen.has(id) || !Object.hasOwn(presentation.assets ?? {}, id)) break;
|
|
1849
|
+
seen.add(id);
|
|
1850
|
+
const { src: _source, ...overrides } = current;
|
|
1851
|
+
current = { ...plain(presentation.assets[id]), ...overrides };
|
|
1852
|
+
}
|
|
1853
|
+
return current;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
// An image that cannot be embedded exports the preview's placeholder, not a second
|
|
1857
|
+
// design: a dashed panel with a centered "Image unavailable" over the asset's alt
|
|
1858
|
+
// text (else title, else "Image"), set bold at 20 px (shrinking to the composition
|
|
1859
|
+
// minimum) in the readable text colour for the panel, or a cross when the label
|
|
1860
|
+
// cannot fit even at that minimum. The panel carries the accessible name the
|
|
1861
|
+
// preview gives its group, "Image unavailable: <description>".
|
|
1862
|
+
function addImagePlaceholder(slide, presentation, asset, region, path, context, options) {
|
|
1863
|
+
const layered = placeholderAsset(asset, presentation);
|
|
1864
|
+
const description = String(layered.alt ?? layered.title ?? "Image");
|
|
1865
|
+
const label = `Image unavailable\n${description}`;
|
|
1866
|
+
// The preview rejects characters DrawingML/SVG XML cannot represent when it draws the label.
|
|
1867
|
+
// The cross and the accessible name would otherwise carry them into the slide XML, so check
|
|
1868
|
+
// up front and fail the same way whichever form the placeholder takes.
|
|
1869
|
+
const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF]/u.exec(label);
|
|
1870
|
+
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 });
|
|
1871
|
+
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1872
|
+
const box = pixelBox(region);
|
|
1873
|
+
const padding = Math.min(24 * scale, box.width * .06, box.height * .1);
|
|
1874
|
+
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) };
|
|
1875
|
+
const surface = normalizeHex(context.colors.surface);
|
|
1876
|
+
const textColor = normalizeHex(textColorForFill(`#${surface}`, `#${context.colors.text}`));
|
|
1877
|
+
const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: 600, path }, options.textMeasurement);
|
|
1878
|
+
const measurer = textWidthMeasurer(style, options.textMeasurement);
|
|
1879
|
+
const minimum = (context.composition?.minFontSize ?? 16) * scale;
|
|
1880
|
+
const fit = fitText(label, inner, 20 * scale, minimum, measurer);
|
|
1881
|
+
const objectName = `OPF image placeholder ${context.imagePlaceholders.size + 1}`;
|
|
1882
|
+
context.imagePlaceholders.set(objectName, `Image unavailable: ${description}`);
|
|
1883
|
+
slide.addShape("rect", {
|
|
1884
|
+
x: region.x, y: region.y, w: region.w, h: region.h,
|
|
1885
|
+
fill: { color: surface },
|
|
1886
|
+
line: { color: context.colors.border, width: .75, dashType: "dash" },
|
|
1887
|
+
objectName
|
|
1888
|
+
});
|
|
1889
|
+
if (!fit.overflow) {
|
|
1890
|
+
// The lines are centered vertically in the padded box. Fit against the shifted
|
|
1891
|
+
// box so any per-line placement carries the same offset.
|
|
1892
|
+
const offset = Math.max(0, (inner.height - fit.lines.length * fit.lineHeight) / 2);
|
|
1893
|
+
const centered = { ...inner, y: inner.y + offset };
|
|
1894
|
+
addMeasuredPayloadText(slide, label, centered, context, options, {
|
|
1895
|
+
path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
|
|
1896
|
+
diagnosticsHandled: true, align: "center", color: textColor
|
|
1897
|
+
});
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
// A status indicator, not shortened authored content: the full description stays
|
|
1901
|
+
// in the panel's accessible name.
|
|
1902
|
+
const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
|
|
1903
|
+
if (!size) return;
|
|
1904
|
+
const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
|
|
1905
|
+
const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
|
|
1906
|
+
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 });
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
// A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
|
|
1910
|
+
function addPlaceholderPayload(slide, label, description, region, context) {
|
|
1443
1911
|
slide.addShape("rect", {
|
|
1444
1912
|
x: region.x,
|
|
1445
1913
|
y: region.y,
|
|
@@ -1448,7 +1916,7 @@ function addPlaceholderPayload(slide, label, value, region, context) {
|
|
|
1448
1916
|
fill: { color: context.colors.surface, transparency: 10 },
|
|
1449
1917
|
line: { color: context.colors.border, pt: 0.75 }
|
|
1450
1918
|
});
|
|
1451
|
-
slide.addText(`${label}\n${
|
|
1919
|
+
slide.addText(`${label}\n${description}`, {
|
|
1452
1920
|
x: region.x + 0.12,
|
|
1453
1921
|
y: region.y + 0.12,
|
|
1454
1922
|
w: Math.max(0.2, region.w - 0.24),
|
|
@@ -1463,6 +1931,47 @@ function addPlaceholderPayload(slide, label, value, region, context) {
|
|
|
1463
1931
|
});
|
|
1464
1932
|
}
|
|
1465
1933
|
|
|
1934
|
+
// FF-31: a measurement provider may preview a chosen family with another face
|
|
1935
|
+
// (Carlito for Aptos or Calibri, Gelasio for Georgia, a caller alias, a generic
|
|
1936
|
+
// fallback). That substitute changes measurement and drawing only. The package
|
|
1937
|
+
// always names the developer's chosen family. Measurement still uses the
|
|
1938
|
+
// substitute, because the provider resolves the chosen family again on every
|
|
1939
|
+
// measure/outline call, so layout is unchanged.
|
|
1940
|
+
const FACE_STYLE_WORDS = /^(?:(?:thin|hairline|extra ?light|ultra ?light|light|semi ?light|demi ?light|book|regular|normal|medium|semi ?bold|demi ?bold|bold|extra ?bold|ultra ?bold|black|heavy|extra ?black|ultra ?black|italic|oblique)\s*)+$/i;
|
|
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} `) && FACE_STYLE_WORDS.test(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) {
|