@openpresentation/opf-pptx 0.6.0 → 0.8.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,10 +1,17 @@
1
1
  import {importTableFrames} from './table-import.js';
2
+ import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
3
+ import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
4
+ import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
5
+ import {attachCardTags,importCardFrames} from './card-provenance.js';
6
+ import {attachHeadingTags,importHeadingGroups} from './heading-provenance.js';
7
+ import {attachPlainTextTags,importPlainTextGroups} from './text-provenance.js';
8
+ import {attachTimelineTags,timelineManifest,importTimelineGroups} from './timeline-provenance.js';
2
9
  import {importImageOrientation} from './image-import.js';
3
10
  import {nativeBackgroundFill} from './background.js';
4
11
  import {importBackground} from './background-import.js';
5
12
  import { webpToPng } from '#image-fallback';
6
13
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
7
- import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
14
+ import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
8
15
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
9
16
  import { unzipSync, zipSync } from "fflate";
10
17
  import { XMLParser } from "fast-xml-parser";
@@ -170,6 +177,13 @@ export async function toPptx(input, options = {}) {
170
177
  context.tableCells = new Map();
171
178
  context.imagePlacements = new Map();
172
179
  context.backgroundFills = new Map();
180
+ context.cardTags = new Map();
181
+ context.headingTags = new Map();
182
+ context.plainTextTags = new Map();
183
+ context.timelineTags = new Map();
184
+ context.codeTags = new Map();
185
+ context.metricTags = new Map();
186
+ context.chartHeadings = new Map();
173
187
  context.imageFormat = options.imageFormat ?? "compatible";
174
188
  const pptx = new PptxGenJS();
175
189
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
@@ -382,10 +396,16 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
382
396
 
383
397
  const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
384
398
  .sort(comparePositionedItems);
385
- const titleItem = takeTitleItem(items, dimensions);
386
- if (titleItem) slide.title = firstLine(titleItem.text);
387
- const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
388
- if (subtitleItem) slide.subtitle = firstLine(subtitleItem.text);
399
+ // Complete OPF heading roles are authoritative. Nearby body lines must not
400
+ // fill an absent role by geometry; explicit native placeholders still apply.
401
+ const inferHeadings = !items.some(item => item.heading||item.sourceText);
402
+ const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
403
+ const tagItem=heading('tag');
404
+ if(tagItem)slide.tag=tagItem.text;
405
+ const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
406
+ if (titleItem) slide.title = titleItem.text;
407
+ const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
408
+ if (subtitleItem) slide.subtitle = subtitleItem.text;
389
409
 
390
410
  const blocks = mergeAdjacentBulletShapes(items)
391
411
  .map((item) => payloadFromSlideItem(item))
@@ -401,9 +421,33 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
401
421
  function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
402
422
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
403
423
  const items = [];
404
-
405
- for (const shape of asArray(tree?.["p:sp"])) {
406
- const item = importShape(shape, dimensions);
424
+ const shapes = nativeTextShapes(tree);
425
+ if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text is retained, but group transforms and non-text group members are not reconstructed; review the reflowed OPF.'});
426
+ const paragraphs = nativeShapeParagraphs(decodeText(entries[slidePath]));
427
+ const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
428
+ const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
429
+ const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
430
+ const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
431
+ const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
432
+ const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
433
+ for(const group of [...headings.items,...plainText.items]) {
434
+ const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
435
+ if(item){
436
+ const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
437
+ if(bounds.length){const x=Math.min(...bounds.map(b=>b.x)),y=Math.min(...bounds.map(b=>b.y));item.bounds={x,y,w:Math.max(...bounds.map(b=>b.x+b.w))-x,h:Math.max(...bounds.map(b=>b.y+b.h))-y};}
438
+ items.push({...item,heading:group.field,sourceText:!group.field});
439
+ }
440
+ }
441
+ for(const group of timelines.items){
442
+ const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
443
+ let union;for(const bound of bounds){if(!union)union={...bound};else{const x=Math.min(union.x,bound.x),y=Math.min(union.y,bound.y);union={x,y,w:Math.max(union.x+union.w,bound.x+bound.w)-x,h:Math.max(union.y+union.h,bound.y+bound.h)-y};}}
444
+ items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload});
445
+ }
446
+ for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
447
+ for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
448
+ for (const [index,shape] of shapes.entries()) {
449
+ if (code.consumed.has(shape)||metric.consumed.has(shape)||cards.has(shape)||headings.consumed.has(shape)||plainText.consumed.has(shape)||timelines.consumed.has(shape)) continue;
450
+ const item = importShape(shape, dimensions, paragraphs[index]);
407
451
  if (item) items.push(item);
408
452
  }
409
453
 
@@ -426,14 +470,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
426
470
  return items;
427
471
  }
428
472
 
429
- function importShape(shape, dimensions) {
430
- const paragraphs = readParagraphs(shape["p:txBody"]);
431
- const text = paragraphs.map((paragraph) => paragraph.text).filter(Boolean).join("\n").trim();
473
+ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"]), allowEmptyText = false) {
474
+ const text = paragraphs.map((paragraph) => paragraph.text).join("\n");
432
475
  const placeholder = shapePlaceholderType(shape);
433
476
  const bounds = shapeBounds(shape["p:spPr"]?.["a:xfrm"]);
434
477
  const name = scalarText(shape["p:nvSpPr"]?.["p:cNvPr"]?.name).trim();
435
478
 
436
- if (text) {
479
+ if (text || allowEmptyText) {
437
480
  return {
438
481
  kind: "text",
439
482
  text,
@@ -544,13 +587,12 @@ function readParagraphs(txBody) {
544
587
  if (Number.isFinite(size)) sizes.push(size / 100);
545
588
  }
546
589
  return {
547
- text: texts.join("").trim(),
590
+ text: texts.join(""),
548
591
  bullet: asArray(paragraph?.["a:pPr"]).some(props => props?.["a:buChar"] !== undefined || props?.["a:buAutoNum"] !== undefined),
549
592
  level: Number(asArray(paragraph?.["a:pPr"])[0]?.lvl ?? 0),
550
593
  maxFontSize: sizes.length > 0 ? Math.max(...sizes) : 0
551
594
  };
552
- })
553
- .filter((paragraph) => paragraph.text);
595
+ });
554
596
  }
555
597
 
556
598
  function shapePlaceholderType(shape) {
@@ -567,13 +609,14 @@ function shapeBounds(xfrm) {
567
609
  return { x, y, w, h };
568
610
  }
569
611
 
570
- function takeTitleItem(items, dimensions) {
612
+ function takeTitleItem(items, dimensions, inferHeadings) {
571
613
  const explicitIndex = items.findIndex((item) => ["title", "ctrTitle"].includes(item.placeholder));
572
614
  if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
615
+ if (!inferHeadings) return null;
573
616
 
574
617
  const titleLimit = dimensions.heightInches * 0.28;
575
618
  const candidateIndex = items.findIndex((item) => {
576
- if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
619
+ if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
577
620
  const y = item.bounds?.y ?? 0;
578
621
  return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
579
622
  });
@@ -581,15 +624,15 @@ function takeTitleItem(items, dimensions) {
581
624
  return null;
582
625
  }
583
626
 
584
- function takeSubtitleItem(items, titleItem, dimensions) {
627
+ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
585
628
  const explicitIndex = items.findIndex((item) => item.placeholder === "subTitle");
586
629
  if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
587
- if (!titleItem) return null;
630
+ if (!inferHeadings || !titleItem) return null;
588
631
 
589
632
  const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
590
633
  const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
591
634
  const candidateIndex = items.findIndex((item) => {
592
- if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
635
+ if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
593
636
  const y = item.bounds?.y ?? 0;
594
637
  const h = item.bounds?.h ?? 0;
595
638
  return y >= titleBottom - 0.05
@@ -624,7 +667,7 @@ function mergeAdjacentBulletShapes(items) {
624
667
  function payloadFromSlideItem(item) {
625
668
  if (item.payload) return item.payload;
626
669
  if (item.kind === "text") {
627
- if (item.paragraphs.length > 1 || item.paragraphs.some(p=>p.bullet)) {
670
+ if (item.paragraphs.some(p=>p.bullet)) {
628
671
  return {
629
672
  type: "list",
630
673
  items: item.paragraphs.map((paragraph) => (
@@ -669,7 +712,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
669
712
  return {
670
713
  type: chartNode.type,
671
714
  data: {
672
- columns: ["Category", ...names],
715
+ columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
673
716
  rows
674
717
  }
675
718
  };
@@ -883,31 +926,47 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
883
926
  const { widthInches, heightInches } = slideContext.dimensions;
884
927
  const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
885
928
  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` });
886
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, textMeasurement: options.textMeasurement });
929
+ const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, contentAlignment:opfSlide.design?.contentAlignment??presentation.design?.contentAlignment, titleAlignment:opfSlide.design?.titleAlignment??presentation.design?.titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement });
887
930
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
888
931
  for (const item of geometry.items) {
889
932
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
890
- if (["title", "subtitle", "tag"].includes(item.field)) {
933
+ if (item.frameBox) {
934
+ const frame=item.frameBox;
935
+ context.cardTags.set(`OPF card ${item.path}`,item.path);
936
+ const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
937
+ const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
938
+ slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
939
+ rectRadius:8*Math.min(widthInches,heightInches)/720,
940
+ fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),pt:.75},objectName:`OPF card ${item.path}`});
941
+ }
942
+ if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
943
+ addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
944
+ } else if (["title", "subtitle", "tag"].includes(item.field)) {
945
+ // Estimated-font headings use one native text box, which still needs a
946
+ // role tag. Role recovery must not depend on outline measurement support.
947
+ const objectName = `OPF heading ${item.path} line 0`;
948
+ context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
891
949
  slide.addText(item.text.lines.join("\n"), {
892
950
  ...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
893
- fontFace: item.textStyle.fontFamily,
894
- bold: item.textStyle.fontWeight >= 600,
895
- italic: item.textStyle.italic,
951
+ ...nativeFontOptions(item.textStyle),
896
952
  color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
953
+ objectName,
897
954
  breakLine: false
898
955
  });
899
956
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
900
957
  addMeasuredList(slide,item.text,slideContext);
901
958
  } else if (item.field === "text" && item.text?.richLines) {
902
- const alignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
903
- for(const line of item.text.richLines){
904
- 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}}));
905
- 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});
959
+ const alignment=item.text.placement?.alignment??opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
960
+ for(const [index,line] of item.text.richLines.entries()){
961
+ const runs=line.fragments.map(fragment=>({text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,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}}));
962
+ const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
963
+ const area=placed?{...region,x:(placed.x+line.width*factor-item.box.width*factor)/96,y:placed.y/96,h:placed.height/96}:{...region,y:region.y+line.y/96,h:line.height/96};
964
+ if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
906
965
  }
907
966
  } else if (item.field === "text" && typeof item.value === "string") {
908
- 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});
967
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle)});
909
968
  } else {
910
- await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout);
969
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: opfSlide.design?.contentAlignment ?? presentation.design?.contentAlignment ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
911
970
  }
912
971
  }
913
972
 
@@ -928,7 +987,7 @@ function fieldToType(field) {
928
987
  return field === "items" || field === "bullets" ? "list" : field;
929
988
  }
930
989
 
931
- async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout) {
990
+ async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout) {
932
991
  const kind = inferPayloadKind(payload);
933
992
  switch (kind) {
934
993
  case "text":
@@ -950,16 +1009,16 @@ async function addPayload(slide, presentation, payload, region, path, context, o
950
1009
  addTablePayload(slide, payload.table, region, context, options, path);
951
1010
  break;
952
1011
  case "code":
953
- addCodePayload(slide, payload.code, region, context, options, path);
1012
+ addCodePayload(slide, payload.code, codeLayout, region, context, path);
954
1013
  break;
955
1014
  case "metric":
956
- addMetricPayload(slide, payload.metric, region, context, options, path);
1015
+ addMetricPayload(slide, payload.metric,metricLayout,context,path);
957
1016
  break;
958
1017
  case "quote":
959
1018
  addQuotePayload(slide, quoteLayout, context, options, path);
960
1019
  break;
961
1020
  case "timeline":
962
- addTimelinePayload(slide, payload.timeline, region, context, options, path);
1021
+ addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
963
1022
  break;
964
1023
  default:
965
1024
  addPlaceholderPayload(slide, "Unsupported OPF payload", payload, region, context);
@@ -989,7 +1048,7 @@ function addTextPayload(slide, value, region, context) {
989
1048
  }
990
1049
 
991
1050
  function richLineRuns(line,color) {
992
- 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}}));
1051
+ return line.fragments.map(fragment=>({text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,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}}));
993
1052
  }
994
1053
  function addMeasuredList(slide,fit,context) {
995
1054
  for(const entry of fit.listEntries){
@@ -1005,7 +1064,7 @@ function addMeasuredList(slide,fit,context) {
1005
1064
  // Keep paragraph intent identical across runs. ZIP normalization below
1006
1065
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
1007
1066
  for(const run of runs)Object.assign(run.options,paragraph);
1008
- slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:entry.marker.style.fontFamily,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
1067
+ slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:nativeFontOptions(entry.marker.style).fontFace,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
1009
1068
  });
1010
1069
  };
1011
1070
  addLines(entry.text,entry.textBox,context.colors.text,true);
@@ -1078,21 +1137,36 @@ function addChartPayload(slide, chart, region, context) {
1078
1137
  return;
1079
1138
  }
1080
1139
 
1140
+ // Keep the resolved palette surface (including alpha) explicit in native
1141
+ // chart/plot areas, so inherited labels are assessed against their own panel.
1142
+ const panelFill = context.colorScheme.surface ?? context.colorScheme[isDarkHex(context.colors.background) ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
1143
+ const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
1144
+ const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
1145
+ const fill = {color:normalizeHex(panelFill),transparency};
1146
+ const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1147
+ const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1148
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
1081
1149
  slide.addChart(chartData.type, chartData.series, {
1150
+ objectName,
1082
1151
  x: region.x,
1083
1152
  y: region.y,
1084
1153
  w: region.w,
1085
1154
  h: region.h,
1086
- showLegend: chartData.series.length > 1,
1155
+ showLegend: circular || chartData.series.length > 1,
1087
1156
  showTitle: false,
1088
- chartColors: CHART_COLORS,
1157
+ chartColors: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
1158
+ chartArea: {fill:{...fill},roundedCorners:false},
1159
+ // Paint alpha once in the chart area, rather than stacking two alpha fills.
1160
+ plotArea: {fill:{color:null}},
1089
1161
  catAxisLabelFontFace: context.fonts.body,
1090
1162
  catAxisLabelFontSize: 9,
1091
- catAxisLabelColor: context.colors.text,
1163
+ catAxisLabelColor: labelColor,
1092
1164
  valAxisLabelFontFace: context.fonts.body,
1093
1165
  valAxisLabelFontSize: 9,
1094
- valAxisLabelColor: context.colors.text,
1095
- legendColor: context.colors.text,
1166
+ valAxisLabelColor: labelColor,
1167
+ legendColor: labelColor,
1168
+ legendFontFace: context.fonts.body,
1169
+ dataLabelColor: labelColor,
1096
1170
  showValue: false,
1097
1171
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1098
1172
  barDir: chartData.barDir,
@@ -1116,8 +1190,8 @@ function addTablePayload(slide, table, region, context, options, path) {
1116
1190
  const rows = layout.rows.map(row => row.cells.map(cell => {
1117
1191
  const {header,rich,fit} = cell;
1118
1192
  const cellStyle = cell.style ?? {};
1119
- const baseColor = (cellStyle.color ?? (header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
1120
1193
  const baseFill = (cellStyle.fill ?? (header ? '#' + context.colors.accent : '#' + context.colors.surface)).replace(/^#/, '');
1194
+ const baseColor = (cellStyle.color ?? textColorForFill('#' + baseFill, header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
1121
1195
  const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
1122
1196
  const text = stringifyText(cell.value), style = cell.textStyle;
1123
1197
  const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
@@ -1128,8 +1202,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1128
1202
  const rawColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color ?? '') ? run.color.slice(1) : baseColor;
1129
1203
  const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1130
1204
  const runOptions = {
1131
- fontFace:runStyle.fontFamily,fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1132
- bold:runStyle.fontWeight >= 600,italic:runStyle.italic,
1205
+ ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1133
1206
  underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1134
1207
  color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
1135
1208
  hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
@@ -1145,13 +1218,12 @@ function addTablePayload(slide, table, region, context, options, path) {
1145
1218
  // soft wraps into the text would change a later import or copy operation.
1146
1219
  text: rich ? runs : text,
1147
1220
  options: {
1148
- fontFace: style.fontFamily,
1221
+ ...nativeFontOptions(style),
1149
1222
  fontSize: fit.fontSize * 0.75,
1150
1223
  // PptxGenJS fills falsy run options from cell defaults. Rich runs
1151
1224
  // carry their resolved weight, so a bold header default must not turn
1152
1225
  // an explicit bold:false run back on.
1153
- bold: rich ? false : style.fontWeight >= 600,
1154
- italic: rich ? false : style.italic,
1226
+ ...(rich ? {bold:false,italic:false} : {}),
1155
1227
  lineSpacing: fit.lineHeight * 0.75,
1156
1228
  paraSpaceAfter: 0,
1157
1229
  align: cellStyle.align ?? context.contentAlignment,
@@ -1194,6 +1266,8 @@ function pixelBox(region) {
1194
1266
  // Match the published renderer's payload geometry and shared core text fitting.
1195
1267
  // Each fitted line remains native editable text, without PowerPoint rewrapping it.
1196
1268
  function addMeasuredPayloadText(slide, text, box, context, options, config) {
1269
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
1270
+ if(invalid)throw new OPFPptxError('invalid-text',`Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`,{path:config.path});
1197
1271
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1198
1272
  const style = config.textStyle ?? resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
1199
1273
  const fit = config.fit ?? fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
@@ -1203,32 +1277,78 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1203
1277
  if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
1204
1278
  }
1205
1279
  for (const [index, line] of fit.lines.entries()) {
1206
- if (!line) continue;
1280
+ if (!line&&!config.heading&&!config.sourceText&&!config.timeline) continue;
1281
+ const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1282
+ const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
1283
+ const objectName=config.heading?`OPF heading ${config.path} line ${index}`:config.timeline?`OPF timeline ${config.timeline.group} part ${config.timeline.part} line ${index}`:config.sourceText?`OPF text ${config.path} line ${index}`:undefined;
1284
+ if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
1285
+ else if(config.timeline)context.timelineTags.set(objectName,{v:1,role:'text',group:config.timeline.group,part:config.timeline.part,line:index,count:fit.lines.length,...boundary,...(config.timeline.part===0&&index===0?{anchor:config.timeline.anchor}:{})});
1286
+ else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1287
+ const area=placed?{x:(placed.x+placed.width*factor-box.width*factor)/96,y:(placed.baseline-fit.fontSize)/96,w:box.width/96,h:placed.height/96}:{x:box.x/96,y:(box.y+index*fit.lineHeight)/96,w:box.width/96,h:fit.lineHeight/96};
1207
1288
  slide.addText(line, {
1208
- ...textBoxOptions({x: box.x / 96, y: (box.y + index * fit.lineHeight) / 96, w: box.width / 96, h: fit.lineHeight / 96}, context, fit.fontSize * .75),
1209
- fontFace: style.fontFamily, bold: style.fontWeight >= 600, italic: style.italic,
1210
- color: normalizeHex(config.color ?? context.colors.text), align: config.align ?? context.contentAlignment ?? 'left',
1289
+ ...textBoxOptions(area, context, fit.fontSize * .75),
1290
+ ...nativeFontOptions(style),
1291
+ color: normalizeHex(config.color ?? context.colors.text), align: alignment,
1292
+ tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
1293
+ objectName,
1211
1294
  fit: 'none', wrap: false, lineSpacingMultiple: 1,
1212
1295
  });
1213
1296
  }
1214
1297
  }
1215
1298
 
1216
- function addCodePayload(slide, value, region, context, options, path) {
1217
- const box = pixelBox(region), code = typeof value === 'string' ? value : value?.source ?? JSON.stringify(value);
1218
- const label = typeof value === 'object' && value?.language ? String(value.language) : 'code';
1219
- slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', pt: .75}});
1220
- // The label has a fixed baseline in the SVG renderer; it does not participate in fitting.
1221
- slide.addText(label.toUpperCase(), {
1222
- ...textBoxOptions({x: (box.x + 18) / 96, y: (box.y + 14) / 96, w: Math.max(1, box.width - 36) / 96, h: 20 / 96}, context, 14 * .75),
1223
- fontFace: context.fonts.code, bold: true, color: '93C5FD', fit: 'none', wrap: false,
1224
- });
1225
- addMeasuredPayloadText(slide, code, {x: box.x + 18, y: box.y + 46, width: box.width - 36, height: box.height - 64}, context, options, {path, fontSize: 18, fontFamily: context.fonts.code, color: 'E5E7EB'});
1299
+ function addCodePayload(slide, value, layout, region, context, path) {
1300
+ if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
1301
+ for (const part of layout.parts) {
1302
+ // XML 1.0 Char excludes controls and unpaired UTF-16 surrogates. The u flag
1303
+ // keeps valid supplementary characters (surrogate pairs) accepted.
1304
+ const invalid = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1305
+ if (invalid) throw new OPFPptxError('invalid-code-text', `Code text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`, {path:part.path});
1306
+ }
1307
+ const group = String(context.codeTags.size + 1), panelName = `OPF code ${group} panel`;
1308
+ for (const part of layout.parts) if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
1309
+ context.codeTags.set(panelName,codeManifest(value,layout,group));
1310
+ slide.addShape('rect', {...region, fill: {color: '111827'}, line: {color: '334155', pt: .75}, objectName:panelName});
1311
+ for (const [partIndex,part] of layout.parts.entries()) {
1312
+ if (!part.fit) throw new OPFPptxError('layout-overflow', 'Code content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
1313
+ for (const [index,line] of part.fit.sourceLines.entries()) {
1314
+ const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
1315
+ const objectName = `OPF code ${group} ${part.role} line ${index+1}`;
1316
+ context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
1317
+ slide.addText(part.text.slice(line.start,line.end),{
1318
+ ...textBoxOptions({x:part.box.x/96,y:(part.box.y+index*part.fit.lineHeight)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
1319
+ ...nativeFontOptions(part.style),
1320
+ color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
1321
+ tabStops:tabStops.length?tabStops:undefined,objectName,
1322
+ });
1323
+ }
1324
+ }
1226
1325
  }
1227
1326
 
1228
- function addMetricPayload(slide, value, region, context, options, path) {
1229
- const metric = isPlainObject(value) ? value : {value}, box = pixelBox(region);
1230
- addMeasuredPayloadText(slide, metric.value, {...box, height: box.height * .45}, context, options, {path: path + '.value', fontSize: Math.min(76, box.height * .28), fontFamily: context.fonts.heading, fontWeight: 800, color: context.colors.accent});
1231
- addMeasuredPayloadText(slide, [metric.label, metric.description, metric.delta].filter(Boolean).join('\n'), {x: box.x, y: box.y + box.height * .45, width: box.width, height: box.height * .55}, context, options, {path, fontSize: 23, fontWeight: 500});
1327
+ function addMetricPayload(slide,value,layout,context,path) {
1328
+ if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
1329
+ const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
1330
+ for (const [partIndex,part] of layout.parts.entries()) {
1331
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1332
+ if (invalid) throw new OPFPptxError('invalid-metric-text',`Metric text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4,'0')} at UTF-16 offset ${invalid.index}, which XML cannot represent; edit that character before exporting.`,{path:part.path});
1333
+ if (!part.visible) continue;
1334
+ if (!part.fit||part.linePositions?.length!==part.fit.sourceLines.length) throw new OPFPptxError('layout-overflow','Metric content has no accepted internal line positions; increase its cell size or coordinate package versions.',{path:part.path,issues:layout.diagnostics});
1335
+ for (const [index,line] of part.fit.sourceLines.entries()) {
1336
+ const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
1337
+ context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
1338
+ const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
1339
+ // Anchor the native paragraph to the same accepted left/center/right point.
1340
+ // Left-aligning at a measured glyph origin loses the intended edge when the
1341
+ // native shaper has a slightly different advance. No re-fitting is needed.
1342
+ const factor=layout.alignment==='right'?1:layout.alignment==='center'?.5:0;
1343
+ const anchor=origin.x+line.width*factor;
1344
+ slide.addText(part.text.slice(line.start,line.end),{
1345
+ ...textBoxOptions({x:(anchor-part.box.width*factor)/96,y:(origin.baseline-part.fit.fontSize)/96,w:part.box.width/96,h:part.fit.lineHeight/96},context,part.fit.fontSize*.75),
1346
+ ...nativeFontOptions(part.style),
1347
+ color:part.role==='value'?context.colors.accent:context.colors.text,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
1348
+ tabStops:tabStops.length?tabStops:undefined,objectName,
1349
+ });
1350
+ }
1351
+ }
1232
1352
  }
1233
1353
 
1234
1354
  function addQuotePayload(slide, layout, context, options, path) {
@@ -1242,20 +1362,23 @@ function addQuotePayload(slide, layout, context, options, path) {
1242
1362
  }
1243
1363
  }
1244
1364
 
1245
- function addTimelinePayload(slide, value, region, context, options, path) {
1246
- const timeline = Array.isArray(value) ? {events: value} : value;
1247
- const events = Array.isArray(timeline?.events) ? timeline.events : [];
1248
- if (!events.length) return;
1249
- const box = pixelBox(region), labelWidth = box.width / Math.max(2, events.length);
1250
- const gap = (box.width - labelWidth) / Math.max(1, events.length - 1);
1251
- const start = events.length === 1 ? box.x + box.width / 2 : box.x + labelWidth / 2;
1252
- const end = events.length === 1 ? start : box.x + box.width - labelWidth / 2;
1253
- const y = box.y + box.height * .46, radius = Math.min(9, labelWidth / 5, box.height * .04);
1254
- slide.addShape('line', {x: start / 96, y: y / 96, w: (end - start) / 96, h: 0, line: {color: context.colors.border, pt: 3 * .75}});
1255
- for (const [index, event] of events.entries()) {
1256
- const x = start + index * gap;
1257
- slide.addShape('ellipse', {x: (x - radius) / 96, y: (y - radius) / 96, w: radius * 2 / 96, h: radius * 2 / 96, fill: {color: context.colors.accent}, line: {transparency: 100}});
1258
- addMeasuredPayloadText(slide, [event.when, event.what, event.description].filter(Boolean).join('\n'), {x: x - labelWidth / 2, y: index % 2 === 0 ? box.y : y + 24, width: labelWidth, height: box.height * .38}, context, options, {path: `${path}.events.${index}`, fontSize: 16, fontWeight: 500, align: 'center'});
1365
+ function addTimelinePayload(slide, value, layout, context, options, path) {
1366
+ if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
1367
+ // Validate optional field fits before the provenance manifest dereferences
1368
+ // source lines or any drawing/tag mutation is performed.
1369
+ for(const part of layout.parts)if(!part.fit)throw new OPFPptxError('layout-overflow','Timeline field has no usable space; change the arrangement or paginate events.',{path:part.path,issues:layout.diagnostics});
1370
+ const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
1371
+ const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
1372
+ const {x1,y1,x2,y2}=layout.connector;
1373
+ slide.addShape('line',{objectName:connectorName,x:x1/96,y:y1/96,w:(x2-x1)/96,h:(y2-y1)/96,line:{color:context.colors.border,pt:3*scale*.75}});
1374
+ context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
1375
+ for(const marker of layout.markers){
1376
+ const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
1377
+ slide.addShape('ellipse',{objectName,x:(marker.x-marker.radius)/96,y:(marker.y-marker.radius)/96,w:marker.radius*2/96,h:marker.radius*2/96,fill:{color:context.colors.accent},line:{transparency:100}});
1378
+ context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
1379
+ }
1380
+ for(const [index,part]of layout.parts.entries()){
1381
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,timeline:{group,part:index,anchor}});
1259
1382
  }
1260
1383
  }
1261
1384
 
@@ -1283,6 +1406,16 @@ function addPlaceholderPayload(slide, label, value, region, context) {
1283
1406
  });
1284
1407
  }
1285
1408
 
1409
+ function nativeFontOptions(style) {
1410
+ const face=style.fontFace;
1411
+ if(face!==undefined) {
1412
+ if(!face || typeof face.family!=='string' || !face.family.trim() || typeof face.bold!=='boolean' || typeof face.italic!=='boolean')
1413
+ throw new OPFPptxError('invalid-font-selection','The font provider must supply a family and explicit bold/italic style-link flags.',{path:style.path});
1414
+ return {fontFace:face.family,bold:face.bold,italic:face.italic};
1415
+ }
1416
+ return {fontFace:style.fontFamily,bold:style.fontWeight>=600,italic:style.italic};
1417
+ }
1418
+
1286
1419
  function textBoxOptions(region, context, fontSize) {
1287
1420
  return {
1288
1421
  x: region.x,
@@ -1553,6 +1686,27 @@ async function normalizePptxZip(raw, context) {
1553
1686
  });
1554
1687
  }
1555
1688
 
1689
+ attachCodeTags(entries, context.codeTags);
1690
+ attachMetricTags(entries,context.metricTags);
1691
+ attachCardTags(entries,context.cardTags);
1692
+ attachHeadingTags(entries,context.headingTags);
1693
+ attachPlainTextTags(entries,context.plainTextTags);
1694
+ attachTimelineTags(entries,context.timelineTags);
1695
+ for(const [part,bytes]of Object.entries(entries)){
1696
+ if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
1697
+ const relationships=parseRelationships(entries,part);
1698
+ for(const [frame]of decodeText(bytes).matchAll(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g)){
1699
+ const name=frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
1700
+ if(!context.chartHeadings.has(name))continue;
1701
+ const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
1702
+ if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
1703
+ const {heading,labelColor}=context.chartHeadings.get(name);
1704
+ if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
1705
+ // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
1706
+ // Normalize only our generated chart text styles; point/series fills stay intact.
1707
+ entries[chartPart]=encodeText(decodeText(entries[chartPart]).replace(/<c:txPr>[\s\S]*?<\/c:txPr>/g,properties=>properties.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g,()=>`<a:solidFill><a:srgbClr val="${labelColor}"/></a:solidFill>`)));
1708
+ }
1709
+ }
1556
1710
  const imageSources = new Map();
1557
1711
  for (const [part, bytes] of Object.entries(entries)) {
1558
1712
  if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
@@ -1628,6 +1782,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1628
1782
  if (isXmlPart(path)) {
1629
1783
  let xml=decodeText(bytes);
1630
1784
  if (path === '[Content_Types].xml') {
1785
+ // PptxGenJS 4.0.1 emits one slide-master override per slide even
1786
+ // though it creates only the actual master parts. Omit phantom master
1787
+ // declarations without changing any existing part or relationship.
1788
+ xml = xml.replace(/<Override PartName="\/(ppt\/slideMasters\/slideMaster\d+\.xml)"[^>]*\/>/g,
1789
+ (override, part) => Object.hasOwn(entries, part) ? override : '');
1631
1790
  // Explicit per-part types also correct PptxGenJS's image/jpg default.
1632
1791
  const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
1633
1792
  `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');