@openpresentation/opf-pptx 0.11.5 → 0.11.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -8
- package/dist/background.js +15 -13
- package/dist/chart-types.js +5 -1
- package/dist/chartex.js +40 -24
- package/dist/code-provenance.js +1 -1
- package/dist/content-topology.js +295 -0
- package/dist/document-provenance.js +260 -46
- package/dist/furniture-provenance.js +57 -4
- package/dist/index.d.ts +21 -12
- package/dist/index.js +380 -65
- package/dist/logo-provenance.js +173 -0
- package/dist/sections.js +111 -0
- package/dist/watermark-provenance.js +2 -1
- package/package.json +7 -6
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilie
|
|
|
5
5
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
6
6
|
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
7
7
|
import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
|
|
8
|
-
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
8
|
+
import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
9
9
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
10
10
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
11
11
|
import {attachMediaTags,importMediaGroups,mediaCaption,mediaFrameRecord,mediaTextFingerprint} from './media-provenance.js';
|
|
@@ -13,13 +13,15 @@ import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
|
|
|
13
13
|
import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
|
|
14
14
|
import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
|
|
15
15
|
import {attachQuoteTags,quoteManifest,importQuoteGroups} from './quote-provenance.js';
|
|
16
|
-
import {attachFurnitureTags, furnitureManifest, importFurniture, staticDateFallback} from './furniture-provenance.js';
|
|
17
|
-
import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, restoreDocumentProvenance} from './document-provenance.js';
|
|
16
|
+
import {attachFurnitureTags, furnitureManifest, importFurniture, manifestPartIndex, staticDateFallback} from './furniture-provenance.js';
|
|
17
|
+
import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, recordContentTopology, restoreDocumentProvenance} from './document-provenance.js';
|
|
18
|
+
import {INVALID_XML_CHARACTER, nativeSections, writeSectionList} from './sections.js';
|
|
18
19
|
import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
|
|
19
20
|
import {importImageOrientation} from './image-import.js';
|
|
20
21
|
import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
|
|
21
22
|
import {dedupeMedia} from './media-dedupe.js';
|
|
22
23
|
import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
|
|
24
|
+
import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from './logo-provenance.js';
|
|
23
25
|
import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
|
|
24
26
|
import {importBackground} from './background-import.js';
|
|
25
27
|
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
@@ -27,7 +29,7 @@ import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts,
|
|
|
27
29
|
import { webpToPng } from '#image-fallback';
|
|
28
30
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
29
31
|
import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
|
|
30
|
-
import { colorContext, resolveExportColor, resolveVariableColors } from "./color-ref.js";
|
|
32
|
+
import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
|
|
31
33
|
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
32
34
|
import { unzipSync, zipSync } from "fflate";
|
|
33
35
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -209,8 +211,12 @@ export async function toPptx(input, options = {}) {
|
|
|
209
211
|
context.tableHeaders = new Map();
|
|
210
212
|
context.tableCells = new Map();
|
|
211
213
|
context.imagePlacements = new Map();
|
|
214
|
+
context.pictureText = new Map();
|
|
212
215
|
context.slideImages = new Map();
|
|
213
216
|
context.watermarks = new Map();
|
|
217
|
+
context.logos = new Map();
|
|
218
|
+
context.logoPlaceholderTags = new Map();
|
|
219
|
+
context.bulletImages = new Map();
|
|
214
220
|
context.backgroundFills = new Map();
|
|
215
221
|
context.notesWithCarriageReturns = new Map();
|
|
216
222
|
context.cardTags = new Map();
|
|
@@ -221,6 +227,7 @@ export async function toPptx(input, options = {}) {
|
|
|
221
227
|
context.timelineTags = new Map();
|
|
222
228
|
context.quoteTags = new Map();
|
|
223
229
|
context.furnitureTags = new Map();
|
|
230
|
+
context.furnitureLogoTags = new Map();
|
|
224
231
|
context.furnitureFields = new Map();
|
|
225
232
|
context.furnitureManifests = new Map();
|
|
226
233
|
context.codeTags = new Map();
|
|
@@ -229,12 +236,13 @@ export async function toPptx(input, options = {}) {
|
|
|
229
236
|
context.chartFonts = new Map();
|
|
230
237
|
context.chartex = new Map();
|
|
231
238
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
239
|
+
// Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
|
|
240
|
+
// check confirmed (treemap, histogram, pareto, box-and-whisker, waterfall, funnel) and the clustered column fallback
|
|
241
|
+
// for the unconfirmed map; 'native' writes every chartex part; 'fallback' writes clustered columns only.
|
|
242
|
+
if (options.chartex !== undefined && !['auto', 'native', 'fallback'].includes(options.chartex)) {
|
|
243
|
+
throw new OPFPptxError('invalid-chartex-mode', 'chartex must be auto, native or fallback.', {path: 'options.chartex'});
|
|
236
244
|
}
|
|
237
|
-
context.chartexMode = options.chartex ?? '
|
|
245
|
+
context.chartexMode = options.chartex ?? 'auto';
|
|
238
246
|
Object.assign(context, exportTheme(presentation, context));
|
|
239
247
|
context.reportedFontSchemes = new Set();
|
|
240
248
|
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
@@ -244,6 +252,14 @@ export async function toPptx(input, options = {}) {
|
|
|
244
252
|
report: diagnostic => options.onDiagnostic?.(diagnostic)
|
|
245
253
|
});
|
|
246
254
|
context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
|
|
255
|
+
// Slide section labels become PowerPoint's native section list (src/sections.js).
|
|
256
|
+
// A label is an XML attribute: the characters text runs reject are rejected here too.
|
|
257
|
+
context.sections = presentation.slides.map((slide, index) => {
|
|
258
|
+
if (typeof slide.section !== 'string') return undefined;
|
|
259
|
+
const invalid = INVALID_XML_CHARACTER.exec(slide.section);
|
|
260
|
+
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: `slides.${index}.section`});
|
|
261
|
+
return slide.section;
|
|
262
|
+
});
|
|
247
263
|
context.masterBackground = masterBackground(context);
|
|
248
264
|
context.linkSentinels = linkSentinels(presentation);
|
|
249
265
|
const pptx = new PptxGenJS();
|
|
@@ -325,6 +341,9 @@ export async function fromPptx(input, options = {}) {
|
|
|
325
341
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
326
342
|
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
327
343
|
}
|
|
344
|
+
// Native sections (PowerPoint's own section list, `Default Section` = none)
|
|
345
|
+
// are reconciled with the footer text and the stored value in restoreDocumentProvenance.
|
|
346
|
+
const slideSections = nativeSections(presentationRoot, slideIdsInOrder(presentationRoot, presentationRels, entries, slidePaths));
|
|
328
347
|
// A watermark carried identically by every slide is the deck's design.watermark.
|
|
329
348
|
const carried = imported.slides.map(slide => slide.design?.watermark);
|
|
330
349
|
if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
|
|
@@ -351,7 +370,8 @@ export async function fromPptx(input, options = {}) {
|
|
|
351
370
|
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
|
|
352
371
|
// Host catalogs format stored socials exactly as export did (FF-34).
|
|
353
372
|
socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
|
|
354
|
-
|
|
373
|
+
nativeSections: slideSections,
|
|
374
|
+
slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
|
|
355
375
|
// The stored language wins while the runs still carry its tag (FF-07).
|
|
356
376
|
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
357
377
|
imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
|
|
@@ -360,6 +380,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
360
380
|
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
361
381
|
}
|
|
362
382
|
if (slideProvenance.length !== imported.slides.length) throw new OPFPptxError("invalid-import-opf", "Slide provenance does not match the imported slides.");
|
|
383
|
+
restoreLogoFallback(imported, furnitureContexts);
|
|
363
384
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
364
385
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
365
386
|
|
|
@@ -374,6 +395,23 @@ export async function fromPptx(input, options = {}) {
|
|
|
374
395
|
return imported;
|
|
375
396
|
}
|
|
376
397
|
|
|
398
|
+
// A consumed logo picture (OPF_LOGO_V1) never becomes content. Its own image restores the logo only when nothing else
|
|
399
|
+
// did: the stored document and slide design (or the organization) win and carry LogoSet variants, so a package exported
|
|
400
|
+
// without provenance still keeps its logo. A slide-level logo path restores slide design; every other path the deck's.
|
|
401
|
+
function restoreLogoFallback(imported, contexts) {
|
|
402
|
+
const organizations = asArray(imported.organization);
|
|
403
|
+
contexts.forEach((context, index) => {
|
|
404
|
+
const fallback = context.logoFallback;
|
|
405
|
+
if (!fallback) return;
|
|
406
|
+
const slide = imported.slides[index];
|
|
407
|
+
const image = fallback.image;
|
|
408
|
+
// A slide's own logo restores whenever the slide states none, whatever the deck logo is; the deck logo only
|
|
409
|
+
// when neither it nor the organization has one.
|
|
410
|
+
if (fallback.path.startsWith('slides.')) { if (slide.design?.logo === undefined) slide.design = {...slide.design, logo: image}; }
|
|
411
|
+
else if (imported.design?.logo === undefined && !organizations.some(item => item?.logo !== undefined)) imported.design = {...imported.design, logo: image};
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
377
415
|
function readPptxZip(input) {
|
|
378
416
|
let bytes;
|
|
379
417
|
if (input instanceof Uint8Array) {
|
|
@@ -472,6 +510,17 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
|
|
|
472
510
|
.sort(compareSlidePaths);
|
|
473
511
|
}
|
|
474
512
|
|
|
513
|
+
// The `p:sldId` ids of the imported slides, aligned with `slidePaths`; empty
|
|
514
|
+
// when the slides were listed from the package rather than `p:sldIdLst`.
|
|
515
|
+
function slideIdsInOrder(presentationRoot, relationships, entries, slidePaths) {
|
|
516
|
+
const ids = [];
|
|
517
|
+
for (const slideId of asArray(presentationRoot["p:sldIdLst"]?.["p:sldId"])) {
|
|
518
|
+
const relationship = relationships.get(slideId?.["r:id"]);
|
|
519
|
+
if (relationship?.type.endsWith("/slide") && entries[relationship.path]) ids.push(String(slideId?.id ?? ""));
|
|
520
|
+
}
|
|
521
|
+
return ids.length === slidePaths.length ? ids : slidePaths.map(() => "");
|
|
522
|
+
}
|
|
523
|
+
|
|
475
524
|
function compareSlidePaths(left, right) {
|
|
476
525
|
return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
|
|
477
526
|
}
|
|
@@ -534,6 +583,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
534
583
|
diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
|
|
535
584
|
if (watermark.design) slide.design = {...slide.design, ...watermark.design};
|
|
536
585
|
nativeContext.watermarkPictures = watermark.consumed;
|
|
586
|
+
// The generated deck logo picture is not content; its value returns from the stored design (or, without one, from the picture).
|
|
587
|
+
const logoPath = `slides.${slideIndex}.design.logo`;
|
|
588
|
+
const logo = importLogo(nativeContext.pictures, relationships, entries, slideIndex,
|
|
589
|
+
picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
|
|
590
|
+
if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: logoPath});
|
|
591
|
+
}),
|
|
592
|
+
diagnostic => options.onDiagnostic?.({...diagnostic, path: logoPath}));
|
|
593
|
+
nativeContext.logoPictures = logo.consumed;
|
|
594
|
+
nativeContext.logoPlaceholderShapes = importLogoPlaceholders(nativeContext.shapes, relationships, entries, slideIndex);
|
|
595
|
+
nativeContext.logoFallback = logo.fallback;
|
|
537
596
|
|
|
538
597
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
|
|
539
598
|
.sort(comparePositionedItems);
|
|
@@ -557,10 +616,13 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
557
616
|
item.text = current.map(paragraph => paragraph.text).join('\n');
|
|
558
617
|
}
|
|
559
618
|
}
|
|
560
|
-
const
|
|
561
|
-
.map((item) => payloadFromSlideItem(item))
|
|
562
|
-
.filter(
|
|
619
|
+
const content = mergeAdjacentBulletShapes(mergeOpfListShapes(items))
|
|
620
|
+
.map((item) => ({payload: payloadFromSlideItem(item), bounds: item.visualBounds ?? item.bounds}))
|
|
621
|
+
.filter((entry) => entry.payload);
|
|
622
|
+
const blocks = content.map((entry) => entry.payload);
|
|
563
623
|
if (blocks.length > 0) slide.blocks = blocks;
|
|
624
|
+
// Native bounds of each block in reference px, for content topology matching (document provenance).
|
|
625
|
+
nativeContext.contentBounds = content.map((entry) => entry.bounds ? {x: entry.bounds.x * 96, y: entry.bounds.y * 96, width: entry.bounds.w * 96, height: entry.bounds.h * 96} : null);
|
|
564
626
|
|
|
565
627
|
const notes = readSlideNotes(entries, relationships);
|
|
566
628
|
if (notes) slide.notes = notes;
|
|
@@ -610,7 +672,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
610
672
|
for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
611
673
|
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});
|
|
612
674
|
for (const [index,shape] of shapes.entries()) {
|
|
613
|
-
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index)) continue;
|
|
675
|
+
if (furniture.text.has(index) || nativeContext.slideImageShapes?.has(index) || nativeContext.logoPlaceholderShapes?.has(index)) continue;
|
|
614
676
|
if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||media.consumed.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)||quotes.consumed.has(shape)) continue;
|
|
615
677
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
616
678
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
@@ -641,7 +703,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
641
703
|
}
|
|
642
704
|
|
|
643
705
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
644
|
-
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
|
|
706
|
+
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index) || nativeContext.logoPictures?.has(index)) continue;
|
|
645
707
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
646
708
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
647
709
|
if (item) items.push(item);
|
|
@@ -718,10 +780,23 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
718
780
|
};
|
|
719
781
|
}
|
|
720
782
|
|
|
783
|
+
// The box a quarter-turned picture occupies on the slide: `a:xfrm` describes the
|
|
784
|
+
// frame before rotation, so a 90 or 270 degree turn swaps its width and height
|
|
785
|
+
// around the same centre. Content topology matching compares this visual box.
|
|
786
|
+
function visualBounds(xfrm) {
|
|
787
|
+
const bounds = shapeBounds(xfrm);
|
|
788
|
+
const rotation = Number(xfrm?.rot ?? 0);
|
|
789
|
+
if (!bounds || !Number.isFinite(rotation) || ((rotation % 10800000) + 10800000) % 10800000 !== 5400000) return bounds;
|
|
790
|
+
return { x: bounds.x + bounds.w / 2 - bounds.h / 2, y: bounds.y + bounds.h / 2 - bounds.w / 2, w: bounds.h, h: bounds.w };
|
|
791
|
+
}
|
|
792
|
+
|
|
721
793
|
function importPicture(entries, picture, slidePath, relationships, report) {
|
|
722
794
|
const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
|
|
723
795
|
const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
|
|
724
|
-
|
|
796
|
+
// PptxGenJS (and so exports before this fix) wrote `preencoded.<ext>` for a picture with no alt text; that is no authored description.
|
|
797
|
+
const authoredAlt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr);
|
|
798
|
+
const alt = /^preencoded\.[a-z0-9]+$/i.test(authoredAlt.trim()) ? "" : authoredAlt;
|
|
799
|
+
const title = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.title);
|
|
725
800
|
const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
|
|
726
801
|
const relationship = relationships.get(relId);
|
|
727
802
|
let bytes = relationship?.path ? entries[relationship.path] : null;
|
|
@@ -743,12 +818,14 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
743
818
|
return {
|
|
744
819
|
kind: "image",
|
|
745
820
|
bounds,
|
|
821
|
+
visualBounds: visualBounds(picture["p:spPr"]?.["a:xfrm"]),
|
|
746
822
|
name,
|
|
747
823
|
payload: {
|
|
748
824
|
type: "image",
|
|
749
825
|
image: {
|
|
750
826
|
src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
|
|
751
|
-
...(alt ? { alt } : {})
|
|
827
|
+
...(alt ? { alt } : {}),
|
|
828
|
+
...(title && alt ? { title } : {})
|
|
752
829
|
}
|
|
753
830
|
}
|
|
754
831
|
};
|
|
@@ -771,7 +848,7 @@ function readParagraphs(txBody) {
|
|
|
771
848
|
}
|
|
772
849
|
return {
|
|
773
850
|
text: texts.join(""),
|
|
774
|
-
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
851
|
+
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buBlip"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
775
852
|
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
776
853
|
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
|
|
777
854
|
};
|
|
@@ -828,6 +905,59 @@ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
|
|
|
828
905
|
return null;
|
|
829
906
|
}
|
|
830
907
|
|
|
908
|
+
// The export writes every native line of a list as its own shape named "OPF list <path> line N". The lines of one list
|
|
909
|
+
// import as one list: a bulleted line starts an entry, an unbulleted line of the entry's own size continues it (a wrapped
|
|
910
|
+
// entry) and a smaller one is its description. Without a bulleted first line the shapes stay as they are.
|
|
911
|
+
const richRuns = value => typeof value === 'string' ? [{text: value}] : value;
|
|
912
|
+
function joinRich(a, b) {
|
|
913
|
+
if (typeof a === 'string' && typeof b === 'string') return a + b;
|
|
914
|
+
const runs = [...richRuns(a), ...richRuns(b)], joined = [];
|
|
915
|
+
for (const run of runs) {
|
|
916
|
+
const last = joined.at(-1);
|
|
917
|
+
if (last && JSON.stringify({...last, text: ''}) === JSON.stringify({...run, text: ''})) last.text += run.text;
|
|
918
|
+
else joined.push({...run});
|
|
919
|
+
}
|
|
920
|
+
return joined;
|
|
921
|
+
}
|
|
922
|
+
function mergeOpfListShapes(items) {
|
|
923
|
+
const lists = new Map();
|
|
924
|
+
for (const item of items) {
|
|
925
|
+
const match = /^OPF list (.+) line (\d+)$/.exec(item.name ?? '');
|
|
926
|
+
if (match && item.kind === 'text' && item.paragraphs?.length === 1) {
|
|
927
|
+
if (!lists.has(match[1])) lists.set(match[1], []);
|
|
928
|
+
lists.get(match[1]).push({item, line: Number(match[2])});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const merged = new Map(), consumed = new Set();
|
|
932
|
+
for (const [path, entries] of lists) {
|
|
933
|
+
entries.sort((a, b) => a.line - b.line);
|
|
934
|
+
if (!entries[0].item.paragraphs[0].bullet) continue;
|
|
935
|
+
const paragraphs = [];
|
|
936
|
+
let bounds, size = 0;
|
|
937
|
+
for (const {item} of entries) {
|
|
938
|
+
const [paragraph] = item.paragraphs, current = paragraphs.at(-1);
|
|
939
|
+
if (paragraph.bullet) { paragraphs.push({...paragraph}); size = paragraph.maxFontSize; }
|
|
940
|
+
else if (paragraph.maxFontSize < size - 0.01) {
|
|
941
|
+
const text = paragraph.richText ?? paragraph.text;
|
|
942
|
+
current.description = current.description === undefined ? text : joinRich(current.description, text);
|
|
943
|
+
} else {
|
|
944
|
+
const before = current.text;
|
|
945
|
+
current.text += paragraph.text;
|
|
946
|
+
if (current.richText !== undefined || paragraph.richText !== undefined) current.richText = joinRich(current.richText ?? before, paragraph.richText ?? paragraph.text);
|
|
947
|
+
}
|
|
948
|
+
const b = item.visualBounds ?? item.bounds;
|
|
949
|
+
if (b) {
|
|
950
|
+
const x = Math.min(bounds?.x ?? b.x, b.x), y = Math.min(bounds?.y ?? b.y, b.y);
|
|
951
|
+
bounds = {x, y, w: Math.max((bounds ? bounds.x + bounds.w : -Infinity), b.x + b.w) - x, h: Math.max((bounds ? bounds.y + bounds.h : -Infinity), b.y + b.h) - y};
|
|
952
|
+
}
|
|
953
|
+
consumed.add(item);
|
|
954
|
+
}
|
|
955
|
+
const first = entries[0].item;
|
|
956
|
+
merged.set(first, {...first, paragraphs, text: paragraphs.map(paragraph => paragraph.text).join('\n'), bounds, visualBounds: undefined});
|
|
957
|
+
}
|
|
958
|
+
return items.flatMap(item => merged.has(item) ? [merged.get(item)] : consumed.has(item) ? [] : [item]);
|
|
959
|
+
}
|
|
960
|
+
|
|
831
961
|
// Adjacent native bullet boxes on the same text column form one imported list.
|
|
832
962
|
// This is a geometry heuristic, not a lossless reconstruction of arbitrary PPTX.
|
|
833
963
|
function mergeAdjacentBulletShapes(items) {
|
|
@@ -855,8 +985,8 @@ function payloadFromSlideItem(item) {
|
|
|
855
985
|
type: "list",
|
|
856
986
|
items: item.paragraphs.map((paragraph) => (
|
|
857
987
|
paragraph.level > 0
|
|
858
|
-
? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
|
|
859
|
-
: (paragraph.richText ?? paragraph.text)
|
|
988
|
+
? { text: paragraph.richText ?? paragraph.text, level: paragraph.level, ...(paragraph.description !== undefined ? {description: paragraph.description} : {}) }
|
|
989
|
+
: paragraph.description !== undefined ? { text: paragraph.richText ?? paragraph.text, description: paragraph.description } : (paragraph.richText ?? paragraph.text)
|
|
860
990
|
))
|
|
861
991
|
};
|
|
862
992
|
}
|
|
@@ -1158,7 +1288,8 @@ function resolvePresentationContext(presentation, options) {
|
|
|
1158
1288
|
const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
|
|
1159
1289
|
const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
|
|
1160
1290
|
const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
|
|
1161
|
-
const
|
|
1291
|
+
const variables = resolveVariableColors(presentation.variables);
|
|
1292
|
+
const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
|
|
1162
1293
|
const fonts = resolveFonts(fontScheme);
|
|
1163
1294
|
for (const role of ["heading","body","code"]) fonts[role] = resolveTextStyle({fontFamily:fonts[role],fontWeight:role === "heading" ? 700 : 400},options.textMeasurement).fontFamily;
|
|
1164
1295
|
const textColor = readableTextColor(background, colorScheme);
|
|
@@ -1184,7 +1315,7 @@ function resolvePresentationContext(presentation, options) {
|
|
|
1184
1315
|
surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
|
|
1185
1316
|
border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
|
|
1186
1317
|
},
|
|
1187
|
-
variables
|
|
1318
|
+
variables
|
|
1188
1319
|
};
|
|
1189
1320
|
}
|
|
1190
1321
|
|
|
@@ -1207,7 +1338,23 @@ function nativeColor(reference, hex, context, fallback) {
|
|
|
1207
1338
|
|
|
1208
1339
|
// FF-59: the slide tag is the eyebrow label. It is written in the deck primary color, the scheme accent1 slot the
|
|
1209
1340
|
// preview draws it from: a:schemeClr accent1 where the deck theme holds that exact color, the literal otherwise.
|
|
1341
|
+
// FF-61: unless that primary is under 4.5:1 (WCAG 2.x) against the slide background, in which case the tag takes the
|
|
1342
|
+
// slide text color, the value the title and body text get (the scheme text slot where the theme holds it). opf-render
|
|
1343
|
+
// applies the same rule with the same arithmetic (test/tag-colour.mjs in both repositories pins the same color pairs).
|
|
1344
|
+
const TAG_MIN_CONTRAST = 4.5;
|
|
1345
|
+
function relativeLuminance(hex) {
|
|
1346
|
+
const [red, green, blue] = [0, 2, 4].map(offset => {
|
|
1347
|
+
const value = Number.parseInt(normalizeHex(hex).slice(offset, offset + 2), 16) / 255;
|
|
1348
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
1349
|
+
});
|
|
1350
|
+
return red * 0.2126 + green * 0.7152 + blue * 0.0722;
|
|
1351
|
+
}
|
|
1352
|
+
function contrastRatio(first, second) {
|
|
1353
|
+
const a = relativeLuminance(first), b = relativeLuminance(second);
|
|
1354
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
1355
|
+
}
|
|
1210
1356
|
function tagColor(context) {
|
|
1357
|
+
if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
|
|
1211
1358
|
return nativeColor('primary', context.colors.accent, context);
|
|
1212
1359
|
}
|
|
1213
1360
|
|
|
@@ -1313,14 +1460,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1313
1460
|
const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
|
|
1314
1461
|
const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
|
|
1315
1462
|
width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
|
|
1316
|
-
}, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext));
|
|
1463
|
+
}, slideContext.colors.background, slideContext.colors.text, (reference, hex) => schemeColorValue(reference, hex, slideContext), reference => resolveBackgroundColorRef(reference, slideContext.colorScheme, slideContext.variables));
|
|
1317
1464
|
if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
|
|
1318
1465
|
if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
|
|
1319
1466
|
options.onDiagnostic?.({code: 'unsupported-pattern', path: `${backgroundPath}.pattern.preset`, message: `Pattern ${backgroundDefinition.pattern?.preset} has no DrawingML preset; only its background color was exported.`});
|
|
1320
1467
|
}
|
|
1321
1468
|
if (backgroundDefinition?.type === 'image') {
|
|
1322
1469
|
const imagePath = `${backgroundPath}.image`;
|
|
1323
|
-
const
|
|
1470
|
+
const outcome = {};
|
|
1471
|
+
const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
|
|
1324
1472
|
if (resolved) {
|
|
1325
1473
|
// PptxGenJS embeds the raster and its relationship; packaging replaces
|
|
1326
1474
|
// its stretched fill with the fitted native picture fill.
|
|
@@ -1329,7 +1477,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1329
1477
|
fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
|
|
1330
1478
|
width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
|
|
1331
1479
|
} });
|
|
1332
|
-
} else {
|
|
1480
|
+
} else if (!outcome.reported) {
|
|
1333
1481
|
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.'});
|
|
1334
1482
|
}
|
|
1335
1483
|
}
|
|
@@ -1349,11 +1497,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1349
1497
|
// Core composition resolves one alignment per composed item for every engine
|
|
1350
1498
|
// (FF-29); the design fallback keeps cores published before item.alignment working.
|
|
1351
1499
|
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1352
|
-
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) });
|
|
1500
|
+
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, darkBackground: isDarkHex(slideContext.colors.background), textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
|
|
1353
1501
|
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
1502
|
+
// The content topology (groups, regions, root form, block ids) with the leaf boxes this geometry draws.
|
|
1503
|
+
recordContentTopology(context.documentProvenance, opfSlide, slideIndex, geometry.items);
|
|
1354
1504
|
// The preview paints background, slide image (and its overlay), then design.watermark, then content.
|
|
1355
1505
|
if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
|
|
1356
1506
|
await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
|
|
1507
|
+
// Cover and section slides: the deck logo core composed at the top-left of the free area, after the watermark and before content.
|
|
1508
|
+
if (geometry.logo) await addLogo(slide, presentation, geometry.logo, slideIndex, slideContext, context, options);
|
|
1357
1509
|
for (const item of geometry.items) {
|
|
1358
1510
|
// Card text sits on a literal card fill, not the slide background: keep it literal.
|
|
1359
1511
|
const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
|
|
@@ -1383,7 +1535,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1383
1535
|
breakLine: false
|
|
1384
1536
|
});
|
|
1385
1537
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1386
|
-
addMeasuredList(slide,item.text,itemContext,item.path);
|
|
1538
|
+
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
|
|
1387
1539
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
1388
1540
|
const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
1389
1541
|
for(const [index,line] of item.text.richLines.entries()){
|
|
@@ -1521,7 +1673,25 @@ function richLineRuns(line,color,native,context) {
|
|
|
1521
1673
|
// the other measured text shapes. A reader maps lines to their list by name, not
|
|
1522
1674
|
// by geometry, so lines of an overflowing list that fall below its box still
|
|
1523
1675
|
// belong to it.
|
|
1524
|
-
|
|
1676
|
+
// design.listBullet: image (core's item.bulletImage, the deck's icon logo) makes every entry marker a native picture bullet.
|
|
1677
|
+
// PptxGenJS embeds the media and its relationship through one picture per slide (OPF bullet image); packaging removes
|
|
1678
|
+
// that picture and points each marker paragraph at the relationship with a:buBlip. An icon that cannot be embedded
|
|
1679
|
+
// keeps the character bullets, as the preview does.
|
|
1680
|
+
async function addMeasuredList(slide,fit,context,path,bulletImage,presentation,slideIndex,options) {
|
|
1681
|
+
let picture=false;
|
|
1682
|
+
if(bulletImage){
|
|
1683
|
+
const part=`ppt/slides/slide${slideIndex+1}.xml`;
|
|
1684
|
+
if(context.bulletImages.has(part))picture=context.bulletImages.get(part);
|
|
1685
|
+
else{
|
|
1686
|
+
const outcome={};
|
|
1687
|
+
const resolved=await resolveImage(bulletImage.source,presentation,options,bulletImage.path,outcome);
|
|
1688
|
+
if(resolved){
|
|
1689
|
+
slide.addImage({...resolved,objectName:bulletImageName(),x:0,y:0,w:.1,h:.1,altText:''});
|
|
1690
|
+
picture=true;
|
|
1691
|
+
}else if(!outcome.reported)options.onDiagnostic?.({code:'unresolved-asset',path:bulletImage.path,message:'A picture bullet needs an embedded raster, a declared asset or a host imageResolver; the character bullets were exported instead.'});
|
|
1692
|
+
context.bulletImages.set(part,picture);
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1525
1695
|
let lineNumber=0;
|
|
1526
1696
|
for(const entry of fit.listEntries){
|
|
1527
1697
|
const addLines=(text,box,color,native,withBullet)=>{
|
|
@@ -1529,7 +1699,7 @@ function addMeasuredList(slide,fit,context,path) {
|
|
|
1529
1699
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1530
1700
|
const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
1531
1701
|
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1532
|
-
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor)});
|
|
1702
|
+
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor),picture});
|
|
1533
1703
|
const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
1534
1704
|
const runs=richLineRuns(line,color,native,context);
|
|
1535
1705
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
@@ -1591,6 +1761,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
1591
1761
|
}
|
|
1592
1762
|
const objectName = `OPF image ${context.imagePlacements.size + 1}`;
|
|
1593
1763
|
context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
|
|
1764
|
+
context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
|
|
1594
1765
|
slide.addImage({
|
|
1595
1766
|
...resolved,
|
|
1596
1767
|
objectName,
|
|
@@ -1618,9 +1789,10 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
1618
1789
|
notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
|
|
1619
1790
|
return;
|
|
1620
1791
|
}
|
|
1621
|
-
const
|
|
1792
|
+
const outcome = {};
|
|
1793
|
+
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
1622
1794
|
if (!resolved) {
|
|
1623
|
-
options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
|
|
1795
|
+
if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
|
|
1624
1796
|
return;
|
|
1625
1797
|
}
|
|
1626
1798
|
const {widthInches, heightInches} = slideContext.dimensions;
|
|
@@ -1630,6 +1802,24 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
1630
1802
|
slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
|
|
1631
1803
|
}
|
|
1632
1804
|
|
|
1805
|
+
// The deck logo (design.logo, a slide's own logo or the primary organization's) on a cover or section slide: one native
|
|
1806
|
+
// picture per slide at core's geometry.logo box, fitted without cropping, anchored left and vertically centered, after
|
|
1807
|
+
// the watermark and before content (the preview's paint order). The frame and the provenance tag are written after
|
|
1808
|
+
// PptxGenJS embeds the bytes. An unresolved source draws the preview's "Image unavailable" panel in the logo box.
|
|
1809
|
+
async function addLogo(slide, presentation, logo, slideIndex, slideContext, context, options) {
|
|
1810
|
+
const region = {x: logo.box.x / 96, y: logo.box.y / 96, w: logo.box.width / 96, h: logo.box.height / 96};
|
|
1811
|
+
const outcome = {};
|
|
1812
|
+
const resolved = await resolveImage(logo.source, presentation, options, logo.path, outcome);
|
|
1813
|
+
if (!resolved) {
|
|
1814
|
+
if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: logo.path, message: 'The logo needs an embedded raster, a declared asset or a host imageResolver; the logo panel shows "Image unavailable" instead.'});
|
|
1815
|
+
const prefix = addImagePlaceholder(slide, presentation, logo.source, region, logo.path, slideContext, options, {logo: true});
|
|
1816
|
+
if (prefix) context.logoPlaceholderTags.set(prefix, {v: 1, role: 'placeholder', slide: `slides.${slideIndex}`, path: logo.path});
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
context.logos.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box: region, path: logo.path, variant: logo.variant});
|
|
1820
|
+
slide.addImage({...resolved, objectName: logoName(), ...region, altText: assetAlt(logo.source, presentation) ?? 'Logo'});
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1633
1823
|
// design.slideImage: one native picture at the shared frame, beneath content.
|
|
1634
1824
|
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
1635
1825
|
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
@@ -1659,7 +1849,9 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1659
1849
|
// FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
|
|
1660
1850
|
// manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
|
|
1661
1851
|
context.slideImages.set(objectName, { slide: `slides.${slideIndex}`, box, fill: image.fill, path: image.sourcePath, treatment: { ...treatment, position: image.position }, effects, content: image.replacesContent === true });
|
|
1662
|
-
|
|
1852
|
+
const slideImageAlt = image.alt ?? assetAlt(image.value, presentation);
|
|
1853
|
+
context.pictureText.set(objectName, pictureText(slideImageAlt, image.value, presentation));
|
|
1854
|
+
slide.addImage({ ...resolved, objectName, ...box, altText: slideImageAlt });
|
|
1663
1855
|
// The overlay scrim is a separate native shape directly above the picture.
|
|
1664
1856
|
if (effects.overlay) {
|
|
1665
1857
|
const overlay = effects.overlay.box;
|
|
@@ -1667,6 +1859,21 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1667
1859
|
}
|
|
1668
1860
|
}
|
|
1669
1861
|
|
|
1862
|
+
// FF-62: chart text is one size. The preview draws every chart label (axis, legend, data label) at
|
|
1863
|
+
// max(14, composition.minFontSize ?? 16) px scaled by the slide's shorter side over 720 px (renderer charts.js, `fontPx`),
|
|
1864
|
+
// the readability floor: 12 pt on a 13.33 x 7.5 in slide. The export writes that size, in hundredths of a point (DrawingML `sz`).
|
|
1865
|
+
function chartTextSize(context) {
|
|
1866
|
+
const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
|
|
1867
|
+
return Math.round(Math.max(14, context.composition?.minFontSize ?? 16) * scale * 75);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// Every c:txPr of a generated classic chart carries the chart text size: PptxGenJS writes no size on the legend (PowerPoint's
|
|
1871
|
+
// 18 pt default) and hardcodes 18 pt on pie and doughnut data labels. Only the size changes; faces and colours stay as written.
|
|
1872
|
+
function applyChartTextSize(xml, size) {
|
|
1873
|
+
return xml.replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g, properties => properties.replace(/<a:defRPr\b([^>]*?)(\/?)>/g, (match, attributes, close) =>
|
|
1874
|
+
`<a:defRPr${/\bsz="[^"]*"/.test(attributes) ? attributes.replace(/\bsz="[^"]*"/, `sz="${size}"`) : `${attributes} sz="${size}"`}${close}>`));
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1670
1877
|
function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
|
|
1671
1878
|
const chartData = toPptxChartData(chart, context.chartexMode);
|
|
1672
1879
|
if (!chartData.series) {
|
|
@@ -1685,16 +1892,17 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1685
1892
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
1686
1893
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1687
1894
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1688
|
-
|
|
1895
|
+
const textSize = chartTextSize(context);
|
|
1896
|
+
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize});
|
|
1689
1897
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1690
1898
|
const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
|
|
1691
1899
|
if (chartData.chartex) {
|
|
1692
1900
|
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1693
1901
|
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1694
1902
|
const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
|
|
1695
|
-
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette});
|
|
1903
|
+
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize});
|
|
1696
1904
|
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1697
|
-
options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography:
|
|
1905
|
+
options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography (no cx:geoCache; provider data is never fabricated): PowerPoint must fetch the region shapes from its online map service when the deck is opened, and until it does it shows "There was a problem getting the information for your map chart" and draws nothing. The clustered column fallback shows the same values in readers without chartex support.`});
|
|
1698
1906
|
}
|
|
1699
1907
|
}
|
|
1700
1908
|
const percent = chartData.spec.grouping === 'percentStacked';
|
|
@@ -1711,14 +1919,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1711
1919
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1712
1920
|
plotArea: {fill:{color:null}},
|
|
1713
1921
|
catAxisLabelFontFace: context.fonts.body,
|
|
1714
|
-
catAxisLabelFontSize:
|
|
1922
|
+
catAxisLabelFontSize: textSize / 100,
|
|
1715
1923
|
catAxisLabelColor: labelColor,
|
|
1716
1924
|
valAxisLabelFontFace: context.fonts.body,
|
|
1717
|
-
valAxisLabelFontSize:
|
|
1925
|
+
valAxisLabelFontSize: textSize / 100,
|
|
1718
1926
|
valAxisLabelColor: labelColor,
|
|
1719
1927
|
legendColor: labelColor,
|
|
1720
1928
|
legendFontFace: context.fonts.body,
|
|
1929
|
+
legendFontSize: textSize / 100,
|
|
1721
1930
|
dataLabelColor: labelColor,
|
|
1931
|
+
dataLabelFontSize: textSize / 100,
|
|
1722
1932
|
showValue: false,
|
|
1723
1933
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
1724
1934
|
barDir: chartData.barDir,
|
|
@@ -1892,7 +2102,9 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1892
2102
|
if(part.type==='image'){
|
|
1893
2103
|
const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
|
|
1894
2104
|
const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
|
|
1895
|
-
|
|
2105
|
+
// A generated deck logo is listed beside the manifest topology (see furnitureManifest) and tagged as a logo.
|
|
2106
|
+
if (objectName && part.field === 'logo') context.furnitureLogoTags.set(objectName, {v:1, role:'furniture', group:String(slideIndex), furniture:part.kind, zone:part.zone});
|
|
2107
|
+
else if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:manifestPartIndex(layout.parts, index)});
|
|
1896
2108
|
}else{
|
|
1897
2109
|
if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
|
|
1898
2110
|
// Slide numbers and current dates become native PowerPoint fields; {total}
|
|
@@ -1912,13 +2124,15 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1912
2124
|
if(marker) staticDates.set(index,marker);
|
|
1913
2125
|
}
|
|
1914
2126
|
}
|
|
1915
|
-
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index},liveFields,links:part.links});
|
|
2127
|
+
addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:manifestPartIndex(layout.parts,index)},liveFields,links:part.links});
|
|
1916
2128
|
}
|
|
1917
2129
|
}
|
|
1918
2130
|
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
|
|
1919
2131
|
if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
|
|
1920
2132
|
}
|
|
1921
2133
|
|
|
2134
|
+
const bulletImageName = () => 'OPF bullet image';
|
|
2135
|
+
|
|
1922
2136
|
function addCodePayload(slide, value, layout, region, context, path) {
|
|
1923
2137
|
if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
|
|
1924
2138
|
for (const part of layout.parts) {
|
|
@@ -2054,7 +2268,9 @@ function placeholderAsset(asset, presentation) {
|
|
|
2054
2268
|
// minimum) in the readable text colour for the panel, or a cross when the label
|
|
2055
2269
|
// cannot fit even at that minimum. The panel carries the accessible name the
|
|
2056
2270
|
// preview gives its group, "Image unavailable: <description>".
|
|
2057
|
-
|
|
2271
|
+
// {logo: true} names the panel's other shapes after its panel (OPF image placeholder N text line i / icon i) and returns the
|
|
2272
|
+
// panel's name, so a logo placeholder can be tagged and consumed on import.
|
|
2273
|
+
function addImagePlaceholder(slide, presentation, asset, region, path, context, options, {logo = false} = {}) {
|
|
2058
2274
|
const layered = placeholderAsset(asset, presentation);
|
|
2059
2275
|
const description = String(layered.alt ?? layered.title ?? "Image");
|
|
2060
2276
|
const label = `Image unavailable\n${description}`;
|
|
@@ -2088,17 +2304,18 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
|
|
|
2088
2304
|
const centered = { ...inner, y: inner.y + offset };
|
|
2089
2305
|
addMeasuredPayloadText(slide, label, centered, context, options, {
|
|
2090
2306
|
path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
|
|
2091
|
-
diagnosticsHandled: true, align: "center", color: textColor
|
|
2307
|
+
diagnosticsHandled: true, align: "center", color: textColor, ...(logo ? {objectName: `${objectName} text`} : {})
|
|
2092
2308
|
});
|
|
2093
|
-
return;
|
|
2309
|
+
return logo ? objectName : undefined;
|
|
2094
2310
|
}
|
|
2095
2311
|
// A status indicator, not shortened authored content: the full description stays
|
|
2096
2312
|
// in the panel's accessible name.
|
|
2097
2313
|
const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
|
|
2098
|
-
if (!size) return;
|
|
2314
|
+
if (!size) return logo ? objectName : undefined;
|
|
2099
2315
|
const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
|
|
2100
2316
|
const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
|
|
2101
|
-
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 });
|
|
2317
|
+
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, ...(logo ? {objectName: `${objectName} icon ${flipV ? 2 : 1}`} : {}) });
|
|
2318
|
+
return logo ? objectName : undefined;
|
|
2102
2319
|
}
|
|
2103
2320
|
|
|
2104
2321
|
// A placeholder names what is missing in plain words. It never dumps the source value, so no data or URL lands in slide text.
|
|
@@ -2232,7 +2449,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2232
2449
|
* into equal-width bins and exported as a column chart of the counts, every other type plots the values against
|
|
2233
2450
|
* their row numbers. `adapted` names that transformation so it is reported, never silent.
|
|
2234
2451
|
*/
|
|
2235
|
-
function toPptxChartData(chart, chartexMode = '
|
|
2452
|
+
function toPptxChartData(chart, chartexMode = 'auto') {
|
|
2236
2453
|
const data = chart?.data;
|
|
2237
2454
|
const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
|
|
2238
2455
|
if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
|
|
@@ -2241,17 +2458,21 @@ function toPptxChartData(chart, chartexMode = 'fallback') {
|
|
|
2241
2458
|
if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2242
2459
|
if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
2243
2460
|
const resolved = resolveChartType(chart.type).spec;
|
|
2244
|
-
// A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct.
|
|
2245
|
-
//
|
|
2246
|
-
//
|
|
2247
|
-
// column chart alone
|
|
2248
|
-
|
|
2461
|
+
// A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. Natively
|
|
2462
|
+
// it is written as its cx:chartSpace part and the clustered column chart of the same data is its mc:Fallback
|
|
2463
|
+
// (src/chartex.js). 'auto' (the default) does that for the constructs the native PowerPoint check confirmed and keeps
|
|
2464
|
+
// the unconfirmed map on the clustered column chart alone; 'fallback' keeps every chartex id there. The fallback is
|
|
2465
|
+
// reported as chartex-fallback, never silent.
|
|
2466
|
+
const native = chartexMode === 'native' || (chartexMode === 'auto' && resolved.family === 'chartex' && !resolved.unconfirmed);
|
|
2249
2467
|
const chartex = resolved.family === 'chartex' && native ? resolved : null;
|
|
2250
2468
|
const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
|
|
2251
2469
|
const typeName = stringifyText(chart.type);
|
|
2252
2470
|
const adaptations = [];
|
|
2253
2471
|
if (resolved.family === 'chartex' && !native) {
|
|
2254
|
-
|
|
2472
|
+
const reason = chartexMode === 'fallback'
|
|
2473
|
+
? 'that this exporter does not write'
|
|
2474
|
+
: 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
|
|
2475
|
+
adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart ${reason}; its data is exported as a native clustered column chart instead.`});
|
|
2255
2476
|
}
|
|
2256
2477
|
const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
|
|
2257
2478
|
if (data.columns.length === 1) {
|
|
@@ -2291,7 +2512,9 @@ function toPptxChartData(chart, chartexMode = 'fallback') {
|
|
|
2291
2512
|
let series = data.columns.slice(1).map((name, seriesIndex) => ({
|
|
2292
2513
|
name: stringifyText(name),
|
|
2293
2514
|
labels,
|
|
2294
|
-
|
|
2515
|
+
// A cell that holds no number (null, an empty or non-numeric string, a boolean) is a gap, as in the preview and the single-column
|
|
2516
|
+
// path above: it is neither plotted as a zero nor written into the cache as one.
|
|
2517
|
+
values: data.rows.map((row) => parsedNumber(row?.[seriesIndex + 1]))
|
|
2295
2518
|
}));
|
|
2296
2519
|
const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
|
|
2297
2520
|
if (series.length > plotted) {
|
|
@@ -2335,7 +2558,42 @@ function histogramBins(values) {
|
|
|
2335
2558
|
}
|
|
2336
2559
|
}
|
|
2337
2560
|
|
|
2338
|
-
|
|
2561
|
+
// Embedded bytes must be a raster the exporter can measure and PptxGenJS can embed (PNG, JPEG, GIF, WebP). An SVG, or
|
|
2562
|
+
// bytes that are no readable image, would otherwise fail deep inside packaging. Like an unresolved asset it is not
|
|
2563
|
+
// exported as a picture: the caller draws the preview's placeholder (or omits the picture) and one `unresolved-asset`
|
|
2564
|
+
// diagnostic names the cause; `strictAssets` keeps the `unsupported-image-dimensions` error. `outcome.reported` tells a
|
|
2565
|
+
// caller that the diagnostic was already emitted. Hosts that need SVG art supply a raster through `imageResolver`
|
|
2566
|
+
// (for example opf-render's `svgToPng`). Local paths are read by PptxGenJS and checked after embedding.
|
|
2567
|
+
async function resolveImage(asset, presentation, options, path, outcome = {}) {
|
|
2568
|
+
const resolved = await resolveImageSource(asset, presentation, options, path);
|
|
2569
|
+
if (!resolved?.data) return resolved;
|
|
2570
|
+
const bytes = dataUriBytes(resolved.data);
|
|
2571
|
+
if (bytes && rasterMetadata(bytes)) return resolved;
|
|
2572
|
+
const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render svgToPng).`;
|
|
2573
|
+
if (options.strictAssets) throw new OPFPptxError("unsupported-image-dimensions", `Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. ${message}`, { path });
|
|
2574
|
+
options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "unsupported-format", message });
|
|
2575
|
+
outcome.reported = true;
|
|
2576
|
+
return null;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
function dataUriMediaType(uri) {
|
|
2580
|
+
return /^data:([^;,]+)/i.exec(uri)?.[1]?.toLowerCase();
|
|
2581
|
+
}
|
|
2582
|
+
|
|
2583
|
+
// Only base64 data URIs carry bytes the exporter can read; any other encoding is not a readable raster.
|
|
2584
|
+
function dataUriBytes(uri) {
|
|
2585
|
+
const match = /^data:[^,]*;base64,/i.exec(uri);
|
|
2586
|
+
if (!match) return null;
|
|
2587
|
+
try {
|
|
2588
|
+
if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(uri.slice(match[0].length), "base64"));
|
|
2589
|
+
const binary = atob(uri.slice(match[0].length));
|
|
2590
|
+
return Uint8Array.from(binary, char => char.charCodeAt(0));
|
|
2591
|
+
} catch {
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
async function resolveImageSource(asset, presentation, options, path) {
|
|
2339
2597
|
const assetObject = dereferenceAsset(asset, presentation, path);
|
|
2340
2598
|
const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
|
|
2341
2599
|
if (!src) {
|
|
@@ -2406,6 +2664,24 @@ function dereferenceAsset(asset, presentation, path, seen = new Set()) {
|
|
|
2406
2664
|
return asset;
|
|
2407
2665
|
}
|
|
2408
2666
|
|
|
2667
|
+
// PptxGenJS writes its own stand-in (`preencoded.png`, or the local file path) as the picture's description when it is
|
|
2668
|
+
// given no alt text. The package writes only what the document authored: the alt text as `descr`, and a distinct asset
|
|
2669
|
+
// `title` as the native `title` attribute (a picture's tooltip and Alt Text title).
|
|
2670
|
+
function pictureText(alt, asset, presentation) {
|
|
2671
|
+
const resolved = dereferenceAsset(asset, presentation, "asset-title");
|
|
2672
|
+
const title = isPlainObject(resolved) && typeof resolved.title === "string" && resolved.title && resolved.title !== alt ? resolved.title : undefined;
|
|
2673
|
+
return { alt: typeof alt === "string" && alt ? alt : undefined, title: alt ? title : undefined };
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function writePictureText(picture, text) {
|
|
2677
|
+
const escapes = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'", "\r": " ", "\n": " ", "\t": "	" };
|
|
2678
|
+
const escape = value => value.replace(/[&<>"'\r\n\t]/g, char => escapes[char]);
|
|
2679
|
+
return picture.replace(/<p:cNvPr\b([^>]*?)(\/?)>/, (tag, attributes, close) => {
|
|
2680
|
+
attributes = attributes.replace(/\sdescr="[^"]*"/, "").replace(/\stitle="[^"]*"/, "");
|
|
2681
|
+
return `<p:cNvPr${attributes}${text.alt ? ` descr="${escape(text.alt)}"` : ""}${text.title ? ` title="${escape(text.title)}"` : ""}${close}>`;
|
|
2682
|
+
});
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2409
2685
|
function assetAlt(asset, presentation) {
|
|
2410
2686
|
const resolved = dereferenceAsset(asset, presentation, "asset-alt");
|
|
2411
2687
|
if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
|
|
@@ -2445,10 +2721,13 @@ const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
|
2445
2721
|
// catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
|
|
2446
2722
|
// bundled catalog (the engine default source also resolves to it).
|
|
2447
2723
|
function socialPlatformRecords(presentation, options) {
|
|
2448
|
-
const kind = "socialPlatforms",
|
|
2449
|
-
|
|
2450
|
-
const sourceRecords =
|
|
2451
|
-
|
|
2724
|
+
const kind = "socialPlatforms", declared = presentation.catalogs?.[kind]?.source;
|
|
2725
|
+
// `source` is one source or an ordered search path (an array): records of each source in order, first match wins.
|
|
2726
|
+
const sourceRecords = (Array.isArray(declared) ? declared : [declared]).filter(source => typeof source === "string").flatMap(source => {
|
|
2727
|
+
const bySource = options.catalogSources?.[source];
|
|
2728
|
+
return bySource ? normalizeRecords(bySource)
|
|
2729
|
+
: source.startsWith(DEFAULT_SOURCE_PREFIX) || source.startsWith("pkg:@openpresentation/opf/") ? defaultCatalog(kind) : [];
|
|
2730
|
+
});
|
|
2452
2731
|
return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
|
|
2453
2732
|
}
|
|
2454
2733
|
|
|
@@ -2476,19 +2755,26 @@ function resolveDimensions(value) {
|
|
|
2476
2755
|
return { widthInches: width / 96, heightInches: height / 96 };
|
|
2477
2756
|
}
|
|
2478
2757
|
|
|
2479
|
-
|
|
2758
|
+
// A solid color or pattern background color is a ColorRef (hex, `var:` variable, colour-scheme slot or role), resolved
|
|
2759
|
+
// as for table fills and run colours. Roles resolve through the colour scheme alone: the background cannot depend on itself.
|
|
2760
|
+
function resolveBackgroundColorRef(entry, colorScheme, variables = {}) {
|
|
2761
|
+
return resolveColorRefValue(entry, {colorScheme, colors: {}, variables});
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
function resolveBackground(value, colorScheme, variables = {}) {
|
|
2480
2765
|
const fallback = "FFFFFF";
|
|
2766
|
+
const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
|
|
2481
2767
|
if (typeof value === "string") {
|
|
2482
2768
|
if (value.startsWith("#")) return normalizeHex(value, fallback);
|
|
2483
2769
|
return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
2484
2770
|
}
|
|
2485
2771
|
if (isPlainObject(value)) {
|
|
2486
|
-
if (value.type === "solid" && value.color) return
|
|
2772
|
+
if (value.type === "solid" && value.color) return reference(value.color);
|
|
2487
2773
|
if (value.type === "theme" && value.slot) {
|
|
2488
2774
|
return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
2489
2775
|
}
|
|
2490
2776
|
// Like the SVG preview, text contrast follows a pattern's background color.
|
|
2491
|
-
if (value.type === "pattern") return
|
|
2777
|
+
if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
|
|
2492
2778
|
if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
|
|
2493
2779
|
}
|
|
2494
2780
|
return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
@@ -2545,7 +2831,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
2545
2831
|
attachTimelineTags(entries,context.timelineTags);
|
|
2546
2832
|
attachQuoteTags(entries,context.quoteTags);
|
|
2547
2833
|
attachFurnitureFields(entries,context.furnitureFields);
|
|
2548
|
-
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
|
|
2834
|
+
attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests,context.furnitureLogoTags);
|
|
2835
|
+
// The "Image unavailable" panel of an unresolved logo: identity only, so import does not read it as content.
|
|
2836
|
+
attachTextTags(entries,context.logoPlaceholderTags,LOGO_TAG,'opfLogoPlaceholder','logo placeholder');
|
|
2549
2837
|
for(const [part,bytes]of Object.entries(entries)){
|
|
2550
2838
|
if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
|
|
2551
2839
|
const relationships=parseRelationships(entries,part);
|
|
@@ -2554,12 +2842,13 @@ async function normalizePptxZip(raw, context) {
|
|
|
2554
2842
|
if(!context.chartHeadings.has(name))continue;
|
|
2555
2843
|
const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
|
|
2556
2844
|
if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
|
|
2557
|
-
const {heading,labelColor,spec}=context.chartHeadings.get(name);
|
|
2845
|
+
const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
|
|
2558
2846
|
if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
|
|
2559
|
-
entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
|
|
2847
|
+
entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
|
|
2560
2848
|
// PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
|
|
2561
2849
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
2562
2850
|
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>`)));
|
|
2851
|
+
entries[chartPart]=encodeText(applyChartTextSize(decodeText(entries[chartPart]),textSize));
|
|
2563
2852
|
}
|
|
2564
2853
|
}
|
|
2565
2854
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
@@ -2570,13 +2859,18 @@ async function normalizePptxZip(raw, context) {
|
|
|
2570
2859
|
throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
|
|
2571
2860
|
}
|
|
2572
2861
|
applyPitchFamilies(entries,context.fontPitch);
|
|
2862
|
+
// Native PowerPoint sections (p14:sectionLst) from slide `section` labels, written whatever the provenance option.
|
|
2863
|
+
if (context.sections.some(section => section !== undefined)) {
|
|
2864
|
+
try { entries['ppt/presentation.xml'] = encodeText(writeSectionList(decodeText(entries['ppt/presentation.xml']), context.sections)); }
|
|
2865
|
+
catch (error) { throw new OPFPptxError('packaging-failed', 'Section list could not be written.', {cause: errorMessage(error)}); }
|
|
2866
|
+
}
|
|
2573
2867
|
const imageSources = new Map();
|
|
2574
2868
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
2575
2869
|
if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
|
|
2576
2870
|
const relationships = parseRelationships(entries, part);
|
|
2577
2871
|
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
2578
2872
|
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
2579
|
-
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : undefined);
|
|
2873
|
+
const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : name === logoName() ? context.logos.get(part) : undefined);
|
|
2580
2874
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
2581
2875
|
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
2582
2876
|
}
|
|
@@ -2609,6 +2903,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
2609
2903
|
placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2610
2904
|
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
2611
2905
|
});
|
|
2906
|
+
placeLogos(entries, context.logos, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2907
|
+
throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
|
|
2908
|
+
});
|
|
2612
2909
|
// A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
|
|
2613
2910
|
dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
|
|
2614
2911
|
// Charts and notes follow the language and fonts of the slide they belong to.
|
|
@@ -2812,7 +3109,15 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2812
3109
|
// Image data is already resolved and embedded by PptxGenJS. Read those
|
|
2813
3110
|
// exact bytes instead of fetching or resolving the source a second time.
|
|
2814
3111
|
const relationships = parseRelationships(entries, path);
|
|
3112
|
+
let bulletRelationship;
|
|
2815
3113
|
xml = xml.replace(/<p:pic>([\s\S]*?)<\/p:pic>/g, picture => {
|
|
3114
|
+
if (picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] === bulletImageName()) {
|
|
3115
|
+
// Only its media part and relationship are wanted: every picture-bullet paragraph points at them (a:buBlip).
|
|
3116
|
+
bulletRelationship = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
3117
|
+
return '';
|
|
3118
|
+
}
|
|
3119
|
+
const text = context.pictureText.get(picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
|
|
3120
|
+
if (text) picture = writePictureText(picture, text);
|
|
2816
3121
|
const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
|
|
2817
3122
|
if (!placement) return picture;
|
|
2818
3123
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
@@ -2833,6 +3138,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2833
3138
|
xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
|
|
2834
3139
|
const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
|
|
2835
3140
|
if(!marker)return shape;
|
|
3141
|
+
if(marker.picture&&bulletRelationship)return shape.replace(/<a:buChar\b[^>]*\/>/g,`<a:buBlip><a:blip r:embed="${bulletRelationship}"/></a:buBlip>`);
|
|
2836
3142
|
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
2837
3143
|
return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr>${solidColorXml(marker.color)}</a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
|
|
2838
3144
|
});
|
|
@@ -2921,9 +3227,12 @@ function normalizeNestedZip(bytes, context) {
|
|
|
2921
3227
|
const entries = unzipSync(bytes);
|
|
2922
3228
|
const output = {};
|
|
2923
3229
|
for (const path of Object.keys(entries).sort()) {
|
|
3230
|
+
// A gap in the chart data is a blank workbook cell, not a numeric cell with an empty value.
|
|
2924
3231
|
const entryBytes = path === "docProps/core.xml"
|
|
2925
3232
|
? encodeText(normalizeCoreProperties(decodeText(entries[path]), context.timestamp))
|
|
2926
|
-
:
|
|
3233
|
+
: /^xl\/worksheets\/sheet\d+\.xml$/.test(path)
|
|
3234
|
+
? encodeText(decodeText(entries[path]).replace(/<c ((?:r|s)="[^"]*"(?: (?:r|s)="[^"]*")*)><v><\/v><\/c>/g, "<c $1/>"))
|
|
3235
|
+
: entries[path];
|
|
2927
3236
|
output[path] = [entryBytes, {
|
|
2928
3237
|
level: context.compressionLevel,
|
|
2929
3238
|
mtime: context.zipDate
|
|
@@ -3050,6 +3359,12 @@ function parsedNumber(value) {
|
|
|
3050
3359
|
return Number.isFinite(parsed) ? parsed : null;
|
|
3051
3360
|
}
|
|
3052
3361
|
|
|
3362
|
+
// PptxGenJS writes a gap (null) as `<c:pt idx="n"><c:v></c:v></c:pt>`. A native gap is a numeric cache with no point at that
|
|
3363
|
+
// index (ptCount keeps the row count); an empty value is not a number, so drop those points, leaving the index unwritten.
|
|
3364
|
+
function omitEmptyNumberPoints(xml) {
|
|
3365
|
+
return xml.replace(/<c:numCache>[\s\S]*?<\/c:numCache>/g, cache => cache.replace(/<c:pt idx="\d+"><c:v><\/c:v><\/c:pt>/g, ""));
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3053
3368
|
function numericValue(value) {
|
|
3054
3369
|
return parsedNumber(value) ?? 0;
|
|
3055
3370
|
}
|