@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 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
- level: Number(paragraph?.["a:pPr"]?.lvl ?? 0),
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 margin = 0.55;
851
- let y = 0.34;
852
-
853
- if (opfSlide.tag) {
854
- slide.addText(String(opfSlide.tag), {
855
- x: margin,
856
- y,
857
- w: widthInches - margin * 2,
858
- h: 0.24,
859
- margin: 0,
860
- fontFace: slideContext.fonts.body,
861
- fontSize: 9,
862
- bold: true,
863
- color: slideContext.colors.accent,
864
- fit: "shrink"
865
- });
866
- y += 0.32;
867
- }
868
-
869
- const title = opfSlide.title ?? presentation.title ?? presentation.name;
870
- if (title) {
871
- slide.addText(String(title), {
872
- x: margin,
873
- y,
874
- w: widthInches - margin * 2,
875
- h: 0.58,
876
- margin: 0,
877
- fontFace: slideContext.fonts.heading,
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
- if (!slide.design) return baseContext;
925
- const design = slide.design;
926
- const colorScheme = resolveDesignRecord(
927
- presentation,
928
- "colorSchemes",
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
- if (field === "items") return "list";
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: context.colors.text,
1140
- fill: { color: context.colors.surface }
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: region.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: 10,
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: 0.05,
1168
- valign: "mid",
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: 4,
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: "mid"
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
- hyperlink: run?.link ? { url: run.link } : undefined
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
- if (typeof value === "string") return DIMENSION_PRESETS[value] ?? DIMENSION_PRESETS.widescreen;
1466
- if (isPlainObject(value)) {
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
- const heading = fontFamily(fontScheme.heading) ?? fontScheme.major ?? "Aptos Display";
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
- return encodeText(normalizePartReferences(decodeText(bytes), renameMaps));
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[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.1",
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/smoke.mjs",
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.2.2",
44
+ "@openpresentation/opf": "^0.4.0",
45
45
  "fast-xml-parser": "^5.8.0",
46
46
  "fflate": "^0.8.3",
47
- "pptxgenjs": "^4.0.1"
47
+ "pptxgenjs": "4.0.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@openpresentation/opf-render": ">=0.0.0"
50
+ "@openpresentation/opf-render": "^0.1.0"
51
51
  },
52
52
  "peerDependenciesMeta": {
53
53
  "@openpresentation/opf-render": {