@openpresentation/opf-pptx 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/background.js +81 -0
- package/dist/image-fallback-browser.js +33 -0
- package/dist/image-fallback-node.js +8 -0
- package/dist/image-geometry.js +113 -0
- package/dist/image-import.js +92 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +321 -289
- package/package.json +20 -8
package/dist/index.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {importImageOrientation} from './image-import.js';
|
|
2
|
+
import {nativeBackgroundFill, readNativeBackground} from './background.js';
|
|
3
|
+
import { webpToPng } from '#image-fallback';
|
|
4
|
+
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
5
|
+
import { composeSlide, fitText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
1
6
|
import PptxGenJS from "pptxgenjs";
|
|
2
7
|
import { unzipSync, zipSync } from "fflate";
|
|
3
8
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -151,12 +156,20 @@ const CHART_COLORS = [
|
|
|
151
156
|
];
|
|
152
157
|
|
|
153
158
|
export async function toPptx(input, options = {}) {
|
|
159
|
+
if (options.imageFormat !== undefined && !['compatible', 'preserve'].includes(options.imageFormat)) {
|
|
160
|
+
throw new OPFPptxError('invalid-image-format', 'imageFormat must be compatible or preserve.', {path: 'options.imageFormat'});
|
|
161
|
+
}
|
|
154
162
|
const presentation = parseInput(input);
|
|
155
163
|
assertValidBoundary(presentation);
|
|
156
164
|
|
|
157
|
-
const context = resolvePresentationContext(presentation, options);
|
|
165
|
+
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
166
|
+
context.listMarkers = new Map();
|
|
167
|
+
context.tableHeaders = new Map();
|
|
168
|
+
context.imagePlacements = new Map();
|
|
169
|
+
context.backgroundFills = new Map();
|
|
170
|
+
context.imageFormat = options.imageFormat ?? "compatible";
|
|
158
171
|
const pptx = new PptxGenJS();
|
|
159
|
-
configurePresentation(pptx, presentation, context);
|
|
172
|
+
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
160
173
|
|
|
161
174
|
for (let index = 0; index < presentation.slides.length; index += 1) {
|
|
162
175
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
@@ -204,7 +217,7 @@ export async function fromPptx(input, options = {}) {
|
|
|
204
217
|
if (dimensions) imported.design = { dimensions };
|
|
205
218
|
|
|
206
219
|
for (let index = 0; index < slidePaths.length; index += 1) {
|
|
207
|
-
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions));
|
|
220
|
+
imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options));
|
|
208
221
|
}
|
|
209
222
|
|
|
210
223
|
const result = validatePresentation(imported);
|
|
@@ -344,7 +357,7 @@ function dimensionsFromPresentation(presentationRoot) {
|
|
|
344
357
|
};
|
|
345
358
|
}
|
|
346
359
|
|
|
347
|
-
function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
|
|
360
|
+
function importSlide(entries, slidePath, slideIndex, presentationDimensions, options) {
|
|
348
361
|
const doc = parseRequiredXml(entries, slidePath);
|
|
349
362
|
const slideRoot = doc["p:sld"];
|
|
350
363
|
if (!slideRoot) {
|
|
@@ -358,24 +371,17 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
|
|
|
358
371
|
const slide = {};
|
|
359
372
|
if (slideRoot.show === "0") slide.hidden = true;
|
|
360
373
|
|
|
361
|
-
const background =
|
|
362
|
-
if (background) {
|
|
363
|
-
slide.design = {
|
|
364
|
-
background: {
|
|
365
|
-
type: "solid",
|
|
366
|
-
color: background
|
|
367
|
-
}
|
|
368
|
-
};
|
|
369
|
-
}
|
|
374
|
+
const background = readNativeBackground(slideRoot["p:cSld"]?.["p:bg"]?.["p:bgPr"], resolveCanvasDimensions(dimensions), diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
|
|
375
|
+
if (background) slide.design = {background};
|
|
370
376
|
|
|
371
|
-
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions)
|
|
377
|
+
const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
|
|
372
378
|
.sort(comparePositionedItems);
|
|
373
379
|
const titleItem = takeTitleItem(items, dimensions);
|
|
374
380
|
if (titleItem) slide.title = firstLine(titleItem.text);
|
|
375
381
|
const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
|
|
376
382
|
if (subtitleItem) slide.subtitle = firstLine(subtitleItem.text);
|
|
377
383
|
|
|
378
|
-
const blocks = items
|
|
384
|
+
const blocks = mergeAdjacentBulletShapes(items)
|
|
379
385
|
.map((item) => payloadFromSlideItem(item))
|
|
380
386
|
.filter(Boolean);
|
|
381
387
|
if (blocks.length > 0) slide.blocks = blocks;
|
|
@@ -383,14 +389,10 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
|
|
|
383
389
|
const notes = readSlideNotes(entries, relationships);
|
|
384
390
|
if (notes) slide.notes = notes;
|
|
385
391
|
|
|
386
|
-
if (!slide.title && !slide.blocks && !slide.notes) {
|
|
387
|
-
slide.title = `Slide ${slideIndex + 1}`;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
392
|
return slide;
|
|
391
393
|
}
|
|
392
394
|
|
|
393
|
-
function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions) {
|
|
395
|
+
function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
|
|
394
396
|
const tree = slideRoot["p:cSld"]?.["p:spTree"];
|
|
395
397
|
const items = [];
|
|
396
398
|
|
|
@@ -404,8 +406,9 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
404
406
|
if (item) items.push(item);
|
|
405
407
|
}
|
|
406
408
|
|
|
407
|
-
for (const picture of asArray(tree?.["p:pic"])) {
|
|
408
|
-
const
|
|
409
|
+
for (const [index, picture] of asArray(tree?.["p:pic"]).entries()) {
|
|
410
|
+
const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
|
|
411
|
+
const item = importPicture(entries, picture, slidePath, relationships, report);
|
|
409
412
|
if (item) items.push(item);
|
|
410
413
|
}
|
|
411
414
|
|
|
@@ -478,13 +481,13 @@ function importGraphicFrame(entries, frame, slidePath, relationships) {
|
|
|
478
481
|
};
|
|
479
482
|
}
|
|
480
483
|
|
|
481
|
-
function importPicture(entries, picture, slidePath, relationships) {
|
|
484
|
+
function importPicture(entries, picture, slidePath, relationships, report) {
|
|
482
485
|
const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
|
|
483
486
|
const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
|
|
484
487
|
const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr).trim();
|
|
485
488
|
const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
|
|
486
489
|
const relationship = relationships.get(relId);
|
|
487
|
-
|
|
490
|
+
let bytes = relationship?.path ? entries[relationship.path] : null;
|
|
488
491
|
if (!bytes) {
|
|
489
492
|
return {
|
|
490
493
|
kind: "unknown",
|
|
@@ -494,6 +497,12 @@ function importPicture(entries, picture, slidePath, relationships) {
|
|
|
494
497
|
};
|
|
495
498
|
}
|
|
496
499
|
|
|
500
|
+
const crop = picture["p:blipFill"]?.["a:srcRect"];
|
|
501
|
+
if (crop && ['l','r','t','b'].some(key => Number(crop[key] ?? 0) !== 0)) {
|
|
502
|
+
report({code: 'unsupported-image-crop', message: 'Native picture crop is not represented by the imported OPF asset; the full image was retained.'});
|
|
503
|
+
}
|
|
504
|
+
bytes = importImageOrientation(bytes, picture["p:spPr"]?.["a:xfrm"], report);
|
|
505
|
+
|
|
497
506
|
return {
|
|
498
507
|
kind: "image",
|
|
499
508
|
bounds,
|
|
@@ -501,7 +510,7 @@ function importPicture(entries, picture, slidePath, relationships) {
|
|
|
501
510
|
payload: {
|
|
502
511
|
type: "image",
|
|
503
512
|
image: {
|
|
504
|
-
src: `data:${mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
|
|
513
|
+
src: `data:${rasterMetadata(bytes)?.mediaType ?? mediaTypeForPath(relationship.path)};base64,${bytesToBase64(bytes)}`,
|
|
505
514
|
...(alt ? { alt } : {})
|
|
506
515
|
}
|
|
507
516
|
}
|
|
@@ -525,7 +534,8 @@ function readParagraphs(txBody) {
|
|
|
525
534
|
}
|
|
526
535
|
return {
|
|
527
536
|
text: texts.join("").trim(),
|
|
528
|
-
|
|
537
|
+
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
538
|
+
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
529
539
|
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
|
|
530
540
|
};
|
|
531
541
|
})
|
|
@@ -552,7 +562,7 @@ function takeTitleItem(items, dimensions) {
|
|
|
552
562
|
|
|
553
563
|
const titleLimit = dimensions.heightInches * 0.28;
|
|
554
564
|
const candidateIndex = items.findIndex((item) => {
|
|
555
|
-
if (item.kind !== "text" || !item.text) return false;
|
|
565
|
+
if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
556
566
|
const y = item.bounds?.y ?? 0;
|
|
557
567
|
return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
|
|
558
568
|
});
|
|
@@ -568,7 +578,7 @@ function takeSubtitleItem(items, titleItem, dimensions) {
|
|
|
568
578
|
const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
|
|
569
579
|
const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
|
|
570
580
|
const candidateIndex = items.findIndex((item) => {
|
|
571
|
-
if (item.kind !== "text" || !item.text) return false;
|
|
581
|
+
if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
572
582
|
const y = item.bounds?.y ?? 0;
|
|
573
583
|
const h = item.bounds?.h ?? 0;
|
|
574
584
|
return y >= titleBottom - 0.05
|
|
@@ -581,10 +591,29 @@ function takeSubtitleItem(items, titleItem, dimensions) {
|
|
|
581
591
|
return null;
|
|
582
592
|
}
|
|
583
593
|
|
|
594
|
+
// Adjacent native bullet boxes on the same text column form one imported list.
|
|
595
|
+
// This is a geometry heuristic, not a lossless reconstruction of arbitrary PPTX.
|
|
596
|
+
function mergeAdjacentBulletShapes(items) {
|
|
597
|
+
const result=[];
|
|
598
|
+
for(const item of items){
|
|
599
|
+
const previous=result.at(-1);
|
|
600
|
+
const bullets=value=>value?.kind==='text'&&value.paragraphs.length&&value.paragraphs.every(p=>p.bullet);
|
|
601
|
+
if(bullets(previous)&&bullets(item)&&previous.bounds&&item.bounds
|
|
602
|
+
&&Math.abs(previous.bounds.x-item.bounds.x)<.05
|
|
603
|
+
&&item.bounds.y>=previous.bounds.y+previous.bounds.h-.02
|
|
604
|
+
&&item.bounds.y-(previous.bounds.y+previous.bounds.h)<.3){
|
|
605
|
+
previous.paragraphs.push(...item.paragraphs);
|
|
606
|
+
previous.text+='\n'+item.text;
|
|
607
|
+
previous.bounds.h=item.bounds.y+item.bounds.h-previous.bounds.y;
|
|
608
|
+
}else result.push({...item,paragraphs:item.paragraphs?[...item.paragraphs]:undefined,bounds:item.bounds?{...item.bounds}:undefined});
|
|
609
|
+
}
|
|
610
|
+
return result;
|
|
611
|
+
}
|
|
612
|
+
|
|
584
613
|
function payloadFromSlideItem(item) {
|
|
585
614
|
if (item.payload) return item.payload;
|
|
586
615
|
if (item.kind === "text") {
|
|
587
|
-
if (item.paragraphs.length > 1) {
|
|
616
|
+
if (item.paragraphs.length > 1 || item.paragraphs.some(p=>p.bullet)) {
|
|
588
617
|
return {
|
|
589
618
|
type: "list",
|
|
590
619
|
items: item.paragraphs.map((paragraph) => (
|
|
@@ -602,13 +631,14 @@ function payloadFromSlideItem(item) {
|
|
|
602
631
|
|
|
603
632
|
function tableFromXml(table) {
|
|
604
633
|
const rows = asArray(table["a:tr"])
|
|
605
|
-
.map((row) => asArray(row?.["a:tc"]).map((cell) => textFromTextBody(cell?.["a:txBody"])))
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
634
|
+
.map((row) => asArray(row?.["a:tc"]).map((cell) => textFromTextBody(cell?.["a:txBody"])));
|
|
635
|
+
// DrawingML's firstRow flag applies header-row formatting. Without that
|
|
636
|
+
// signal, retain all rows as data instead of guessing from their contents.
|
|
637
|
+
const firstRow = table["a:tblPr"]?.firstRow;
|
|
638
|
+
const hasHeaders = firstRow === "1" || firstRow === "true";
|
|
639
|
+
return hasHeaders && rows.length
|
|
640
|
+
? { columns: rows[0], rows: rows.slice(1) }
|
|
641
|
+
: { rows };
|
|
612
642
|
}
|
|
613
643
|
|
|
614
644
|
function chartFromRelationship(entries, slidePath, relationships, relId) {
|
|
@@ -693,10 +723,6 @@ function readSlideNotes(entries, relationships) {
|
|
|
693
723
|
return bodyNotes.join("\n").trim();
|
|
694
724
|
}
|
|
695
725
|
|
|
696
|
-
function slideBackground(slideRoot) {
|
|
697
|
-
const color = slideRoot["p:cSld"]?.["p:bg"]?.["p:bgPr"]?.["a:solidFill"]?.["a:srgbClr"]?.val;
|
|
698
|
-
return color ? `#${normalizeHex(color)}` : "";
|
|
699
|
-
}
|
|
700
726
|
|
|
701
727
|
function textFromTextBody(txBody) {
|
|
702
728
|
return readParagraphs(txBody).map((paragraph) => paragraph.text).filter(Boolean).join("\n").trim();
|
|
@@ -733,7 +759,9 @@ function isFullSlide(bounds, dimensions) {
|
|
|
733
759
|
function emuToInches(value) {
|
|
734
760
|
const number = Number(value);
|
|
735
761
|
if (!Number.isFinite(number)) return null;
|
|
736
|
-
|
|
762
|
+
// Keep native precision through layout/rendering. Rounding inches to six
|
|
763
|
+
// decimals turns a 1280-pixel canvas into 1279.999968 and changes raster edges.
|
|
764
|
+
return number / EMUS_PER_INCH;
|
|
737
765
|
}
|
|
738
766
|
|
|
739
767
|
function asArray(value) {
|
|
@@ -783,7 +811,7 @@ function assertValidBoundary(presentation) {
|
|
|
783
811
|
|
|
784
812
|
function resolvePresentationContext(presentation, options) {
|
|
785
813
|
const design = presentation.design ?? {};
|
|
786
|
-
const theme =
|
|
814
|
+
const theme = resolveDesignRecord(presentation, "themes", design.theme, DEFAULTS.theme);
|
|
787
815
|
const colorScheme = resolveDesignRecord(
|
|
788
816
|
presentation,
|
|
789
817
|
"colorSchemes",
|
|
@@ -799,7 +827,9 @@ function resolvePresentationContext(presentation, options) {
|
|
|
799
827
|
const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
|
|
800
828
|
const background = resolveBackground(design.background ?? theme?.background, colorScheme);
|
|
801
829
|
const fonts = resolveFonts(fontScheme);
|
|
830
|
+
for (const role of ["heading","body","code"]) fonts[role] = resolveTextStyle({fontFamily:fonts[role],fontWeight:role === "heading" ? 700 : 400},options.textMeasurement).fontFamily;
|
|
802
831
|
const textColor = readableTextColor(background, colorScheme);
|
|
832
|
+
const darkBackground = isDarkHex(background);
|
|
803
833
|
|
|
804
834
|
return {
|
|
805
835
|
seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
|
|
@@ -809,14 +839,15 @@ function resolvePresentationContext(presentation, options) {
|
|
|
809
839
|
layoutName: "OPF_CANVAS",
|
|
810
840
|
dimensions,
|
|
811
841
|
colorScheme,
|
|
842
|
+
backgroundDefinition: design.background ?? theme?.background,
|
|
812
843
|
fonts,
|
|
813
844
|
colors: {
|
|
814
845
|
background,
|
|
815
846
|
text: textColor,
|
|
816
|
-
mutedText: normalizeHex(colorScheme.textSecondary ?? colorScheme.dark2 ?? "#475569"),
|
|
847
|
+
mutedText: normalizeHex(colorScheme.textSecondary ?? (darkBackground ? colorScheme.light2 : colorScheme.dark2) ?? "#475569"),
|
|
817
848
|
accent: normalizeHex(colorScheme.primary ?? colorScheme.accent1 ?? "#2874A6"),
|
|
818
|
-
surface: normalizeHex(colorScheme.surface ?? colorScheme.light2 ?? "#F8FAFC"),
|
|
819
|
-
border: normalizeHex(colorScheme.
|
|
849
|
+
surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
|
|
850
|
+
border: normalizeHex(colorScheme.accent5 ?? "#CBD5E1")
|
|
820
851
|
}
|
|
821
852
|
};
|
|
822
853
|
}
|
|
@@ -841,151 +872,61 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
841
872
|
|
|
842
873
|
async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
|
|
843
874
|
const slide = pptx.addSlide();
|
|
844
|
-
const slideContext = resolveSlideContext(presentation, opfSlide, context);
|
|
875
|
+
const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
|
|
845
876
|
slide.background = { color: slideContext.colors.background };
|
|
877
|
+
const backgroundFill = nativeBackgroundFill(slideContext.backgroundDefinition, {
|
|
878
|
+
width: slideContext.dimensions.widthInches, height: slideContext.dimensions.heightInches
|
|
879
|
+
}, slideContext.colors.background);
|
|
880
|
+
if (backgroundFill) context.backgroundFills.set(`ppt/slides/slide${slideIndex + 1}.xml`, backgroundFill);
|
|
846
881
|
slide.color = slideContext.colors.text;
|
|
847
882
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
848
883
|
|
|
849
884
|
const { widthInches, heightInches } = slideContext.dimensions;
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
})
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
fontSize: 28,
|
|
879
|
-
bold: true,
|
|
880
|
-
color: slideContext.colors.text,
|
|
881
|
-
fit: "shrink",
|
|
882
|
-
breakLine: false
|
|
883
|
-
});
|
|
884
|
-
y += 0.68;
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
const subtitle = opfSlide.subtitle ?? presentation.subtitle;
|
|
888
|
-
if (subtitle) {
|
|
889
|
-
slide.addText(String(subtitle), {
|
|
890
|
-
x: margin,
|
|
891
|
-
y,
|
|
892
|
-
w: widthInches - margin * 2,
|
|
893
|
-
h: 0.34,
|
|
894
|
-
margin: 0,
|
|
895
|
-
fontFace: slideContext.fonts.body,
|
|
896
|
-
fontSize: 14,
|
|
897
|
-
color: slideContext.colors.mutedText,
|
|
898
|
-
fit: "shrink"
|
|
899
|
-
});
|
|
900
|
-
y += 0.48;
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
const contentTop = Math.max(y + 0.08, title || subtitle || opfSlide.tag ? 1.25 : 0.55);
|
|
904
|
-
const contentArea = {
|
|
905
|
-
x: margin,
|
|
906
|
-
y: contentTop,
|
|
907
|
-
w: widthInches - margin * 2,
|
|
908
|
-
h: Math.max(0.7, heightInches - contentTop - 0.48)
|
|
909
|
-
};
|
|
910
|
-
const bindings = collectSlideBindings(opfSlide, slideIndex);
|
|
911
|
-
|
|
912
|
-
for (let index = 0; index < bindings.length; index += 1) {
|
|
913
|
-
const binding = bindings[index];
|
|
914
|
-
const region = binding.regionKey
|
|
915
|
-
? regionFromPromotedKey(binding.regionKey, contentArea)
|
|
916
|
-
: regionFromIndex(index, bindings.length, contentArea);
|
|
917
|
-
await addPayload(slide, presentation, binding.payload, insetRegion(region, 0.08), binding.path, slideContext, options);
|
|
885
|
+
const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
|
|
886
|
+
if (opfSlide.layout && layout.id !== opfSlide.layout) throw new OPFPptxError("catalog-resolution-failed", `Layout '${opfSlide.layout}' needs an inline or bundled catalog record.`, { path: `slides.${slideIndex}.layout` });
|
|
887
|
+
const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, textMeasurement: options.textMeasurement });
|
|
888
|
+
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
889
|
+
for (const item of geometry.items) {
|
|
890
|
+
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
891
|
+
if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
892
|
+
slide.addText(item.text.lines.join("\n"), {
|
|
893
|
+
...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
|
|
894
|
+
fontFace: item.textStyle.fontFamily,
|
|
895
|
+
bold: item.textStyle.fontWeight >= 600,
|
|
896
|
+
italic: item.textStyle.italic,
|
|
897
|
+
color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
|
|
898
|
+
breakLine: false
|
|
899
|
+
});
|
|
900
|
+
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
901
|
+
addMeasuredList(slide,item.text,slideContext);
|
|
902
|
+
} else if (item.field === "text" && item.text?.richLines) {
|
|
903
|
+
const alignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
|
|
904
|
+
for(const line of item.text.richLines){
|
|
905
|
+
const runs=line.fragments.map(fragment=>({text:fragment.text,options:{fontFace:fragment.style.fontFamily,fontSize:fragment.fontSize*.75,bold:fragment.style.fontWeight>=600,italic:fragment.style.italic,color:normalizeHex(fragment.run.color??slideContext.colors.text),underline:fragment.run.underline?{color:normalizeHex(fragment.run.color??slideContext.colors.text)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}}));
|
|
906
|
+
if(runs.length)slide.addText(runs,{...textBoxOptions({...region,y:region.y+line.y/96,h:line.height/96},slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
|
|
907
|
+
}
|
|
908
|
+
} else if (item.field === "text" && typeof item.value === "string") {
|
|
909
|
+
slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),fontFace:item.textStyle.fontFamily,bold:item.textStyle.fontWeight>=600,italic:item.textStyle.italic});
|
|
910
|
+
} else {
|
|
911
|
+
await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options);
|
|
912
|
+
}
|
|
918
913
|
}
|
|
919
914
|
|
|
920
915
|
if (opfSlide.notes) slide.addNotes(String(opfSlide.notes));
|
|
921
916
|
}
|
|
922
917
|
|
|
923
|
-
function resolveSlideContext(presentation, slide, baseContext) {
|
|
924
|
-
|
|
925
|
-
const
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
"
|
|
929
|
-
design.colorScheme,
|
|
930
|
-
baseContext.colorScheme.id ?? DEFAULTS.colorScheme
|
|
931
|
-
);
|
|
932
|
-
const fontScheme = resolveDesignRecord(
|
|
933
|
-
presentation,
|
|
934
|
-
"fontSchemes",
|
|
935
|
-
design.fontScheme,
|
|
936
|
-
baseContext.fonts.id ?? DEFAULTS.fontScheme
|
|
937
|
-
);
|
|
938
|
-
const background = design.background
|
|
939
|
-
? resolveBackground(design.background, colorScheme)
|
|
940
|
-
: baseContext.colors.background;
|
|
941
|
-
const fonts = design.fontScheme ? resolveFonts(fontScheme) : baseContext.fonts;
|
|
942
|
-
|
|
943
|
-
return {
|
|
944
|
-
...baseContext,
|
|
945
|
-
colorScheme,
|
|
946
|
-
fonts,
|
|
947
|
-
colors: {
|
|
948
|
-
...baseContext.colors,
|
|
949
|
-
background,
|
|
950
|
-
text: readableTextColor(background, colorScheme),
|
|
951
|
-
mutedText: normalizeHex(colorScheme.textSecondary ?? colorScheme.dark2 ?? baseContext.colors.mutedText),
|
|
952
|
-
accent: normalizeHex(colorScheme.primary ?? colorScheme.accent1 ?? baseContext.colors.accent),
|
|
953
|
-
surface: normalizeHex(colorScheme.surface ?? colorScheme.light2 ?? baseContext.colors.surface),
|
|
954
|
-
border: normalizeHex(colorScheme.accent3 ?? baseContext.colors.border)
|
|
955
|
-
}
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
function collectSlideBindings(slide, slideIndex) {
|
|
960
|
-
const promoted = PROMOTED_REGION_KEYS
|
|
961
|
-
.filter((key) => slide[key] !== undefined)
|
|
962
|
-
.map((key) => ({
|
|
963
|
-
payload: slide[key],
|
|
964
|
-
regionKey: key,
|
|
965
|
-
path: `slides.${slideIndex}.${key}`
|
|
966
|
-
}));
|
|
967
|
-
|
|
968
|
-
if (promoted.length > 0) return promoted;
|
|
969
|
-
|
|
970
|
-
if (Array.isArray(slide.blocks) && slide.blocks.length > 0) {
|
|
971
|
-
return slide.blocks.map((payload, index) => ({
|
|
972
|
-
payload,
|
|
973
|
-
path: `slides.${slideIndex}.blocks.${index}`
|
|
974
|
-
}));
|
|
918
|
+
function resolveSlideContext(presentation, slide, baseContext, options) {
|
|
919
|
+
const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
|
|
920
|
+
const resolved = resolvePresentationContext(effective, options);
|
|
921
|
+
if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
|
|
922
|
+
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
923
|
+
throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
|
|
975
924
|
}
|
|
976
|
-
|
|
977
|
-
return ROOT_PAYLOAD_FIELDS
|
|
978
|
-
.filter((field) => slide[field] !== undefined)
|
|
979
|
-
.map((field) => ({
|
|
980
|
-
payload: { type: fieldToType(field), [field]: slide[field] },
|
|
981
|
-
path: `slides.${slideIndex}.${field}`
|
|
982
|
-
}));
|
|
925
|
+
return { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, imageFill: effective.design.imageFill ?? "fit" };
|
|
983
926
|
}
|
|
984
927
|
|
|
985
928
|
function fieldToType(field) {
|
|
986
|
-
|
|
987
|
-
if (field === "bullets") return "text";
|
|
988
|
-
return field;
|
|
929
|
+
return field === "items" || field === "bullets" ? "list" : field;
|
|
989
930
|
}
|
|
990
931
|
|
|
991
932
|
async function addPayload(slide, presentation, payload, region, path, context, options) {
|
|
@@ -1007,7 +948,7 @@ async function addPayload(slide, presentation, payload, region, path, context, o
|
|
|
1007
948
|
addChartPayload(slide, payload.chart, region, context);
|
|
1008
949
|
break;
|
|
1009
950
|
case "table":
|
|
1010
|
-
addTablePayload(slide, payload.table, region, context);
|
|
951
|
+
addTablePayload(slide, payload.table, region, context, options, path);
|
|
1011
952
|
break;
|
|
1012
953
|
case "code":
|
|
1013
954
|
addCodePayload(slide, payload.code, region, context);
|
|
@@ -1048,6 +989,31 @@ function addTextPayload(slide, value, region, context) {
|
|
|
1048
989
|
slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
|
|
1049
990
|
}
|
|
1050
991
|
|
|
992
|
+
function richLineRuns(line,color) {
|
|
993
|
+
return line.fragments.map(fragment=>({text:fragment.text,options:{fontFace:fragment.style.fontFamily,fontSize:fragment.fontSize*.75,bold:fragment.style.fontWeight>=600,italic:fragment.style.italic,color:normalizeHex(fragment.run.color??color),underline:fragment.run.underline?{color:normalizeHex(fragment.run.color??color)}:undefined,strike:fragment.run.strikethrough?'sngStrike':undefined,baseline:fragment.baselineShift?-fragment.baselineShift/fragment.fontSize*2000:undefined,hyperlink:fragment.run.link&&/^(https?:|mailto:)/i.test(fragment.run.link)?{url:fragment.run.link}:undefined}}));
|
|
994
|
+
}
|
|
995
|
+
function addMeasuredList(slide,fit,context) {
|
|
996
|
+
for(const entry of fit.listEntries){
|
|
997
|
+
const addLines=(text,box,color,withBullet)=>{
|
|
998
|
+
text.richLines.forEach((line,index)=>{
|
|
999
|
+
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
1000
|
+
const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
1001
|
+
const objectName=first?`OPF list paragraph ${context.listMarkers.size+1}`:undefined;
|
|
1002
|
+
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
|
|
1003
|
+
const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
1004
|
+
const runs=richLineRuns(line,color);
|
|
1005
|
+
if(!runs.length)runs.push({text:'',options:{}});
|
|
1006
|
+
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
1007
|
+
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
1008
|
+
for(const run of runs)Object.assign(run.options,paragraph);
|
|
1009
|
+
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:entry.marker.style.fontFamily,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
|
|
1010
|
+
});
|
|
1011
|
+
};
|
|
1012
|
+
addLines(entry.text,entry.textBox,context.colors.text,true);
|
|
1013
|
+
if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,false);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1051
1017
|
function addListPayload(slide, items, region, context) {
|
|
1052
1018
|
const list = Array.isArray(items) ? items : [];
|
|
1053
1019
|
if (list.length === 0) {
|
|
@@ -1093,8 +1059,11 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
1093
1059
|
addPlaceholderPayload(slide, "Image", asset, region, context);
|
|
1094
1060
|
return;
|
|
1095
1061
|
}
|
|
1062
|
+
const objectName = `OPF image ${context.imagePlacements.size + 1}`;
|
|
1063
|
+
context.imagePlacements.set(objectName, { region, mode: context.imageFill, path });
|
|
1096
1064
|
slide.addImage({
|
|
1097
1065
|
...resolved,
|
|
1066
|
+
objectName,
|
|
1098
1067
|
x: region.x,
|
|
1099
1068
|
y: region.y,
|
|
1100
1069
|
w: region.w,
|
|
@@ -1129,44 +1098,62 @@ function addChartPayload(slide, chart, region, context) {
|
|
|
1129
1098
|
});
|
|
1130
1099
|
}
|
|
1131
1100
|
|
|
1132
|
-
function addTablePayload(slide, table, region, context) {
|
|
1133
|
-
const
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
options: {
|
|
1138
|
-
bold: true,
|
|
1139
|
-
color: context.colors.text,
|
|
1140
|
-
fill: { color: context.colors.surface }
|
|
1141
|
-
}
|
|
1142
|
-
})));
|
|
1143
|
-
}
|
|
1144
|
-
if (Array.isArray(table?.rows)) {
|
|
1145
|
-
for (const row of table.rows) {
|
|
1146
|
-
rows.push((Array.isArray(row) ? row : [row]).map((value) => ({
|
|
1147
|
-
text: stringifyText(value),
|
|
1148
|
-
options: { color: context.colors.text }
|
|
1149
|
-
})));
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
if (rows.length === 0) {
|
|
1101
|
+
function addTablePayload(slide, table, region, context, options, path) {
|
|
1102
|
+
const scale = Math.min(context.dimensions.widthInches * 96, context.dimensions.heightInches * 96) / 720;
|
|
1103
|
+
const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
|
|
1104
|
+
const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
|
|
1105
|
+
if (sourceRows.length === 0) {
|
|
1154
1106
|
addPlaceholderPayload(slide, "Table", table, region, context);
|
|
1155
1107
|
return;
|
|
1156
1108
|
}
|
|
1157
1109
|
|
|
1110
|
+
const columnCount = Math.max(1, ...sourceRows.map(row => row.length));
|
|
1111
|
+
const rowHeight = Math.min(54 * scale / 96, region.h / sourceRows.length);
|
|
1112
|
+
const cellBox = {
|
|
1113
|
+
x: 0, y: 0,
|
|
1114
|
+
width: Math.max(scale, region.w * 96 / columnCount - 20 * scale),
|
|
1115
|
+
height: Math.max(scale, rowHeight * 96 - 12 * scale),
|
|
1116
|
+
};
|
|
1117
|
+
const rows = sourceRows.map((row, rowIndex) => Array.from({ length: columnCount }, (_, columnIndex) => {
|
|
1118
|
+
const header = hasHeaders && rowIndex === 0;
|
|
1119
|
+
const cellPath = header ? `${path}.columns.${columnIndex}` : `${path}.rows.${rowIndex - Number(hasHeaders)}.${columnIndex}`;
|
|
1120
|
+
const text = stringifyText(row[columnIndex]);
|
|
1121
|
+
const style = resolveTextStyle({ fontFamily: context.fonts.body, fontWeight: header ? 700 : 400, italic: false, path: cellPath }, options.textMeasurement);
|
|
1122
|
+
const fit = fitText(text, cellBox, 15 * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
|
|
1123
|
+
return {
|
|
1124
|
+
// Keep native wrapping and the original cell value: inserting measured
|
|
1125
|
+
// soft wraps into the text would change a later import or copy operation.
|
|
1126
|
+
text,
|
|
1127
|
+
options: {
|
|
1128
|
+
fontFace: style.fontFamily,
|
|
1129
|
+
fontSize: fit.fontSize * 0.75,
|
|
1130
|
+
bold: style.fontWeight >= 600,
|
|
1131
|
+
italic: style.italic,
|
|
1132
|
+
lineSpacing: fit.lineHeight * 0.75,
|
|
1133
|
+
paraSpaceAfter: 0,
|
|
1134
|
+
align: context.contentAlignment,
|
|
1135
|
+
color: header ? "FFFFFF" : context.colors.text,
|
|
1136
|
+
fill: { color: header ? context.colors.accent : context.colors.surface },
|
|
1137
|
+
},
|
|
1138
|
+
};
|
|
1139
|
+
}));
|
|
1140
|
+
const objectName = `OPF table ${context.tableHeaders.size + 1}`;
|
|
1141
|
+
context.tableHeaders.set(objectName, hasHeaders);
|
|
1158
1142
|
slide.addTable(rows, {
|
|
1143
|
+
objectName,
|
|
1159
1144
|
x: region.x,
|
|
1160
1145
|
y: region.y,
|
|
1161
1146
|
w: region.w,
|
|
1162
|
-
h:
|
|
1147
|
+
h: rowHeight * rows.length,
|
|
1148
|
+
rowH: rowHeight,
|
|
1149
|
+
colW: Array(columnCount).fill(region.w / columnCount),
|
|
1150
|
+
autoPage: false,
|
|
1163
1151
|
fontFace: context.fonts.body,
|
|
1164
|
-
fontSize:
|
|
1152
|
+
fontSize: 15 * scale * 0.75,
|
|
1165
1153
|
color: context.colors.text,
|
|
1166
1154
|
border: { type: "solid", color: context.colors.border, pt: 0.75 },
|
|
1167
|
-
margin:
|
|
1168
|
-
valign: "
|
|
1169
|
-
fit: "shrink"
|
|
1155
|
+
margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
|
|
1156
|
+
valign: "top"
|
|
1170
1157
|
});
|
|
1171
1158
|
}
|
|
1172
1159
|
|
|
@@ -1263,13 +1250,15 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
1263
1250
|
y: region.y,
|
|
1264
1251
|
w: region.w,
|
|
1265
1252
|
h: region.h,
|
|
1266
|
-
margin:
|
|
1253
|
+
margin: 0,
|
|
1254
|
+
paraSpaceAfter: 0,
|
|
1255
|
+
lineSpacingMultiple: 1.22,
|
|
1267
1256
|
fontFace: context.fonts.body,
|
|
1268
1257
|
fontSize,
|
|
1269
1258
|
color: context.colors.text,
|
|
1270
1259
|
breakLine: false,
|
|
1271
1260
|
fit: "shrink",
|
|
1272
|
-
valign: "
|
|
1261
|
+
valign: "top"
|
|
1273
1262
|
};
|
|
1274
1263
|
}
|
|
1275
1264
|
|
|
@@ -1296,7 +1285,9 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
1296
1285
|
color: normalizeHex(run?.color ?? context.colors.text),
|
|
1297
1286
|
fontFace: run?.fontFamily ?? context.fonts.body,
|
|
1298
1287
|
fontSize: run?.fontSize ?? fallbackFontSize,
|
|
1299
|
-
|
|
1288
|
+
superscript: run?.superscript,
|
|
1289
|
+
subscript: !run?.superscript && run?.subscript,
|
|
1290
|
+
hyperlink: run?.link && /^(https?:|mailto:)/i.test(run.link) ? { url: run.link } : undefined
|
|
1300
1291
|
}
|
|
1301
1292
|
};
|
|
1302
1293
|
});
|
|
@@ -1462,15 +1453,8 @@ function withoutSchema(value) {
|
|
|
1462
1453
|
}
|
|
1463
1454
|
|
|
1464
1455
|
function resolveDimensions(value) {
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
const preset = DIMENSION_PRESETS[value.preset] ?? DIMENSION_PRESETS.widescreen;
|
|
1468
|
-
return {
|
|
1469
|
-
widthInches: value.widthInches ?? preset.widthInches,
|
|
1470
|
-
heightInches: value.heightInches ?? preset.heightInches
|
|
1471
|
-
};
|
|
1472
|
-
}
|
|
1473
|
-
return DIMENSION_PRESETS.widescreen;
|
|
1456
|
+
const { width, height } = resolveCanvasDimensions(value);
|
|
1457
|
+
return { widthInches: width / 96, heightInches: height / 96 };
|
|
1474
1458
|
}
|
|
1475
1459
|
|
|
1476
1460
|
function resolveBackground(value, colorScheme) {
|
|
@@ -1489,21 +1473,9 @@ function resolveBackground(value, colorScheme) {
|
|
|
1489
1473
|
}
|
|
1490
1474
|
|
|
1491
1475
|
function resolveFonts(fontScheme) {
|
|
1492
|
-
|
|
1493
|
-
const body = fontFamily(fontScheme.body) ?? fontScheme.minor ?? "Aptos";
|
|
1494
|
-
return {
|
|
1495
|
-
id: fontScheme.id,
|
|
1496
|
-
heading,
|
|
1497
|
-
body,
|
|
1498
|
-
code: fontFamily(fontScheme.code) ?? "Consolas"
|
|
1499
|
-
};
|
|
1476
|
+
return {id:fontScheme.id,...resolveFontFamilies(fontScheme)};
|
|
1500
1477
|
}
|
|
1501
1478
|
|
|
1502
|
-
function fontFamily(value) {
|
|
1503
|
-
if (typeof value === "string") return value;
|
|
1504
|
-
if (isPlainObject(value) && typeof value.family === "string") return value.family;
|
|
1505
|
-
return null;
|
|
1506
|
-
}
|
|
1507
1479
|
|
|
1508
1480
|
function readableTextColor(background, colorScheme) {
|
|
1509
1481
|
return isDarkHex(background)
|
|
@@ -1532,61 +1504,7 @@ function isDarkHex(value) {
|
|
|
1532
1504
|
return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
|
|
1533
1505
|
}
|
|
1534
1506
|
|
|
1535
|
-
function
|
|
1536
|
-
const [first, second] = key.includes(":") ? key.split(":") : [key, null];
|
|
1537
|
-
const rowPart = second ? first : isRowPart(first) ? first : "top+middle+bottom";
|
|
1538
|
-
const colPart = second ? second : isColumnPart(first) ? first : "left+center+right";
|
|
1539
|
-
const rowSpan = span(rowPart, ["top", "middle", "bottom"]);
|
|
1540
|
-
const colSpan = span(colPart, ["left", "center", "right"]);
|
|
1541
|
-
const cellW = area.w / 3;
|
|
1542
|
-
const cellH = area.h / 3;
|
|
1543
|
-
|
|
1544
|
-
return {
|
|
1545
|
-
x: area.x + colSpan.start * cellW,
|
|
1546
|
-
y: area.y + rowSpan.start * cellH,
|
|
1547
|
-
w: (colSpan.end - colSpan.start + 1) * cellW,
|
|
1548
|
-
h: (rowSpan.end - rowSpan.start + 1) * cellH
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
function isRowPart(value) {
|
|
1553
|
-
return value.split("+").every((part) => ["top", "middle", "bottom"].includes(part));
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
function isColumnPart(value) {
|
|
1557
|
-
return value.split("+").every((part) => ["left", "center", "right"].includes(part));
|
|
1558
|
-
}
|
|
1559
|
-
|
|
1560
|
-
function span(value, order) {
|
|
1561
|
-
const indexes = value.split("+").map((part) => order.indexOf(part)).filter((index) => index >= 0);
|
|
1562
|
-
if (indexes.length === 0) return { start: 0, end: order.length - 1 };
|
|
1563
|
-
return { start: Math.min(...indexes), end: Math.max(...indexes) };
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
function regionFromIndex(index, total, area) {
|
|
1567
|
-
if (total <= 1) return area;
|
|
1568
|
-
const columns = total === 2 ? 2 : Math.ceil(Math.sqrt(total));
|
|
1569
|
-
const rows = Math.ceil(total / columns);
|
|
1570
|
-
const row = Math.floor(index / columns);
|
|
1571
|
-
const col = index % columns;
|
|
1572
|
-
return {
|
|
1573
|
-
x: area.x + (area.w / columns) * col,
|
|
1574
|
-
y: area.y + (area.h / rows) * row,
|
|
1575
|
-
w: area.w / columns,
|
|
1576
|
-
h: area.h / rows
|
|
1577
|
-
};
|
|
1578
|
-
}
|
|
1579
|
-
|
|
1580
|
-
function insetRegion(region, amount) {
|
|
1581
|
-
return {
|
|
1582
|
-
x: region.x + amount,
|
|
1583
|
-
y: region.y + amount,
|
|
1584
|
-
w: Math.max(0.2, region.w - amount * 2),
|
|
1585
|
-
h: Math.max(0.2, region.h - amount * 2)
|
|
1586
|
-
};
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
function normalizePptxZip(raw, context) {
|
|
1507
|
+
async function normalizePptxZip(raw, context) {
|
|
1590
1508
|
let entries;
|
|
1591
1509
|
try {
|
|
1592
1510
|
entries = unzipSync(raw);
|
|
@@ -1596,18 +1514,59 @@ function normalizePptxZip(raw, context) {
|
|
|
1596
1514
|
});
|
|
1597
1515
|
}
|
|
1598
1516
|
|
|
1517
|
+
const imageSources = new Map();
|
|
1518
|
+
for (const [part, bytes] of Object.entries(entries)) {
|
|
1519
|
+
if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
|
|
1520
|
+
const relationships = parseRelationships(entries, part);
|
|
1521
|
+
for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
1522
|
+
const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
|
|
1523
|
+
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
1524
|
+
if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const imageMetadata = new Map();
|
|
1528
|
+
for (const [part, bytes] of Object.entries(entries)) {
|
|
1529
|
+
if (!part.startsWith('ppt/media/')) continue;
|
|
1530
|
+
let metadata = rasterMetadata(bytes);
|
|
1531
|
+
if (metadata?.mediaType === 'image/webp' && context.imageFormat === 'compatible') {
|
|
1532
|
+
try {
|
|
1533
|
+
if (metadata.width * metadata.height > 40_000_000) throw new Error('Image dimensions exceed the 40 megapixel conversion limit.');
|
|
1534
|
+
const png = await webpToPng(bytes);
|
|
1535
|
+
metadata = rasterMetadata(png);
|
|
1536
|
+
if (metadata?.mediaType !== 'image/png') throw new Error('The local decoder did not return a PNG.');
|
|
1537
|
+
entries[part] = png;
|
|
1538
|
+
} catch (error) {
|
|
1539
|
+
throw new OPFPptxError('image-conversion-failed', 'WebP could not be converted to a compatible PNG.', {path: imageSources.get(part) ?? part, cause: errorMessage(error)});
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
imageMetadata.set(part, metadata);
|
|
1543
|
+
}
|
|
1599
1544
|
const output = {};
|
|
1600
1545
|
const renameMaps = buildRenameMaps(Object.keys(entries));
|
|
1546
|
+
// The host may transform assets or supply a filename/MIME hint that no
|
|
1547
|
+
// longer matches its bytes. Native package metadata must describe the bytes.
|
|
1548
|
+
renameMaps.media = new Map();
|
|
1549
|
+
for (const [path, metadata] of imageMetadata) {
|
|
1550
|
+
if (!metadata) continue;
|
|
1551
|
+
const extension = metadata.mediaType.slice('image/'.length);
|
|
1552
|
+
const currentExtension = path.split('.').at(-1).toLowerCase();
|
|
1553
|
+
if (currentExtension === extension || (extension === 'jpeg' && currentExtension === 'jpg')) continue;
|
|
1554
|
+
const target = path.replace(/\.[^/.]+$/, `.${extension}`);
|
|
1555
|
+
if (target !== path && Object.hasOwn(entries, target)) throw new OPFPptxError('packaging-failed', 'Normalized image paths collide.', {path, target});
|
|
1556
|
+
renameMaps.media.set(path, target);
|
|
1557
|
+
}
|
|
1601
1558
|
for (const path of Object.keys(entries).sort()) {
|
|
1602
1559
|
const normalizedPath = normalizePartPath(path, renameMaps);
|
|
1603
|
-
const bytes = normalizePartBytes(path, entries[path], context, renameMaps);
|
|
1560
|
+
const bytes = normalizePartBytes(path, entries[path], context, renameMaps, entries, imageMetadata);
|
|
1604
1561
|
output[normalizedPath] = [bytes, {
|
|
1605
1562
|
level: context.compressionLevel,
|
|
1606
1563
|
mtime: context.zipDate
|
|
1607
1564
|
}];
|
|
1608
1565
|
}
|
|
1609
1566
|
|
|
1610
|
-
|
|
1567
|
+
// Sort after chart/worksheet renaming; source counters can cross digit widths.
|
|
1568
|
+
const sortedOutput = Object.fromEntries(Object.keys(output).sort().map(path => [path, output[path]]));
|
|
1569
|
+
return zipSync(sortedOutput, {
|
|
1611
1570
|
level: context.compressionLevel,
|
|
1612
1571
|
mtime: context.zipDate
|
|
1613
1572
|
});
|
|
@@ -1619,7 +1578,8 @@ function normalizeCoreProperties(xml, timestamp) {
|
|
|
1619
1578
|
.replace(/<dcterms:modified xsi:type="dcterms:W3CDTF">[^<]*<\/dcterms:modified>/g, `<dcterms:modified xsi:type="dcterms:W3CDTF">${timestamp}</dcterms:modified>`);
|
|
1620
1579
|
}
|
|
1621
1580
|
|
|
1622
|
-
function normalizePartBytes(path, bytes, context, renameMaps) {
|
|
1581
|
+
function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMetadata) {
|
|
1582
|
+
if (imageMetadata.has(path)) return normalizeImageOrientation(bytes, imageMetadata.get(path));
|
|
1623
1583
|
if (path.endsWith(".xlsx")) {
|
|
1624
1584
|
return normalizeNestedZip(bytes, context);
|
|
1625
1585
|
}
|
|
@@ -1627,7 +1587,71 @@ function normalizePartBytes(path, bytes, context, renameMaps) {
|
|
|
1627
1587
|
return encodeText(normalizePartReferences(normalizeCoreProperties(decodeText(bytes), context.timestamp), renameMaps));
|
|
1628
1588
|
}
|
|
1629
1589
|
if (isXmlPart(path)) {
|
|
1630
|
-
|
|
1590
|
+
let xml=decodeText(bytes);
|
|
1591
|
+
if (path === '[Content_Types].xml') {
|
|
1592
|
+
// Explicit per-part types also correct PptxGenJS's image/jpg default.
|
|
1593
|
+
const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
|
|
1594
|
+
`<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
|
|
1595
|
+
xml = xml.replace('</Types>', `${overrides}</Types>`);
|
|
1596
|
+
}
|
|
1597
|
+
if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
|
|
1598
|
+
const fill = context.backgroundFills.get(path);
|
|
1599
|
+
if (fill) xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
|
|
1600
|
+
// PptxGenJS table IDs can collide with other objects on the same slide.
|
|
1601
|
+
// Preserve existing IDs and allocate unused IDs only for duplicates. This
|
|
1602
|
+
// export path creates no connector attachments or animation ID references.
|
|
1603
|
+
const objectIds = [...xml.matchAll(/<p:cNvPr\b[^>]*\bid="(\d+)"/g)].map(match => Number(match[1]));
|
|
1604
|
+
let nextObjectId = Math.max(0, ...objectIds) + 1;
|
|
1605
|
+
const seenObjectIds = new Set();
|
|
1606
|
+
xml = xml.replace(/(<p:cNvPr\b[^>]*\bid=")(\d+)(")/g, (node, before, rawId, after) => {
|
|
1607
|
+
const id = Number(rawId);
|
|
1608
|
+
if (seenObjectIds.has(id)) return `${before}${nextObjectId++}${after}`;
|
|
1609
|
+
seenObjectIds.add(id);
|
|
1610
|
+
return node;
|
|
1611
|
+
});
|
|
1612
|
+
// PptxGenJS 4 has no firstRow option. Set the native flag explicitly so
|
|
1613
|
+
// viewers and later imports distinguish column labels from data rows.
|
|
1614
|
+
xml = xml.replace(/<p:graphicFrame>([\s\S]*?)<\/p:graphicFrame>/g, frame => {
|
|
1615
|
+
const name = frame.match(/name="(OPF table \d+)"/)?.[1];
|
|
1616
|
+
if (!context.tableHeaders.has(name)) return frame;
|
|
1617
|
+
return frame.replace('<a:tblPr/>', `<a:tblPr firstRow="${context.tableHeaders.get(name) ? 1 : 0}"/>`);
|
|
1618
|
+
});
|
|
1619
|
+
// Image data is already resolved and embedded by PptxGenJS. Read those
|
|
1620
|
+
// exact bytes instead of fetching or resolving the source a second time.
|
|
1621
|
+
const relationships = parseRelationships(entries, path);
|
|
1622
|
+
xml = xml.replace(/<p:pic>([\s\S]*?)<\/p:pic>/g, picture => {
|
|
1623
|
+
const placement = context.imagePlacements.get(picture.match(/name="(OPF image \d+)"/)?.[1]);
|
|
1624
|
+
if (!placement) return picture;
|
|
1625
|
+
const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
1626
|
+
const dimensions = imageMetadata.get(relationships.get(id)?.path);
|
|
1627
|
+
if (!dimensions) throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path: placement.path });
|
|
1628
|
+
const fitted = pictureTransform(dimensions, placement.region, placement.mode);
|
|
1629
|
+
const emu = value => Math.round(value * EMUS_PER_INCH);
|
|
1630
|
+
const transformAttrs = `${fitted.rotation ? ` rot="${fitted.rotation * 60000}"` : ''}${fitted.flipH ? ' flipH="1"' : ''}${fitted.flipV ? ' flipV="1"' : ''}`;
|
|
1631
|
+
picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${transformAttrs}><a:off x="${emu(fitted.x)}" y="${emu(fitted.y)}"/><a:ext cx="${emu(fitted.w)}" cy="${emu(fitted.h)}"/></a:xfrm>`);
|
|
1632
|
+
if (fitted.crop) {
|
|
1633
|
+
const attrs = Object.entries(fitted.crop).map(([key, value]) => `${key}="${value}"`).join(' ');
|
|
1634
|
+
picture = picture.replace('<a:stretch>', `<a:srcRect ${attrs}/><a:stretch>`);
|
|
1635
|
+
}
|
|
1636
|
+
return picture;
|
|
1637
|
+
});
|
|
1638
|
+
// Native bullets otherwise inherit the first rich run's size, font and
|
|
1639
|
+
// color, which can differ from the measured list marker.
|
|
1640
|
+
xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
|
|
1641
|
+
const marker=context.listMarkers.get(shape.match(/name="(OPF list paragraph \d+)"/)?.[1]);
|
|
1642
|
+
if(!marker)return shape;
|
|
1643
|
+
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
1644
|
+
return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr><a:srgbClr val="${marker.color}"/></a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
|
|
1645
|
+
});
|
|
1646
|
+
// PptxGenJS 4 emits pPr before each rich run. OOXML allows one pPr,
|
|
1647
|
+
// before all runs. Paragraph options belong to the first run.
|
|
1648
|
+
xml=xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g,(_,body)=>{
|
|
1649
|
+
let properties='';
|
|
1650
|
+
const content=body.replace(/<a:pPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:pPr>)/g,node=>{properties ||= node;return '';});
|
|
1651
|
+
return `<a:p>${properties}${content}</a:p>`;
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
return encodeText(normalizePartReferences(xml, renameMaps));
|
|
1631
1655
|
}
|
|
1632
1656
|
return bytes;
|
|
1633
1657
|
}
|
|
@@ -1652,7 +1676,7 @@ function numberedFilenameMap(paths, pattern) {
|
|
|
1652
1676
|
}
|
|
1653
1677
|
|
|
1654
1678
|
function normalizePartPath(path, renameMaps) {
|
|
1655
|
-
return normalizePartReferences(path, renameMaps);
|
|
1679
|
+
return normalizePartReferences(renameMaps.media?.get(path) ?? path, renameMaps);
|
|
1656
1680
|
}
|
|
1657
1681
|
|
|
1658
1682
|
function normalizePartReferences(value, renameMaps) {
|
|
@@ -1666,6 +1690,14 @@ function normalizePartReferences(value, renameMaps) {
|
|
|
1666
1690
|
`Microsoft_Excel_Worksheet${newId}.xlsx`
|
|
1667
1691
|
);
|
|
1668
1692
|
}
|
|
1693
|
+
// Rewrite package references only, not user-visible text containing paths.
|
|
1694
|
+
output = output.replace(/\b(Target|PartName)="([^"]+)"/g, (attribute, name, value) => {
|
|
1695
|
+
const prefix = value.startsWith('../media/') ? '../' : value.startsWith('/ppt/media/') ? '/ppt/' : null;
|
|
1696
|
+
if (!prefix) return attribute;
|
|
1697
|
+
const part = 'ppt/' + value.slice(prefix.length);
|
|
1698
|
+
const target = renameMaps.media?.get(part);
|
|
1699
|
+
return target ? `${name}="${prefix}${target.slice('ppt/'.length)}"` : attribute;
|
|
1700
|
+
});
|
|
1669
1701
|
return output;
|
|
1670
1702
|
}
|
|
1671
1703
|
|