@openpresentation/opf-pptx 0.11.6 → 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 +17 -3
- package/dist/background.js +15 -13
- package/dist/chartex.js +9 -9
- 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 +12 -6
- package/dist/index.js +362 -52
- 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();
|
|
@@ -245,6 +252,14 @@ export async function toPptx(input, options = {}) {
|
|
|
245
252
|
report: diagnostic => options.onDiagnostic?.(diagnostic)
|
|
246
253
|
});
|
|
247
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
|
+
});
|
|
248
263
|
context.masterBackground = masterBackground(context);
|
|
249
264
|
context.linkSentinels = linkSentinels(presentation);
|
|
250
265
|
const pptx = new PptxGenJS();
|
|
@@ -326,6 +341,9 @@ export async function fromPptx(input, options = {}) {
|
|
|
326
341
|
furnitureContexts[index].mediaRegistry = mediaRegistry;
|
|
327
342
|
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
|
|
328
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));
|
|
329
347
|
// A watermark carried identically by every slide is the deck's design.watermark.
|
|
330
348
|
const carried = imported.slides.map(slide => slide.design?.watermark);
|
|
331
349
|
if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
|
|
@@ -352,7 +370,8 @@ export async function fromPptx(input, options = {}) {
|
|
|
352
370
|
const restored = restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, organizationConflict: furniture.organizationConflict === true,
|
|
353
371
|
// Host catalogs format stored socials exactly as export did (FF-34).
|
|
354
372
|
socialPlatformRecords: catalogs => socialPlatformRecords({catalogs}, options),
|
|
355
|
-
|
|
373
|
+
nativeSections: slideSections,
|
|
374
|
+
slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
|
|
356
375
|
// The stored language wins while the runs still carry its tag (FF-07).
|
|
357
376
|
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
358
377
|
imported = applyDocumentProvenance(imported, restored, validatePresentation, report);
|
|
@@ -361,6 +380,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
361
380
|
report({code: "invalid-document-provenance", path: "", message: `${errorMessage(error)} Ordinary import keeps the values observed in the PPTX.`});
|
|
362
381
|
}
|
|
363
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);
|
|
364
384
|
if (imported.design?.theme === undefined) for (const diagnostic of themeDesign.diagnostics) if (diagnostic.code === "theme-unverified") report(diagnostic);
|
|
365
385
|
languageDiagnostics(imported, observedLanguage, options.onDiagnostic && report);
|
|
366
386
|
|
|
@@ -375,6 +395,23 @@ export async function fromPptx(input, options = {}) {
|
|
|
375
395
|
return imported;
|
|
376
396
|
}
|
|
377
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
|
+
|
|
378
415
|
function readPptxZip(input) {
|
|
379
416
|
let bytes;
|
|
380
417
|
if (input instanceof Uint8Array) {
|
|
@@ -473,6 +510,17 @@ function resolveSlidePaths(entries, presentationRoot, relationships) {
|
|
|
473
510
|
.sort(compareSlidePaths);
|
|
474
511
|
}
|
|
475
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
|
+
|
|
476
524
|
function compareSlidePaths(left, right) {
|
|
477
525
|
return slideNumber(left) - slideNumber(right) || (left < right ? -1 : left > right ? 1 : 0);
|
|
478
526
|
}
|
|
@@ -535,6 +583,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
535
583
|
diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
|
|
536
584
|
if (watermark.design) slide.design = {...slide.design, ...watermark.design};
|
|
537
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;
|
|
538
596
|
|
|
539
597
|
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
|
|
540
598
|
.sort(comparePositionedItems);
|
|
@@ -558,10 +616,13 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
|
|
|
558
616
|
item.text = current.map(paragraph => paragraph.text).join('\n');
|
|
559
617
|
}
|
|
560
618
|
}
|
|
561
|
-
const
|
|
562
|
-
.map((item) => payloadFromSlideItem(item))
|
|
563
|
-
.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);
|
|
564
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);
|
|
565
626
|
|
|
566
627
|
const notes = readSlideNotes(entries, relationships);
|
|
567
628
|
if (notes) slide.notes = notes;
|
|
@@ -611,7 +672,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
611
672
|
for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
|
|
612
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});
|
|
613
674
|
for (const [index,shape] of shapes.entries()) {
|
|
614
|
-
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;
|
|
615
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;
|
|
616
677
|
const ordinaryBody = Object.hasOwn(shape, 'p:txBody') && !shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'];
|
|
617
678
|
const item = importShape(shape, dimensions, paragraphs[index], ordinaryBody || furniture.taggedText.has(index) || media.captionShapes.has(shape));
|
|
@@ -642,7 +703,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
642
703
|
}
|
|
643
704
|
|
|
644
705
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
645
|
-
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;
|
|
646
707
|
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
647
708
|
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
648
709
|
if (item) items.push(item);
|
|
@@ -719,10 +780,23 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
719
780
|
};
|
|
720
781
|
}
|
|
721
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
|
+
|
|
722
793
|
function importPicture(entries, picture, slidePath, relationships, report) {
|
|
723
794
|
const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
|
|
724
795
|
const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
|
|
725
|
-
|
|
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);
|
|
726
800
|
const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
|
|
727
801
|
const relationship = relationships.get(relId);
|
|
728
802
|
let bytes = relationship?.path ? entries[relationship.path] : null;
|
|
@@ -744,12 +818,14 @@ function importPicture(entries, picture, slidePath, relationships, report) {
|
|
|
744
818
|
return {
|
|
745
819
|
kind: "image",
|
|
746
820
|
bounds,
|
|
821
|
+
visualBounds: visualBounds(picture["p:spPr"]?.["a:xfrm"]),
|
|
747
822
|
name,
|
|
748
823
|
payload: {
|
|
749
824
|
type: "image",
|
|
750
825
|
image: {
|
|
751
826
|
src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
|
|
752
|
-
...(alt ? { alt } : {})
|
|
827
|
+
...(alt ? { alt } : {}),
|
|
828
|
+
...(title && alt ? { title } : {})
|
|
753
829
|
}
|
|
754
830
|
}
|
|
755
831
|
};
|
|
@@ -772,7 +848,7 @@ function readParagraphs(txBody) {
|
|
|
772
848
|
}
|
|
773
849
|
return {
|
|
774
850
|
text: texts.join(""),
|
|
775
|
-
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),
|
|
776
852
|
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
777
853
|
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
|
|
778
854
|
};
|
|
@@ -829,6 +905,59 @@ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
|
|
|
829
905
|
return null;
|
|
830
906
|
}
|
|
831
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
|
+
|
|
832
961
|
// Adjacent native bullet boxes on the same text column form one imported list.
|
|
833
962
|
// This is a geometry heuristic, not a lossless reconstruction of arbitrary PPTX.
|
|
834
963
|
function mergeAdjacentBulletShapes(items) {
|
|
@@ -856,8 +985,8 @@ function payloadFromSlideItem(item) {
|
|
|
856
985
|
type: "list",
|
|
857
986
|
items: item.paragraphs.map((paragraph) => (
|
|
858
987
|
paragraph.level > 0
|
|
859
|
-
? { text: paragraph.richText ?? paragraph.text, level: paragraph.level }
|
|
860
|
-
: (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)
|
|
861
990
|
))
|
|
862
991
|
};
|
|
863
992
|
}
|
|
@@ -1159,7 +1288,8 @@ function resolvePresentationContext(presentation, options) {
|
|
|
1159
1288
|
const fontSchemeId = referenceId(design.fontScheme ?? theme?.fontScheme);
|
|
1160
1289
|
const unresolvedFontScheme = fontSchemeId && !findById(normalizeRecords(presentation.catalogs?.fontSchemes), fontSchemeId) && !findById(defaultCatalog("fontSchemes"), fontSchemeId) ? fontSchemeId : null;
|
|
1161
1290
|
const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
|
|
1162
|
-
const
|
|
1291
|
+
const variables = resolveVariableColors(presentation.variables);
|
|
1292
|
+
const background = resolveBackground(design.background ?? theme?.background, colorScheme, variables);
|
|
1163
1293
|
const fonts = resolveFonts(fontScheme);
|
|
1164
1294
|
for (const role of ["heading","body","code"]) fonts[role] = resolveTextStyle({fontFamily:fonts[role],fontWeight:role === "heading" ? 700 : 400},options.textMeasurement).fontFamily;
|
|
1165
1295
|
const textColor = readableTextColor(background, colorScheme);
|
|
@@ -1185,7 +1315,7 @@ function resolvePresentationContext(presentation, options) {
|
|
|
1185
1315
|
surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
|
|
1186
1316
|
border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
|
|
1187
1317
|
},
|
|
1188
|
-
variables
|
|
1318
|
+
variables
|
|
1189
1319
|
};
|
|
1190
1320
|
}
|
|
1191
1321
|
|
|
@@ -1208,7 +1338,23 @@ function nativeColor(reference, hex, context, fallback) {
|
|
|
1208
1338
|
|
|
1209
1339
|
// FF-59: the slide tag is the eyebrow label. It is written in the deck primary color, the scheme accent1 slot the
|
|
1210
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
|
+
}
|
|
1211
1356
|
function tagColor(context) {
|
|
1357
|
+
if (contrastRatio(context.colors.accent, context.colors.background) < TAG_MIN_CONTRAST) return context.textColor;
|
|
1212
1358
|
return nativeColor('primary', context.colors.accent, context);
|
|
1213
1359
|
}
|
|
1214
1360
|
|
|
@@ -1314,14 +1460,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1314
1460
|
const backgroundPath = `${opfSlide.design?.background !== undefined ? `slides.${slideIndex}.` : ''}design.background`;
|
|
1315
1461
|
const backgroundFill = schemeBackgroundFill(backgroundDefinition, slideContext) ?? nativeBackgroundFill(backgroundDefinition, {
|
|
1316
1462
|
width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
|
|
1317
|
-
}, 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));
|
|
1318
1464
|
if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
|
|
1319
1465
|
if (backgroundDefinition?.type === 'pattern' && !nativePatternPreset(backgroundDefinition.pattern?.preset)) {
|
|
1320
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.`});
|
|
1321
1467
|
}
|
|
1322
1468
|
if (backgroundDefinition?.type === 'image') {
|
|
1323
1469
|
const imagePath = `${backgroundPath}.image`;
|
|
1324
|
-
const
|
|
1470
|
+
const outcome = {};
|
|
1471
|
+
const resolved = await resolveImage(backgroundDefinition.image, presentation, options, imagePath, outcome);
|
|
1325
1472
|
if (resolved) {
|
|
1326
1473
|
// PptxGenJS embeds the raster and its relationship; packaging replaces
|
|
1327
1474
|
// its stretched fill with the fitted native picture fill.
|
|
@@ -1330,7 +1477,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1330
1477
|
fit: backgroundDefinition.image?.fit ?? 'cover', opacity: backgroundDefinition.opacity ?? 1, imageFill: slideContext.imageFill, path: imagePath,
|
|
1331
1478
|
width: slideContext.dimensions.widthInches * 96, height: slideContext.dimensions.heightInches * 96, report: options.onDiagnostic
|
|
1332
1479
|
} });
|
|
1333
|
-
} else {
|
|
1480
|
+
} else if (!outcome.reported) {
|
|
1334
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.'});
|
|
1335
1482
|
}
|
|
1336
1483
|
}
|
|
@@ -1350,11 +1497,15 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1350
1497
|
// Core composition resolves one alignment per composed item for every engine
|
|
1351
1498
|
// (FF-29); the design fallback keeps cores published before item.alignment working.
|
|
1352
1499
|
const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
|
|
1353
|
-
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) });
|
|
1354
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);
|
|
1355
1504
|
// The preview paints background, slide image (and its overlay), then design.watermark, then content.
|
|
1356
1505
|
if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
|
|
1357
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);
|
|
1358
1509
|
for (const item of geometry.items) {
|
|
1359
1510
|
// Card text sits on a literal card fill, not the slide background: keep it literal.
|
|
1360
1511
|
const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
|
|
@@ -1384,7 +1535,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
|
|
|
1384
1535
|
breakLine: false
|
|
1385
1536
|
});
|
|
1386
1537
|
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
1387
|
-
addMeasuredList(slide,item.text,itemContext,item.path);
|
|
1538
|
+
await addMeasuredList(slide,item.text,itemContext,item.path,item.bulletImage,presentation,slideIndex,options);
|
|
1388
1539
|
} else if (item.field === "text" && item.text?.richLines) {
|
|
1389
1540
|
const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
|
|
1390
1541
|
for(const [index,line] of item.text.richLines.entries()){
|
|
@@ -1522,7 +1673,25 @@ function richLineRuns(line,color,native,context) {
|
|
|
1522
1673
|
// the other measured text shapes. A reader maps lines to their list by name, not
|
|
1523
1674
|
// by geometry, so lines of an overflowing list that fall below its box still
|
|
1524
1675
|
// belong to it.
|
|
1525
|
-
|
|
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
|
+
}
|
|
1526
1695
|
let lineNumber=0;
|
|
1527
1696
|
for(const entry of fit.listEntries){
|
|
1528
1697
|
const addLines=(text,box,color,native,withBullet)=>{
|
|
@@ -1530,7 +1699,7 @@ function addMeasuredList(slide,fit,context,path) {
|
|
|
1530
1699
|
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1531
1700
|
const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
1532
1701
|
const objectName=`OPF list ${path} line ${lineNumber++}`;
|
|
1533
|
-
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});
|
|
1534
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};
|
|
1535
1704
|
const runs=richLineRuns(line,color,native,context);
|
|
1536
1705
|
if(!runs.length)runs.push({text:'',options:{}});
|
|
@@ -1592,6 +1761,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
1592
1761
|
}
|
|
1593
1762
|
const objectName = `OPF image ${context.imagePlacements.size + 1}`;
|
|
1594
1763
|
context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
|
|
1764
|
+
context.pictureText.set(objectName, pictureText(assetAlt(asset, presentation), asset, presentation));
|
|
1595
1765
|
slide.addImage({
|
|
1596
1766
|
...resolved,
|
|
1597
1767
|
objectName,
|
|
@@ -1619,9 +1789,10 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
1619
1789
|
notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
|
|
1620
1790
|
return;
|
|
1621
1791
|
}
|
|
1622
|
-
const
|
|
1792
|
+
const outcome = {};
|
|
1793
|
+
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
1623
1794
|
if (!resolved) {
|
|
1624
|
-
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.'});
|
|
1625
1796
|
return;
|
|
1626
1797
|
}
|
|
1627
1798
|
const {widthInches, heightInches} = slideContext.dimensions;
|
|
@@ -1631,6 +1802,24 @@ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideCont
|
|
|
1631
1802
|
slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
|
|
1632
1803
|
}
|
|
1633
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
|
+
|
|
1634
1823
|
// design.slideImage: one native picture at the shared frame, beneath content.
|
|
1635
1824
|
// Crop/fit and treatments are written after PptxGenJS embeds the bytes.
|
|
1636
1825
|
async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
|
|
@@ -1660,7 +1849,9 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1660
1849
|
// FF-53: a root `image` with the slide image's source is the slide image (core `replacesContent`); the
|
|
1661
1850
|
// manifest records that so an unchanged picture imports back as both design.slideImage and slide.image.
|
|
1662
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 });
|
|
1663
|
-
|
|
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 });
|
|
1664
1855
|
// The overlay scrim is a separate native shape directly above the picture.
|
|
1665
1856
|
if (effects.overlay) {
|
|
1666
1857
|
const overlay = effects.overlay.box;
|
|
@@ -1668,6 +1859,21 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1668
1859
|
}
|
|
1669
1860
|
}
|
|
1670
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
|
+
|
|
1671
1877
|
function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
|
|
1672
1878
|
const chartData = toPptxChartData(chart, context.chartexMode);
|
|
1673
1879
|
if (!chartData.series) {
|
|
@@ -1686,14 +1892,15 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1686
1892
|
const fill = {color:normalizeHex(panelFill),transparency};
|
|
1687
1893
|
const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
|
|
1688
1894
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1689
|
-
|
|
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});
|
|
1690
1897
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1691
1898
|
const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
|
|
1692
1899
|
if (chartData.chartex) {
|
|
1693
1900
|
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1694
1901
|
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1695
1902
|
const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
|
|
1696
|
-
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});
|
|
1697
1904
|
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1698
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.`});
|
|
1699
1906
|
}
|
|
@@ -1712,14 +1919,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1712
1919
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1713
1920
|
plotArea: {fill:{color:null}},
|
|
1714
1921
|
catAxisLabelFontFace: context.fonts.body,
|
|
1715
|
-
catAxisLabelFontSize:
|
|
1922
|
+
catAxisLabelFontSize: textSize / 100,
|
|
1716
1923
|
catAxisLabelColor: labelColor,
|
|
1717
1924
|
valAxisLabelFontFace: context.fonts.body,
|
|
1718
|
-
valAxisLabelFontSize:
|
|
1925
|
+
valAxisLabelFontSize: textSize / 100,
|
|
1719
1926
|
valAxisLabelColor: labelColor,
|
|
1720
1927
|
legendColor: labelColor,
|
|
1721
1928
|
legendFontFace: context.fonts.body,
|
|
1929
|
+
legendFontSize: textSize / 100,
|
|
1722
1930
|
dataLabelColor: labelColor,
|
|
1931
|
+
dataLabelFontSize: textSize / 100,
|
|
1723
1932
|
showValue: false,
|
|
1724
1933
|
valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
|
|
1725
1934
|
barDir: chartData.barDir,
|
|
@@ -1893,7 +2102,9 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1893
2102
|
if(part.type==='image'){
|
|
1894
2103
|
const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
|
|
1895
2104
|
const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
|
|
1896
|
-
|
|
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)});
|
|
1897
2108
|
}else{
|
|
1898
2109
|
if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
|
|
1899
2110
|
// Slide numbers and current dates become native PowerPoint fields; {total}
|
|
@@ -1913,13 +2124,15 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
|
|
|
1913
2124
|
if(marker) staticDates.set(index,marker);
|
|
1914
2125
|
}
|
|
1915
2126
|
}
|
|
1916
|
-
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});
|
|
1917
2128
|
}
|
|
1918
2129
|
}
|
|
1919
2130
|
const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
|
|
1920
2131
|
if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
|
|
1921
2132
|
}
|
|
1922
2133
|
|
|
2134
|
+
const bulletImageName = () => 'OPF bullet image';
|
|
2135
|
+
|
|
1923
2136
|
function addCodePayload(slide, value, layout, region, context, path) {
|
|
1924
2137
|
if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
|
|
1925
2138
|
for (const part of layout.parts) {
|
|
@@ -2055,7 +2268,9 @@ function placeholderAsset(asset, presentation) {
|
|
|
2055
2268
|
// minimum) in the readable text colour for the panel, or a cross when the label
|
|
2056
2269
|
// cannot fit even at that minimum. The panel carries the accessible name the
|
|
2057
2270
|
// preview gives its group, "Image unavailable: <description>".
|
|
2058
|
-
|
|
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} = {}) {
|
|
2059
2274
|
const layered = placeholderAsset(asset, presentation);
|
|
2060
2275
|
const description = String(layered.alt ?? layered.title ?? "Image");
|
|
2061
2276
|
const label = `Image unavailable\n${description}`;
|
|
@@ -2089,17 +2304,18 @@ function addImagePlaceholder(slide, presentation, asset, region, path, context,
|
|
|
2089
2304
|
const centered = { ...inner, y: inner.y + offset };
|
|
2090
2305
|
addMeasuredPayloadText(slide, label, centered, context, options, {
|
|
2091
2306
|
path, fit: fitText(label, centered, 20 * scale, minimum, measurer), textStyle: style,
|
|
2092
|
-
diagnosticsHandled: true, align: "center", color: textColor
|
|
2307
|
+
diagnosticsHandled: true, align: "center", color: textColor, ...(logo ? {objectName: `${objectName} text`} : {})
|
|
2093
2308
|
});
|
|
2094
|
-
return;
|
|
2309
|
+
return logo ? objectName : undefined;
|
|
2095
2310
|
}
|
|
2096
2311
|
// A status indicator, not shortened authored content: the full description stays
|
|
2097
2312
|
// in the panel's accessible name.
|
|
2098
2313
|
const size = Math.max(0, Math.min(inner.width, inner.height, 24 * scale));
|
|
2099
|
-
if (!size) return;
|
|
2314
|
+
if (!size) return logo ? objectName : undefined;
|
|
2100
2315
|
const icon = { x: inner.x + (inner.width - size) / 2, y: inner.y + (inner.height - size) / 2 };
|
|
2101
2316
|
const line = { color: textColor, width: Math.min(2 * scale, size / 8) * .75 };
|
|
2102
|
-
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;
|
|
2103
2319
|
}
|
|
2104
2320
|
|
|
2105
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.
|
|
@@ -2296,7 +2512,9 @@ function toPptxChartData(chart, chartexMode = 'auto') {
|
|
|
2296
2512
|
let series = data.columns.slice(1).map((name, seriesIndex) => ({
|
|
2297
2513
|
name: stringifyText(name),
|
|
2298
2514
|
labels,
|
|
2299
|
-
|
|
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]))
|
|
2300
2518
|
}));
|
|
2301
2519
|
const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
|
|
2302
2520
|
if (series.length > plotted) {
|
|
@@ -2340,7 +2558,42 @@ function histogramBins(values) {
|
|
|
2340
2558
|
}
|
|
2341
2559
|
}
|
|
2342
2560
|
|
|
2343
|
-
|
|
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) {
|
|
2344
2597
|
const assetObject = dereferenceAsset(asset, presentation, path);
|
|
2345
2598
|
const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
|
|
2346
2599
|
if (!src) {
|
|
@@ -2411,6 +2664,24 @@ function dereferenceAsset(asset, presentation, path, seen = new Set()) {
|
|
|
2411
2664
|
return asset;
|
|
2412
2665
|
}
|
|
2413
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
|
+
|
|
2414
2685
|
function assetAlt(asset, presentation) {
|
|
2415
2686
|
const resolved = dereferenceAsset(asset, presentation, "asset-alt");
|
|
2416
2687
|
if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
|
|
@@ -2450,10 +2721,13 @@ const DEFAULT_SOURCE_PREFIX = "https://www.pptx.gallery/";
|
|
|
2450
2721
|
// catalog for pptx.gallery/pkg sources), injected options.catalogs, and the
|
|
2451
2722
|
// bundled catalog (the engine default source also resolves to it).
|
|
2452
2723
|
function socialPlatformRecords(presentation, options) {
|
|
2453
|
-
const kind = "socialPlatforms",
|
|
2454
|
-
|
|
2455
|
-
const sourceRecords =
|
|
2456
|
-
|
|
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
|
+
});
|
|
2457
2731
|
return [...sourceRecords, ...normalizeRecords(options.catalogs?.[kind]), ...defaultCatalog(kind)];
|
|
2458
2732
|
}
|
|
2459
2733
|
|
|
@@ -2481,19 +2755,26 @@ function resolveDimensions(value) {
|
|
|
2481
2755
|
return { widthInches: width / 96, heightInches: height / 96 };
|
|
2482
2756
|
}
|
|
2483
2757
|
|
|
2484
|
-
|
|
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 = {}) {
|
|
2485
2765
|
const fallback = "FFFFFF";
|
|
2766
|
+
const reference = entry => normalizeHex(resolveBackgroundColorRef(entry, colorScheme, variables) ?? entry, fallback);
|
|
2486
2767
|
if (typeof value === "string") {
|
|
2487
2768
|
if (value.startsWith("#")) return normalizeHex(value, fallback);
|
|
2488
2769
|
return normalizeHex(colorScheme[value] ?? colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
2489
2770
|
}
|
|
2490
2771
|
if (isPlainObject(value)) {
|
|
2491
|
-
if (value.type === "solid" && value.color) return
|
|
2772
|
+
if (value.type === "solid" && value.color) return reference(value.color);
|
|
2492
2773
|
if (value.type === "theme" && value.slot) {
|
|
2493
2774
|
return normalizeHex(colorScheme[value.slot] ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
2494
2775
|
}
|
|
2495
2776
|
// Like the SVG preview, text contrast follows a pattern's background color.
|
|
2496
|
-
if (value.type === "pattern") return
|
|
2777
|
+
if (value.type === "pattern") return reference(value.pattern?.backgroundColor ?? "#FFFFFF");
|
|
2497
2778
|
if (value.backgroundColor) return normalizeHex(value.backgroundColor, fallback);
|
|
2498
2779
|
}
|
|
2499
2780
|
return normalizeHex(colorScheme.background ?? colorScheme.light1 ?? "#FFFFFF", fallback);
|
|
@@ -2550,7 +2831,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
2550
2831
|
attachTimelineTags(entries,context.timelineTags);
|
|
2551
2832
|
attachQuoteTags(entries,context.quoteTags);
|
|
2552
2833
|
attachFurnitureFields(entries,context.furnitureFields);
|
|
2553
|
-
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');
|
|
2554
2837
|
for(const [part,bytes]of Object.entries(entries)){
|
|
2555
2838
|
if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
|
|
2556
2839
|
const relationships=parseRelationships(entries,part);
|
|
@@ -2559,12 +2842,13 @@ async function normalizePptxZip(raw, context) {
|
|
|
2559
2842
|
if(!context.chartHeadings.has(name))continue;
|
|
2560
2843
|
const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
|
|
2561
2844
|
if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
|
|
2562
|
-
const {heading,labelColor,spec}=context.chartHeadings.get(name);
|
|
2845
|
+
const {heading,labelColor,spec,textSize}=context.chartHeadings.get(name);
|
|
2563
2846
|
if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
|
|
2564
|
-
entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
|
|
2847
|
+
entries[chartPart]=encodeText(omitEmptyNumberPoints(applyChartConstruct(decodeText(entries[chartPart]),spec)));
|
|
2565
2848
|
// PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
|
|
2566
2849
|
// Normalize only our generated chart text styles; point/series fills stay intact.
|
|
2567
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));
|
|
2568
2852
|
}
|
|
2569
2853
|
}
|
|
2570
2854
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
@@ -2575,13 +2859,18 @@ async function normalizePptxZip(raw, context) {
|
|
|
2575
2859
|
throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
|
|
2576
2860
|
}
|
|
2577
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
|
+
}
|
|
2578
2867
|
const imageSources = new Map();
|
|
2579
2868
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
2580
2869
|
if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
|
|
2581
2870
|
const relationships = parseRelationships(entries, part);
|
|
2582
2871
|
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
2583
2872
|
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
2584
|
-
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);
|
|
2585
2874
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
2586
2875
|
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
2587
2876
|
}
|
|
@@ -2614,6 +2903,9 @@ async function normalizePptxZip(raw, context) {
|
|
|
2614
2903
|
placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
|
|
2615
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 });
|
|
2616
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
|
+
});
|
|
2617
2909
|
// A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
|
|
2618
2910
|
dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
|
|
2619
2911
|
// Charts and notes follow the language and fonts of the slide they belong to.
|
|
@@ -2817,7 +3109,15 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2817
3109
|
// Image data is already resolved and embedded by PptxGenJS. Read those
|
|
2818
3110
|
// exact bytes instead of fetching or resolving the source a second time.
|
|
2819
3111
|
const relationships = parseRelationships(entries, path);
|
|
3112
|
+
let bulletRelationship;
|
|
2820
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);
|
|
2821
3121
|
const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
|
|
2822
3122
|
if (!placement) return picture;
|
|
2823
3123
|
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
@@ -2838,6 +3138,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
|
|
|
2838
3138
|
xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
|
|
2839
3139
|
const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
|
|
2840
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>`);
|
|
2841
3142
|
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
2842
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}"/>`);
|
|
2843
3144
|
});
|
|
@@ -2926,9 +3227,12 @@ function normalizeNestedZip(bytes, context) {
|
|
|
2926
3227
|
const entries = unzipSync(bytes);
|
|
2927
3228
|
const output = {};
|
|
2928
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.
|
|
2929
3231
|
const entryBytes = path === "docProps/core.xml"
|
|
2930
3232
|
? encodeText(normalizeCoreProperties(decodeText(entries[path]), context.timestamp))
|
|
2931
|
-
:
|
|
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];
|
|
2932
3236
|
output[path] = [entryBytes, {
|
|
2933
3237
|
level: context.compressionLevel,
|
|
2934
3238
|
mtime: context.zipDate
|
|
@@ -3055,6 +3359,12 @@ function parsedNumber(value) {
|
|
|
3055
3359
|
return Number.isFinite(parsed) ? parsed : null;
|
|
3056
3360
|
}
|
|
3057
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
|
+
|
|
3058
3368
|
function numericValue(value) {
|
|
3059
3369
|
return parsedNumber(value) ?? 0;
|
|
3060
3370
|
}
|