@openpresentation/opf-pptx 0.0.1 → 0.1.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 +12 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +137 -228
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -106,3 +106,15 @@ Required first-publish setup:
|
|
|
106
106
|
3. Publish by creating a GitHub Release or manually running the Release workflow after CI passes.
|
|
107
107
|
|
|
108
108
|
This repo does not require an npm automation token when Trusted Publishing is configured.
|
|
109
|
+
|
|
110
|
+
## Shared dynamic composition (local development)
|
|
111
|
+
|
|
112
|
+
The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
|
|
113
|
+
|
|
114
|
+
Version 0.1.0 requires published `@openpresentation/opf@^0.4.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.1.0`. Clean registry installs support the new composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
|
|
115
|
+
|
|
116
|
+
For crowded drafts, run `paginatePresentation` from `@openpresentation/opf/pagination` first, then pass its returned presentation to both preview and `toPptx`. Native table row sizing now follows shared reference geometry; the exporter does not add hidden table continuation slides.
|
|
117
|
+
|
|
118
|
+
Pass the same `textMeasurement` provider used by preview and pagination to `toPptx`. Plain text and headings retain the measured line breaks and resolved font family in editable PowerPoint shapes. Font binaries are not yet embedded in PPTX; native viewers still need the resolved font installed.
|
|
119
|
+
|
|
120
|
+
PptxGenJS is pinned to 4.0.1. Its unused `image-size` dependency remains flagged by npm audit; tested OPF operations run with that parser blocked. See [dependency reachability and regression coverage](DEPENDENCY-NOTES.md).
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
|
|
1
2
|
export declare const packageName = "@openpresentation/opf-pptx";
|
|
2
3
|
|
|
3
4
|
export declare const releaseLane: Readonly<{
|
|
@@ -33,6 +34,8 @@ export interface ImageResolverContext {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
export interface ToPptxOptions {
|
|
37
|
+
textMeasurement?: TextMeasurement;
|
|
38
|
+
onDiagnostic?: (diagnostic: LayoutDiagnostic) => void;
|
|
36
39
|
baseDir?: string;
|
|
37
40
|
compressionLevel?: number;
|
|
38
41
|
imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { composeSlide, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
|
|
1
2
|
import PptxGenJS from "pptxgenjs";
|
|
2
3
|
import { unzipSync, zipSync } from "fflate";
|
|
3
4
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -154,9 +155,10 @@ export async function toPptx(input, options = {}) {
|
|
|
154
155
|
const presentation = parseInput(input);
|
|
155
156
|
assertValidBoundary(presentation);
|
|
156
157
|
|
|
157
|
-
const context = resolvePresentationContext(presentation, options);
|
|
158
|
+
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
159
|
+
context.listMarkers = new Map();
|
|
158
160
|
const pptx = new PptxGenJS();
|
|
159
|
-
configurePresentation(pptx, presentation, context);
|
|
161
|
+
configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
|
|
160
162
|
|
|
161
163
|
for (let index = 0; index < presentation.slides.length; index += 1) {
|
|
162
164
|
await addSlide(pptx, presentation, presentation.slides[index], index, context, options);
|
|
@@ -375,7 +377,7 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions) {
|
|
|
375
377
|
const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
|
|
376
378
|
if (subtitleItem) slide.subtitle = firstLine(subtitleItem.text);
|
|
377
379
|
|
|
378
|
-
const blocks = items
|
|
380
|
+
const blocks = mergeAdjacentBulletShapes(items)
|
|
379
381
|
.map((item) => payloadFromSlideItem(item))
|
|
380
382
|
.filter(Boolean);
|
|
381
383
|
if (blocks.length > 0) slide.blocks = blocks;
|
|
@@ -525,7 +527,8 @@ function readParagraphs(txBody) {
|
|
|
525
527
|
}
|
|
526
528
|
return {
|
|
527
529
|
text: texts.join("").trim(),
|
|
528
|
-
|
|
530
|
+
bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buAutoNum"] !== undefined),
|
|
531
|
+
level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
|
|
529
532
|
maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
|
|
530
533
|
};
|
|
531
534
|
})
|
|
@@ -552,7 +555,7 @@ function takeTitleItem(items, dimensions) {
|
|
|
552
555
|
|
|
553
556
|
const titleLimit = dimensions.heightInches * 0.28;
|
|
554
557
|
const candidateIndex = items.findIndex((item) => {
|
|
555
|
-
if (item.kind !== "text" || !item.text) return false;
|
|
558
|
+
if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
556
559
|
const y = item.bounds?.y ?? 0;
|
|
557
560
|
return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
|
|
558
561
|
});
|
|
@@ -568,7 +571,7 @@ function takeSubtitleItem(items, titleItem, dimensions) {
|
|
|
568
571
|
const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
|
|
569
572
|
const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
|
|
570
573
|
const candidateIndex = items.findIndex((item) => {
|
|
571
|
-
if (item.kind !== "text" || !item.text) return false;
|
|
574
|
+
if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
|
|
572
575
|
const y = item.bounds?.y ?? 0;
|
|
573
576
|
const h = item.bounds?.h ?? 0;
|
|
574
577
|
return y >= titleBottom - 0.05
|
|
@@ -581,10 +584,29 @@ function takeSubtitleItem(items, titleItem, dimensions) {
|
|
|
581
584
|
return null;
|
|
582
585
|
}
|
|
583
586
|
|
|
587
|
+
// Adjacent native bullet boxes on the same text column form one imported list.
|
|
588
|
+
// This is a geometry heuristic, not a lossless reconstruction of arbitrary PPTX.
|
|
589
|
+
function mergeAdjacentBulletShapes(items) {
|
|
590
|
+
const result=[];
|
|
591
|
+
for(const item of items){
|
|
592
|
+
const previous=result.at(-1);
|
|
593
|
+
const bullets=value=>value?.kind==='text'&&value.paragraphs.length&&value.paragraphs.every(p=>p.bullet);
|
|
594
|
+
if(bullets(previous)&&bullets(item)&&previous.bounds&&item.bounds
|
|
595
|
+
&&Math.abs(previous.bounds.x-item.bounds.x)<.05
|
|
596
|
+
&&item.bounds.y>=previous.bounds.y+previous.bounds.h-.02
|
|
597
|
+
&&item.bounds.y-(previous.bounds.y+previous.bounds.h)<.3){
|
|
598
|
+
previous.paragraphs.push(...item.paragraphs);
|
|
599
|
+
previous.text+='\n'+item.text;
|
|
600
|
+
previous.bounds.h=item.bounds.y+item.bounds.h-previous.bounds.y;
|
|
601
|
+
}else result.push({...item,paragraphs:item.paragraphs?[...item.paragraphs]:undefined,bounds:item.bounds?{...item.bounds}:undefined});
|
|
602
|
+
}
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
|
|
584
606
|
function payloadFromSlideItem(item) {
|
|
585
607
|
if (item.payload) return item.payload;
|
|
586
608
|
if (item.kind === "text") {
|
|
587
|
-
if (item.paragraphs.length > 1) {
|
|
609
|
+
if (item.paragraphs.length > 1 || item.paragraphs.some(p=>p.bullet)) {
|
|
588
610
|
return {
|
|
589
611
|
type: "list",
|
|
590
612
|
items: item.paragraphs.map((paragraph) => (
|
|
@@ -799,7 +821,9 @@ function resolvePresentationContext(presentation, options) {
|
|
|
799
821
|
const dimensions = resolveDimensions(design.dimensions ?? theme?.dimensions);
|
|
800
822
|
const background = resolveBackground(design.background ?? theme?.background, colorScheme);
|
|
801
823
|
const fonts = resolveFonts(fontScheme);
|
|
824
|
+
for (const role of ["heading","body","code"]) fonts[role] = resolveTextStyle({fontFamily:fonts[role],fontWeight:role === "heading" ? 700 : 400},options.textMeasurement).fontFamily;
|
|
802
825
|
const textColor = readableTextColor(background, colorScheme);
|
|
826
|
+
const darkBackground = isDarkHex(background);
|
|
803
827
|
|
|
804
828
|
return {
|
|
805
829
|
seed: Number.isInteger(options.seed) ? options.seed : DEFAULT_SEED,
|
|
@@ -813,9 +837,9 @@ function resolvePresentationContext(presentation, options) {
|
|
|
813
837
|
colors: {
|
|
814
838
|
background,
|
|
815
839
|
text: textColor,
|
|
816
|
-
mutedText: normalizeHex(colorScheme.textSecondary ?? colorScheme.dark2 ?? "#475569"),
|
|
840
|
+
mutedText: normalizeHex(colorScheme.textSecondary ?? (darkBackground ? colorScheme.light2 : colorScheme.dark2) ?? "#475569"),
|
|
817
841
|
accent: normalizeHex(colorScheme.primary ?? colorScheme.accent1 ?? "#2874A6"),
|
|
818
|
-
surface: normalizeHex(colorScheme.surface ?? colorScheme.light2 ?? "#F8FAFC"),
|
|
842
|
+
surface: normalizeHex(colorScheme.surface ?? (darkBackground ? colorScheme.dark2 : colorScheme.light2) ?? "#F8FAFC"),
|
|
819
843
|
border: normalizeHex(colorScheme.accent3 ?? "#CBD5E1")
|
|
820
844
|
}
|
|
821
845
|
};
|
|
@@ -841,151 +865,57 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
841
865
|
|
|
842
866
|
async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
|
|
843
867
|
const slide = pptx.addSlide();
|
|
844
|
-
const slideContext = resolveSlideContext(presentation, opfSlide, context);
|
|
868
|
+
const slideContext = resolveSlideContext(presentation, opfSlide, context, options);
|
|
845
869
|
slide.background = { color: slideContext.colors.background };
|
|
846
870
|
slide.color = slideContext.colors.text;
|
|
847
871
|
if (opfSlide.hidden === true) slide.hidden = true;
|
|
848
872
|
|
|
849
873
|
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);
|
|
874
|
+
const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
|
|
875
|
+
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` });
|
|
876
|
+
const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, textMeasurement: options.textMeasurement });
|
|
877
|
+
for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
|
|
878
|
+
for (const item of geometry.items) {
|
|
879
|
+
const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
|
|
880
|
+
if (["title", "subtitle", "tag"].includes(item.field)) {
|
|
881
|
+
slide.addText(item.text.lines.join("\n"), {
|
|
882
|
+
...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
|
|
883
|
+
fontFace: item.textStyle.fontFamily,
|
|
884
|
+
bold: item.textStyle.fontWeight >= 600,
|
|
885
|
+
italic: item.textStyle.italic,
|
|
886
|
+
color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
|
|
887
|
+
breakLine: false
|
|
888
|
+
});
|
|
889
|
+
} else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
|
|
890
|
+
addMeasuredList(slide,item.text,slideContext);
|
|
891
|
+
} else if (item.field === "text" && item.text?.richLines) {
|
|
892
|
+
const alignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
|
|
893
|
+
for(const line of item.text.richLines){
|
|
894
|
+
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}}));
|
|
895
|
+
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});
|
|
896
|
+
}
|
|
897
|
+
} else if (item.field === "text" && typeof item.value === "string") {
|
|
898
|
+
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});
|
|
899
|
+
} else {
|
|
900
|
+
await addPayload(slide, presentation, item.payload, region, item.path, slideContext, options);
|
|
901
|
+
}
|
|
918
902
|
}
|
|
919
903
|
|
|
920
904
|
if (opfSlide.notes) slide.addNotes(String(opfSlide.notes));
|
|
921
905
|
}
|
|
922
906
|
|
|
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
|
-
}));
|
|
907
|
+
function resolveSlideContext(presentation, slide, baseContext, options) {
|
|
908
|
+
const effective = { ...presentation, design: { ...presentation.design, ...slide.design } };
|
|
909
|
+
const resolved = resolvePresentationContext(effective, options);
|
|
910
|
+
if (Math.abs(resolved.dimensions.widthInches - baseContext.dimensions.widthInches) > 1e-6
|
|
911
|
+
|| Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
|
|
912
|
+
throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
|
|
975
913
|
}
|
|
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
|
-
}));
|
|
914
|
+
return { ...baseContext, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors };
|
|
983
915
|
}
|
|
984
916
|
|
|
985
917
|
function fieldToType(field) {
|
|
986
|
-
|
|
987
|
-
if (field === "bullets") return "text";
|
|
988
|
-
return field;
|
|
918
|
+
return field === "items" || field === "bullets" ? "list" : field;
|
|
989
919
|
}
|
|
990
920
|
|
|
991
921
|
async function addPayload(slide, presentation, payload, region, path, context, options) {
|
|
@@ -1048,6 +978,31 @@ function addTextPayload(slide, value, region, context) {
|
|
|
1048
978
|
slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
|
|
1049
979
|
}
|
|
1050
980
|
|
|
981
|
+
function richLineRuns(line,color) {
|
|
982
|
+
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}}));
|
|
983
|
+
}
|
|
984
|
+
function addMeasuredList(slide,fit,context) {
|
|
985
|
+
for(const entry of fit.listEntries){
|
|
986
|
+
const addLines=(text,box,color,withBullet)=>{
|
|
987
|
+
text.richLines.forEach((line,index)=>{
|
|
988
|
+
const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
|
|
989
|
+
const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
|
|
990
|
+
const objectName=first?`OPF list paragraph ${context.listMarkers.size+1}`:undefined;
|
|
991
|
+
if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
|
|
992
|
+
const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
|
|
993
|
+
const runs=richLineRuns(line,color);
|
|
994
|
+
if(!runs.length)runs.push({text:'',options:{}});
|
|
995
|
+
// Keep paragraph intent identical across runs. ZIP normalization below
|
|
996
|
+
// removes the duplicate paragraph-property nodes emitted by PptxGenJS.
|
|
997
|
+
for(const run of runs)Object.assign(run.options,paragraph);
|
|
998
|
+
slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:entry.marker.style.fontFamily,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
|
|
999
|
+
});
|
|
1000
|
+
};
|
|
1001
|
+
addLines(entry.text,entry.textBox,context.colors.text,true);
|
|
1002
|
+
if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,false);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1051
1006
|
function addListPayload(slide, items, region, context) {
|
|
1052
1007
|
const list = Array.isArray(items) ? items : [];
|
|
1053
1008
|
if (list.length === 0) {
|
|
@@ -1130,14 +1085,15 @@ function addChartPayload(slide, chart, region, context) {
|
|
|
1130
1085
|
}
|
|
1131
1086
|
|
|
1132
1087
|
function addTablePayload(slide, table, region, context) {
|
|
1088
|
+
const scale = Math.min(context.dimensions.widthInches * 96, context.dimensions.heightInches * 96) / 720;
|
|
1133
1089
|
const rows = [];
|
|
1134
1090
|
if (Array.isArray(table?.columns) && table.columns.length > 0) {
|
|
1135
1091
|
rows.push(table.columns.map((value) => ({
|
|
1136
1092
|
text: stringifyText(value),
|
|
1137
1093
|
options: {
|
|
1138
1094
|
bold: true,
|
|
1139
|
-
color:
|
|
1140
|
-
fill: { color: context.colors.
|
|
1095
|
+
color: "FFFFFF",
|
|
1096
|
+
fill: { color: context.colors.accent }
|
|
1141
1097
|
}
|
|
1142
1098
|
})));
|
|
1143
1099
|
}
|
|
@@ -1145,7 +1101,7 @@ function addTablePayload(slide, table, region, context) {
|
|
|
1145
1101
|
for (const row of table.rows) {
|
|
1146
1102
|
rows.push((Array.isArray(row) ? row : [row]).map((value) => ({
|
|
1147
1103
|
text: stringifyText(value),
|
|
1148
|
-
options: { color: context.colors.text }
|
|
1104
|
+
options: { color: context.colors.text, fill: {color:context.colors.surface} }
|
|
1149
1105
|
})));
|
|
1150
1106
|
}
|
|
1151
1107
|
}
|
|
@@ -1155,18 +1111,22 @@ function addTablePayload(slide, table, region, context) {
|
|
|
1155
1111
|
return;
|
|
1156
1112
|
}
|
|
1157
1113
|
|
|
1114
|
+
const columnCount = Math.max(1, ...rows.map(row => row.length));
|
|
1115
|
+
const rowHeight = Math.min(54 * scale / 96, region.h / rows.length);
|
|
1158
1116
|
slide.addTable(rows, {
|
|
1159
1117
|
x: region.x,
|
|
1160
1118
|
y: region.y,
|
|
1161
1119
|
w: region.w,
|
|
1162
|
-
h:
|
|
1120
|
+
h: rowHeight * rows.length,
|
|
1121
|
+
rowH: rowHeight,
|
|
1122
|
+
colW: Array(columnCount).fill(region.w / columnCount),
|
|
1123
|
+
autoPage: false,
|
|
1163
1124
|
fontFace: context.fonts.body,
|
|
1164
|
-
fontSize:
|
|
1125
|
+
fontSize: 15 * scale * 0.75,
|
|
1165
1126
|
color: context.colors.text,
|
|
1166
1127
|
border: { type: "solid", color: context.colors.border, pt: 0.75 },
|
|
1167
|
-
margin:
|
|
1168
|
-
valign: "
|
|
1169
|
-
fit: "shrink"
|
|
1128
|
+
margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
|
|
1129
|
+
valign: "top"
|
|
1170
1130
|
});
|
|
1171
1131
|
}
|
|
1172
1132
|
|
|
@@ -1263,13 +1223,15 @@ function textBoxOptions(region, context, fontSize) {
|
|
|
1263
1223
|
y: region.y,
|
|
1264
1224
|
w: region.w,
|
|
1265
1225
|
h: region.h,
|
|
1266
|
-
margin:
|
|
1226
|
+
margin: 0,
|
|
1227
|
+
paraSpaceAfter: 0,
|
|
1228
|
+
lineSpacingMultiple: 1.22,
|
|
1267
1229
|
fontFace: context.fonts.body,
|
|
1268
1230
|
fontSize,
|
|
1269
1231
|
color: context.colors.text,
|
|
1270
1232
|
breakLine: false,
|
|
1271
1233
|
fit: "shrink",
|
|
1272
|
-
valign: "
|
|
1234
|
+
valign: "top"
|
|
1273
1235
|
};
|
|
1274
1236
|
}
|
|
1275
1237
|
|
|
@@ -1296,7 +1258,9 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
1296
1258
|
color: normalizeHex(run?.color ?? context.colors.text),
|
|
1297
1259
|
fontFace: run?.fontFamily ?? context.fonts.body,
|
|
1298
1260
|
fontSize: run?.fontSize ?? fallbackFontSize,
|
|
1299
|
-
|
|
1261
|
+
superscript: run?.superscript,
|
|
1262
|
+
subscript: !run?.superscript && run?.subscript,
|
|
1263
|
+
hyperlink: run?.link && /^(https?:|mailto:)/i.test(run.link) ? { url: run.link } : undefined
|
|
1300
1264
|
}
|
|
1301
1265
|
};
|
|
1302
1266
|
});
|
|
@@ -1462,15 +1426,8 @@ function withoutSchema(value) {
|
|
|
1462
1426
|
}
|
|
1463
1427
|
|
|
1464
1428
|
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;
|
|
1429
|
+
const { width, height } = resolveCanvasDimensions(value);
|
|
1430
|
+
return { widthInches: width / 96, heightInches: height / 96 };
|
|
1474
1431
|
}
|
|
1475
1432
|
|
|
1476
1433
|
function resolveBackground(value, colorScheme) {
|
|
@@ -1489,21 +1446,9 @@ function resolveBackground(value, colorScheme) {
|
|
|
1489
1446
|
}
|
|
1490
1447
|
|
|
1491
1448
|
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
|
-
};
|
|
1449
|
+
return {id:fontScheme.id,...resolveFontFamilies(fontScheme)};
|
|
1500
1450
|
}
|
|
1501
1451
|
|
|
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
1452
|
|
|
1508
1453
|
function readableTextColor(background, colorScheme) {
|
|
1509
1454
|
return isDarkHex(background)
|
|
@@ -1532,60 +1477,6 @@ function isDarkHex(value) {
|
|
|
1532
1477
|
return (red * 299 + green * 587 + blue * 114) / 1000 < 128;
|
|
1533
1478
|
}
|
|
1534
1479
|
|
|
1535
|
-
function regionFromPromotedKey(key, area) {
|
|
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
1480
|
function normalizePptxZip(raw, context) {
|
|
1590
1481
|
let entries;
|
|
1591
1482
|
try {
|
|
@@ -1627,7 +1518,25 @@ function normalizePartBytes(path, bytes, context, renameMaps) {
|
|
|
1627
1518
|
return encodeText(normalizePartReferences(normalizeCoreProperties(decodeText(bytes), context.timestamp), renameMaps));
|
|
1628
1519
|
}
|
|
1629
1520
|
if (isXmlPart(path)) {
|
|
1630
|
-
|
|
1521
|
+
let xml=decodeText(bytes);
|
|
1522
|
+
if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
|
|
1523
|
+
// Native bullets otherwise inherit the first rich run's size, font and
|
|
1524
|
+
// color, which can differ from the measured list marker.
|
|
1525
|
+
xml=xml.replace(/<p:sp>([\s\S]*?)<\/p:sp>/g,(shape)=>{
|
|
1526
|
+
const marker=context.listMarkers.get(shape.match(/name="(OPF list paragraph \d+)"/)?.[1]);
|
|
1527
|
+
if(!marker)return shape;
|
|
1528
|
+
const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
1529
|
+
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}"/>`);
|
|
1530
|
+
});
|
|
1531
|
+
// PptxGenJS 4 emits pPr before each rich run. OOXML allows one pPr,
|
|
1532
|
+
// before all runs. Paragraph options belong to the first run.
|
|
1533
|
+
xml=xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g,(_,body)=>{
|
|
1534
|
+
let properties='';
|
|
1535
|
+
const content=body.replace(/<a:pPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:pPr>)/g,node=>{properties ||= node;return '';});
|
|
1536
|
+
return `<a:p>${properties}${content}</a:p>`;
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
return encodeText(normalizePartReferences(xml, renameMaps));
|
|
1631
1540
|
}
|
|
1632
1541
|
return bytes;
|
|
1633
1542
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,18 +36,18 @@
|
|
|
36
36
|
"scripts": {
|
|
37
37
|
"build": "node scripts/build.mjs",
|
|
38
38
|
"typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs",
|
|
39
|
-
"test": "npm run build && node test/
|
|
39
|
+
"test": "npm run build && node test/dependency-boundary.mjs",
|
|
40
40
|
"validate": "node scripts/validate-package.mjs",
|
|
41
41
|
"prepack": "npm run build"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@openpresentation/opf": "^0.
|
|
44
|
+
"@openpresentation/opf": "^0.4.0",
|
|
45
45
|
"fast-xml-parser": "^5.8.0",
|
|
46
46
|
"fflate": "^0.8.3",
|
|
47
|
-
"pptxgenjs": "
|
|
47
|
+
"pptxgenjs": "4.0.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"@openpresentation/opf-render": "
|
|
50
|
+
"@openpresentation/opf-render": "^0.1.0"
|
|
51
51
|
},
|
|
52
52
|
"peerDependenciesMeta": {
|
|
53
53
|
"@openpresentation/opf-render": {
|