@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/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 = slideBackground(slideRoot);
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 item = importPicture(entries, picture, slidePath, relationships);
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
- const bytes = relationship?.path ? entries[relationship.path] : null;
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
- level: Number(paragraph?.["a:pPr"]?.lvl ?? 0),
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
- .filter((row) => row.some(Boolean));
607
- if (rows.length === 0) return { rows: [] };
608
- return {
609
- columns: rows[0],
610
- rows: rows.slice(1)
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
- return Math.round((number / EMUS_PER_INCH) * 1_000_000) / 1_000_000;
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 = resolveCatalogRecord(presentation, "themes", design.theme, DEFAULTS.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.accent3 ?? "#CBD5E1")
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 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);
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
- 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
- }));
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
- if (field === "items") return "list";
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 rows = [];
1134
- if (Array.isArray(table?.columns) && table.columns.length > 0) {
1135
- rows.push(table.columns.map((value) => ({
1136
- text: stringifyText(value),
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: region.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: 10,
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: 0.05,
1168
- valign: "mid",
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: 4,
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: "mid"
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
- hyperlink: run?.link ? { url: run.link } : undefined
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
- 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;
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
- 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
- };
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 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
- 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
- return zipSync(output, {
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
- return encodeText(normalizePartReferences(decodeText(bytes), renameMaps));
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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[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