@openpresentation/opf-pptx 0.7.0 → 0.8.1

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,11 +1,18 @@
1
1
  import {importTableFrames} from './table-import.js';
2
+ import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
2
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';
9
+ import {attachFurnitureTags, furnitureManifest, importFurniture} from './furniture-provenance.js';
3
10
  import {importImageOrientation} from './image-import.js';
4
11
  import {nativeBackgroundFill} from './background.js';
5
12
  import {importBackground} from './background-import.js';
6
13
  import { webpToPng } from '#image-fallback';
7
14
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
8
- import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle } from "@openpresentation/opf/composition";
15
+ import { layoutTable, composeSlide, fitText, fitRichText, textWidthMeasurer, resolveCanvasDimensions, resolveFontFamilies, resolveTextStyle, textColorForFill, chartColorForFill } from "@openpresentation/opf/composition";
9
16
  import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
10
17
  import { unzipSync, zipSync } from "fflate";
11
18
  import { XMLParser } from "fast-xml-parser";
@@ -171,7 +178,15 @@ export async function toPptx(input, options = {}) {
171
178
  context.tableCells = new Map();
172
179
  context.imagePlacements = new Map();
173
180
  context.backgroundFills = new Map();
181
+ context.cardTags = new Map();
182
+ context.headingTags = new Map();
183
+ context.plainTextTags = new Map();
184
+ context.timelineTags = new Map();
185
+ context.furnitureTags = new Map();
186
+ context.furnitureManifests = new Map();
174
187
  context.codeTags = new Map();
188
+ context.metricTags = new Map();
189
+ context.chartHeadings = new Map();
175
190
  context.imageFormat = options.imageFormat ?? "compatible";
176
191
  const pptx = new PptxGenJS();
177
192
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
@@ -221,8 +236,21 @@ export async function fromPptx(input, options = {}) {
221
236
  if (core.author) imported.author = core.author;
222
237
  if (dimensions) imported.design = { dimensions };
223
238
 
239
+ const furnitureContexts = slidePaths.map((slidePath, index) => {
240
+ const root = parseRequiredXml(entries, slidePath)['p:sld'];
241
+ if (!root) throw new OPFPptxError('invalid-pptx', `PPTX slide is not a PresentationML slide: ${slidePath}.`, {path: slidePath});
242
+ const tree = root['p:cSld']?.['p:spTree'], relationships = parseRelationships(entries, slidePath);
243
+ return {root, relationships, shapes: nativeTextShapes(tree), pictures: nativePictures(tree),
244
+ paragraphs: nativeShapeParagraphs(decodeText(entries[slidePath])),
245
+ readPicture: picture => importPicture(entries, picture, slidePath, relationships,
246
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${index}.design`}))};
247
+ });
248
+ const furniture = importFurniture(furnitureContexts, entries, options.onDiagnostic);
249
+ if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
250
+ if (furniture.organization) imported.organization = furniture.organization;
251
+
224
252
  for (let index = 0; index < slidePaths.length; index += 1) {
225
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options));
253
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
226
254
  }
227
255
 
228
256
  const result = validatePresentation(imported);
@@ -363,16 +391,9 @@ function dimensionsFromPresentation(presentationRoot) {
363
391
  };
364
392
  }
365
393
 
366
- function importSlide(entries, slidePath, slideIndex, presentationDimensions, options) {
367
- const doc = parseRequiredXml(entries, slidePath);
368
- const slideRoot = doc["p:sld"];
369
- if (!slideRoot) {
370
- throw new OPFPptxError("invalid-pptx", `PPTX slide is not a PresentationML slide: ${slidePath}.`, {
371
- path: slidePath
372
- });
373
- }
374
-
375
- const relationships = parseRelationships(entries, slidePath);
394
+ function importSlide(entries, slidePath, slideIndex, presentationDimensions, options, furniture, nativeContext) {
395
+ const slideRoot = nativeContext.root;
396
+ const relationships = nativeContext.relationships;
376
397
  const dimensions = presentationDimensions ?? DIMENSION_PRESETS.widescreen;
377
398
  const slide = {};
378
399
  if (slideRoot.show === "0") slide.hidden = true;
@@ -381,12 +402,20 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
381
402
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
382
403
  }, diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.design.background`}));
383
404
  if (background) slide.design = {background};
405
+ if (Object.keys(furniture.design).length) slide.design = {...slide.design, ...furniture.design};
406
+ if (furniture.section !== undefined) slide.section = furniture.section;
384
407
 
385
- const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex)
408
+ const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
386
409
  .sort(comparePositionedItems);
387
- const titleItem = takeTitleItem(items, dimensions);
410
+ // Complete OPF heading roles are authoritative. Nearby body lines must not
411
+ // fill an absent role by geometry; explicit native placeholders still apply.
412
+ const inferHeadings = !items.some(item => item.heading||item.sourceText);
413
+ const heading = field => {const index=items.findIndex(item=>item.heading===field);return index<0?null:items.splice(index,1)[0];};
414
+ const tagItem=heading('tag');
415
+ if(tagItem)slide.tag=tagItem.text;
416
+ const titleItem = heading('title')??takeTitleItem(items, dimensions, inferHeadings);
388
417
  if (titleItem) slide.title = titleItem.text;
389
- const subtitleItem = takeSubtitleItem(items, titleItem, dimensions);
418
+ const subtitleItem = heading('subtitle')??takeSubtitleItem(items, titleItem, dimensions, inferHeadings);
390
419
  if (subtitleItem) slide.subtitle = subtitleItem.text;
391
420
 
392
421
  const blocks = mergeAdjacentBulletShapes(items)
@@ -400,17 +429,42 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
400
429
  return slide;
401
430
  }
402
431
 
403
- function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex) {
432
+ const nativePictures = tree => [...asArray(tree?.['p:pic']), ...asArray(tree?.['p:grpSp']).flatMap(nativePictures)];
433
+
434
+ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext) {
404
435
  const tree = slideRoot["p:cSld"]?.["p:spTree"];
405
436
  const items = [];
406
- const shapes = nativeTextShapes(tree);
407
- 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.'});
408
- const paragraphs = nativeShapeParagraphs(decodeText(entries[slidePath]));
437
+ const shapes = nativeContext.shapes;
438
+ if (tree?.['p:grpSp']) options.onDiagnostic?.({code:'grouped-text-reflow',path:`slides.${slideIndex}`,message:'Grouped native text and pictures are retained, but group transforms and unsupported group members are not reconstructed; review the reflowed OPF.'});
439
+ const paragraphs = nativeContext.paragraphs;
409
440
  const code = importCodeGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.code`}));
441
+ const metric = importMetricGroups(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}.metric`}));
442
+ const cards = importCardFrames(shapes, paragraphs, relationships, entries, diagnostic => options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
443
+ const headings=importHeadingGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
444
+ const plainText=importPlainTextGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
445
+ const timelines=importTimelineGroups(shapes,paragraphs,relationships,entries,diagnostic=>options.onDiagnostic?.({...diagnostic,path:`slides.${slideIndex}`}));
446
+ for(const group of [...headings.items,...plainText.items]) {
447
+ const item=importShape(group.shapes[0],dimensions,group.paragraphs,true);
448
+ if(item){
449
+ const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
450
+ 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};}
451
+ items.push({...item,heading:group.field,sourceText:!group.field});
452
+ }
453
+ }
454
+ for(const group of timelines.items){
455
+ const bounds=group.shapes.map(shape=>shapeBounds(shape['p:spPr']?.['a:xfrm'])).filter(Boolean);
456
+ 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};}}
457
+ items.push({kind:'timeline',sourceText:true,bounds:union,payload:group.payload});
458
+ }
410
459
  for (const item of code.items) items.push({kind:'code',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
460
+ for (const item of metric.items) items.push({kind:'metric',bounds:shapeBounds(item.shape['p:spPr']?.['a:xfrm']),payload:item.payload});
411
461
  for (const [index,shape] of shapes.entries()) {
412
- if (code.consumed.has(shape)) continue;
413
- const item = importShape(shape, dimensions, paragraphs[index]);
462
+ if (furniture.text.has(index)) continue;
463
+ 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;
464
+ const item = importShape(shape, dimensions, paragraphs[index], furniture.taggedText.has(index));
465
+ // A damaged/edited furniture group falls back to current native text,
466
+ // including cleared text boxes, without inventing a title or shape label.
467
+ if (item && furniture.taggedText.has(index)) item.sourceText = true;
414
468
  if (item) items.push(item);
415
469
  }
416
470
 
@@ -424,7 +478,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
424
478
  if (item) items.push(item);
425
479
  }
426
480
 
427
- for (const [index, picture] of asArray(tree?.["p:pic"]).entries()) {
481
+ for (const [index, picture] of nativeContext.pictures.entries()) {
482
+ if (furniture.pictures.has(index)) continue;
428
483
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
429
484
  const item = importPicture(entries, picture, slidePath, relationships, report);
430
485
  if (item) items.push(item);
@@ -433,13 +488,13 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
433
488
  return items;
434
489
  }
435
490
 
436
- function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"])) {
491
+ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txBody"]), allowEmptyText = false) {
437
492
  const text = paragraphs.map((paragraph) => paragraph.text).join("\n");
438
493
  const placeholder = shapePlaceholderType(shape);
439
494
  const bounds = shapeBounds(shape["p:spPr"]?.["a:xfrm"]);
440
495
  const name = scalarText(shape["p:nvSpPr"]?.["p:cNvPr"]?.name).trim();
441
496
 
442
- if (text) {
497
+ if (text || allowEmptyText) {
443
498
  return {
444
499
  kind: "text",
445
500
  text,
@@ -501,7 +556,7 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
501
556
  function importPicture(entries, picture, slidePath, relationships, report) {
502
557
  const bounds = shapeBounds(picture["p:spPr"]?.["a:xfrm"]);
503
558
  const name = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.name).trim();
504
- const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr).trim();
559
+ const alt = scalarText(picture["p:nvPicPr"]?.["p:cNvPr"]?.descr);
505
560
  const relId = picture["p:blipFill"]?.["a:blip"]?.["r:embed"];
506
561
  const relationship = relationships.get(relId);
507
562
  let bytes = relationship?.path ? entries[relationship.path] : null;
@@ -572,13 +627,14 @@ function shapeBounds(xfrm) {
572
627
  return { x, y, w, h };
573
628
  }
574
629
 
575
- function takeTitleItem(items, dimensions) {
630
+ function takeTitleItem(items, dimensions, inferHeadings) {
576
631
  const explicitIndex = items.findIndex((item) => ["title", "ctrTitle"].includes(item.placeholder));
577
632
  if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
633
+ if (!inferHeadings) return null;
578
634
 
579
635
  const titleLimit = dimensions.heightInches * 0.28;
580
636
  const candidateIndex = items.findIndex((item) => {
581
- if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
637
+ if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
582
638
  const y = item.bounds?.y ?? 0;
583
639
  return y <= titleLimit && (item.maxFontSize >= 20 || /^title\b/i.test(item.name ?? ""));
584
640
  });
@@ -586,15 +642,15 @@ function takeTitleItem(items, dimensions) {
586
642
  return null;
587
643
  }
588
644
 
589
- function takeSubtitleItem(items, titleItem, dimensions) {
645
+ function takeSubtitleItem(items, titleItem, dimensions, inferHeadings) {
590
646
  const explicitIndex = items.findIndex((item) => item.placeholder === "subTitle");
591
647
  if (explicitIndex >= 0) return items.splice(explicitIndex, 1)[0];
592
- if (!titleItem) return null;
648
+ if (!inferHeadings || !titleItem) return null;
593
649
 
594
650
  const titleBottom = (titleItem.bounds?.y ?? 0) + (titleItem.bounds?.h ?? 0);
595
651
  const subtitleLimit = Math.min(dimensions.heightInches * 0.34, 1.45);
596
652
  const candidateIndex = items.findIndex((item) => {
597
- if (item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
653
+ if (item.heading || item.kind !== "text" || !item.text || item.paragraphs.some(p=>p.bullet)) return false;
598
654
  const y = item.bounds?.y ?? 0;
599
655
  const h = item.bounds?.h ?? 0;
600
656
  return y >= titleBottom - 0.05
@@ -674,7 +730,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
674
730
  return {
675
731
  type: chartNode.type,
676
732
  data: {
677
- columns: ["Category", ...names],
733
+ columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
678
734
  rows
679
735
  }
680
736
  };
@@ -888,31 +944,48 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
888
944
  const { widthInches, heightInches } = slideContext.dimensions;
889
945
  const layout = resolveCatalogRecord(presentation, "layouts", opfSlide.layout, "blank") ?? {};
890
946
  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` });
891
- const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, slideIndex, fonts: slideContext.fonts, textMeasurement: options.textMeasurement });
947
+ const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, 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 });
892
948
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
949
+ await addFurniture(slide,presentation,opfSlide,geometry.furniture,slideContext,options,slideIndex);
893
950
  for (const item of geometry.items) {
894
951
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
895
- if (["title", "subtitle", "tag"].includes(item.field)) {
952
+ if (item.frameBox) {
953
+ const frame=item.frameBox;
954
+ context.cardTags.set(`OPF card ${item.path}`,item.path);
955
+ const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
956
+ const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
957
+ slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
958
+ rectRadius:8*Math.min(widthInches,heightInches)/720,
959
+ fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),pt:.75},objectName:`OPF card ${item.path}`});
960
+ }
961
+ if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
962
+ 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});
963
+ } else if (["title", "subtitle", "tag"].includes(item.field)) {
964
+ // Estimated-font headings use one native text box, which still needs a
965
+ // role tag. Role recovery must not depend on outline measurement support.
966
+ const objectName = `OPF heading ${item.path} line 0`;
967
+ context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
896
968
  slide.addText(item.text.lines.join("\n"), {
897
969
  ...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
898
- fontFace: item.textStyle.fontFamily,
899
- bold: item.textStyle.fontWeight >= 600,
900
- italic: item.textStyle.italic,
970
+ ...nativeFontOptions(item.textStyle),
901
971
  color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
972
+ objectName,
902
973
  breakLine: false
903
974
  });
904
975
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
905
976
  addMeasuredList(slide,item.text,slideContext);
906
977
  } else if (item.field === "text" && item.text?.richLines) {
907
- const alignment=opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
908
- for(const line of item.text.richLines){
909
- 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}}));
910
- 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});
978
+ const alignment=item.text.placement?.alignment??opfSlide.design?.contentAlignment??presentation.design?.contentAlignment??'left';
979
+ for(const [index,line] of item.text.richLines.entries()){
980
+ 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}}));
981
+ const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
982
+ 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};
983
+ if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
911
984
  }
912
985
  } else if (item.field === "text" && typeof item.value === "string") {
913
- 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});
986
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle)});
914
987
  } else {
915
- 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);
988
+ 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);
916
989
  }
917
990
  }
918
991
 
@@ -933,7 +1006,7 @@ function fieldToType(field) {
933
1006
  return field === "items" || field === "bullets" ? "list" : field;
934
1007
  }
935
1008
 
936
- async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout) {
1009
+ async function addPayload(slide, presentation, payload, region, path, context, options, quoteLayout, codeLayout,metricLayout,timelineLayout) {
937
1010
  const kind = inferPayloadKind(payload);
938
1011
  switch (kind) {
939
1012
  case "text":
@@ -958,13 +1031,13 @@ async function addPayload(slide, presentation, payload, region, path, context, o
958
1031
  addCodePayload(slide, payload.code, codeLayout, region, context, path);
959
1032
  break;
960
1033
  case "metric":
961
- addMetricPayload(slide, payload.metric, region, context, options, path);
1034
+ addMetricPayload(slide, payload.metric,metricLayout,context,path);
962
1035
  break;
963
1036
  case "quote":
964
1037
  addQuotePayload(slide, quoteLayout, context, options, path);
965
1038
  break;
966
1039
  case "timeline":
967
- addTimelinePayload(slide, payload.timeline, region, context, options, path);
1040
+ addTimelinePayload(slide, payload.timeline, timelineLayout, context, options, path);
968
1041
  break;
969
1042
  default:
970
1043
  addPlaceholderPayload(slide, "Unsupported OPF payload", payload, region, context);
@@ -994,7 +1067,7 @@ function addTextPayload(slide, value, region, context) {
994
1067
  }
995
1068
 
996
1069
  function richLineRuns(line,color) {
997
- 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}}));
1070
+ 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}}));
998
1071
  }
999
1072
  function addMeasuredList(slide,fit,context) {
1000
1073
  for(const entry of fit.listEntries){
@@ -1010,7 +1083,7 @@ function addMeasuredList(slide,fit,context) {
1010
1083
  // Keep paragraph intent identical across runs. ZIP normalization below
1011
1084
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
1012
1085
  for(const run of runs)Object.assign(run.options,paragraph);
1013
- slide.addText(runs,{...textBoxOptions(region,context,text.fontSize*.75),fontFace:entry.marker.style.fontFamily,objectName,align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,...paragraph});
1086
+ 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});
1014
1087
  });
1015
1088
  };
1016
1089
  addLines(entry.text,entry.textBox,context.colors.text,true);
@@ -1074,6 +1147,7 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1074
1147
  h: region.h,
1075
1148
  altText: assetAlt(asset, presentation)
1076
1149
  });
1150
+ return objectName;
1077
1151
  }
1078
1152
 
1079
1153
  function addChartPayload(slide, chart, region, context) {
@@ -1083,21 +1157,36 @@ function addChartPayload(slide, chart, region, context) {
1083
1157
  return;
1084
1158
  }
1085
1159
 
1160
+ // Keep the resolved palette surface (including alpha) explicit in native
1161
+ // chart/plot areas, so inherited labels are assessed against their own panel.
1162
+ const panelFill = context.colorScheme.surface ?? context.colorScheme[isDarkHex(context.colors.background) ? 'dark2' : 'light2'] ?? `#${context.colors.surface}`;
1163
+ const labelColor = normalizeHex(textColorForFill(panelFill, `#${context.colors.text}`));
1164
+ const transparency = /^#[0-9a-f]{8}$/i.test(panelFill) ? (1 - parseInt(panelFill.slice(7), 16) / 255) * 100 : 0;
1165
+ const fill = {color:normalizeHex(panelFill),transparency};
1166
+ const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1167
+ const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1168
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chart.data.columns[0],labelColor});
1086
1169
  slide.addChart(chartData.type, chartData.series, {
1170
+ objectName,
1087
1171
  x: region.x,
1088
1172
  y: region.y,
1089
1173
  w: region.w,
1090
1174
  h: region.h,
1091
- showLegend: chartData.series.length > 1,
1175
+ showLegend: circular || chartData.series.length > 1,
1092
1176
  showTitle: false,
1093
- chartColors: CHART_COLORS,
1177
+ chartColors: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
1178
+ chartArea: {fill:{...fill},roundedCorners:false},
1179
+ // Paint alpha once in the chart area, rather than stacking two alpha fills.
1180
+ plotArea: {fill:{color:null}},
1094
1181
  catAxisLabelFontFace: context.fonts.body,
1095
1182
  catAxisLabelFontSize: 9,
1096
- catAxisLabelColor: context.colors.text,
1183
+ catAxisLabelColor: labelColor,
1097
1184
  valAxisLabelFontFace: context.fonts.body,
1098
1185
  valAxisLabelFontSize: 9,
1099
- valAxisLabelColor: context.colors.text,
1100
- legendColor: context.colors.text,
1186
+ valAxisLabelColor: labelColor,
1187
+ legendColor: labelColor,
1188
+ legendFontFace: context.fonts.body,
1189
+ dataLabelColor: labelColor,
1101
1190
  showValue: false,
1102
1191
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1103
1192
  barDir: chartData.barDir,
@@ -1121,8 +1210,8 @@ function addTablePayload(slide, table, region, context, options, path) {
1121
1210
  const rows = layout.rows.map(row => row.cells.map(cell => {
1122
1211
  const {header,rich,fit} = cell;
1123
1212
  const cellStyle = cell.style ?? {};
1124
- const baseColor = (cellStyle.color ?? (header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
1125
1213
  const baseFill = (cellStyle.fill ?? (header ? '#' + context.colors.accent : '#' + context.colors.surface)).replace(/^#/, '');
1214
+ const baseColor = (cellStyle.color ?? textColorForFill('#' + baseFill, header ? '#FFFFFF' : '#' + context.colors.text)).replace(/^#/, '');
1126
1215
  const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
1127
1216
  const text = stringifyText(cell.value), style = cell.textStyle;
1128
1217
  const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
@@ -1133,8 +1222,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1133
1222
  const rawColor = /^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(run.color ?? '') ? run.color.slice(1) : baseColor;
1134
1223
  const color = normalizeHex(rawColor), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1135
1224
  const runOptions = {
1136
- fontFace:runStyle.fontFamily,fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1137
- bold:runStyle.fontWeight >= 600,italic:runStyle.italic,
1225
+ ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1138
1226
  underline:run.underline ? {color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
1139
1227
  color,transparency,baseline:fragment?.baselineShift ? -fragment.baselineShift / fragment.fontSize * 2000 : undefined,
1140
1228
  hyperlink:run.link && /^(https?:|mailto:)/i.test(run.link) ? {url:run.link} : undefined,
@@ -1150,13 +1238,12 @@ function addTablePayload(slide, table, region, context, options, path) {
1150
1238
  // soft wraps into the text would change a later import or copy operation.
1151
1239
  text: rich ? runs : text,
1152
1240
  options: {
1153
- fontFace: style.fontFamily,
1241
+ ...nativeFontOptions(style),
1154
1242
  fontSize: fit.fontSize * 0.75,
1155
1243
  // PptxGenJS fills falsy run options from cell defaults. Rich runs
1156
1244
  // carry their resolved weight, so a bold header default must not turn
1157
1245
  // an explicit bold:false run back on.
1158
- bold: rich ? false : style.fontWeight >= 600,
1159
- italic: rich ? false : style.italic,
1246
+ ...(rich ? {bold:false,italic:false} : {}),
1160
1247
  lineSpacing: fit.lineHeight * 0.75,
1161
1248
  paraSpaceAfter: 0,
1162
1249
  align: cellStyle.align ?? context.contentAlignment,
@@ -1199,6 +1286,8 @@ function pixelBox(region) {
1199
1286
  // Match the published renderer's payload geometry and shared core text fitting.
1200
1287
  // Each fitted line remains native editable text, without PowerPoint rewrapping it.
1201
1288
  function addMeasuredPayloadText(slide, text, box, context, options, config) {
1289
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(String(text??''));
1290
+ 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});
1202
1291
  const scale = Math.min(context.dimensions.widthInches, context.dimensions.heightInches) * 96 / 720;
1203
1292
  const style = config.textStyle ?? resolveTextStyle({fontFamily: config.fontFamily ?? context.fonts.body, fontWeight: config.fontWeight ?? 400, path: config.path}, options.textMeasurement);
1204
1293
  const fit = config.fit ?? fitText(String(text ?? ''), box, config.fontSize * scale, (context.composition?.minFontSize ?? 16) * scale, textWidthMeasurer(style, options.textMeasurement));
@@ -1208,16 +1297,47 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1208
1297
  if (context.composition?.overflow === 'error') throw new OPFPptxError('layout-overflow', diagnostic.message, {path: config.path, issues: [diagnostic]});
1209
1298
  }
1210
1299
  for (const [index, line] of fit.lines.entries()) {
1211
- if (!line) continue;
1300
+ if (!line&&!config.heading&&!config.sourceText&&!config.timeline&&!config.keepEmpty) continue;
1301
+ const alignment=fit.placement?.alignment??config.align??context.contentAlignment??'left',placed=fit.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1302
+ const sourceLine=fit.sourceLines?.[index],boundary=sourceLine?{boundary:sourceLine.boundary,separator:String(text).slice(sourceLine.end,sourceLine.nextStart)}:{};
1303
+ 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}`:config.objectName?`${config.objectName} line ${index}`:undefined;
1304
+ if(config.heading)context.headingTags.set(objectName,{v:1,group:config.path,field:config.heading,line:index,count:fit.lines.length,...boundary});
1305
+ 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}:{})});
1306
+ else if(config.furniture)context.furnitureTags.set(objectName,{v:1,role:'text',...config.furniture,line:index,count:fit.lines.length,...boundary});
1307
+ else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1308
+ 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};
1212
1309
  slide.addText(line, {
1213
- ...textBoxOptions({x: box.x / 96, y: (box.y + index * fit.lineHeight) / 96, w: box.width / 96, h: fit.lineHeight / 96}, context, fit.fontSize * .75),
1214
- fontFace: style.fontFamily, bold: style.fontWeight >= 600, italic: style.italic,
1215
- color: normalizeHex(config.color ?? context.colors.text), align: config.align ?? context.contentAlignment ?? 'left',
1310
+ ...textBoxOptions(area, context, fit.fontSize * .75),
1311
+ ...nativeFontOptions(style),
1312
+ color: normalizeHex(config.color ?? context.colors.text), align: alignment,
1313
+ tabStops:sourceLine?.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'})),
1314
+ objectName,
1216
1315
  fit: 'none', wrap: false, lineSpacingMultiple: 1,
1217
1316
  });
1218
1317
  }
1219
1318
  }
1220
1319
 
1320
+ async function addFurniture(slide,presentation,source,layout,context,options,slideIndex) {
1321
+ if(!layout){
1322
+ if(['header','footer'].some(kind=>(source.design?.[kind]??presentation.design?.[kind])))throw new OPFPptxError('missing-furniture-layout','Header/footer export requires coordinated core furniture geometry.',{path:`slides.${slideIndex}.design`});
1323
+ const manifest = furnitureManifest(presentation, source, {parts: []}, slideIndex);
1324
+ if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1325
+ return;
1326
+ }
1327
+ for(const [index,part]of layout.parts.entries()){
1328
+ if(part.type==='image'){
1329
+ const region={x:part.box.x/96,y:part.box.y/96,w:part.box.width/96,h:part.box.height/96};
1330
+ const objectName = await addImagePayload(slide,presentation,part.image,region,part.path,{...context,imageFill:'fit'},options);
1331
+ if (objectName) context.furnitureTags.set(objectName, {v:1, role:'image', group:String(slideIndex), part:index});
1332
+ }else{
1333
+ if(!part.fit?.sourceLines)throw new OPFPptxError('missing-furniture-layout','Repeated text requires accepted source lines from core.',{path:part.path});
1334
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.colors.mutedText,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index}});
1335
+ }
1336
+ }
1337
+ const manifest = furnitureManifest(presentation, source, layout, slideIndex);
1338
+ if (manifest) context.furnitureManifests.set(`ppt/slides/slide${slideIndex + 1}.xml`, manifest);
1339
+ }
1340
+
1221
1341
  function addCodePayload(slide, value, layout, region, context, path) {
1222
1342
  if (!layout) throw new OPFPptxError('missing-code-layout', 'Code export requires a coordinated core build with shared code geometry.', {path});
1223
1343
  for (const part of layout.parts) {
@@ -1238,7 +1358,7 @@ function addCodePayload(slide, value, layout, region, context, path) {
1238
1358
  context.codeTags.set(objectName,{v:1,group,role:'line',part:partIndex,line:index});
1239
1359
  slide.addText(part.text.slice(line.start,line.end),{
1240
1360
  ...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),
1241
- fontFace:part.style.fontFamily,bold:part.style.fontWeight>=600,italic:part.style.italic,
1361
+ ...nativeFontOptions(part.style),
1242
1362
  color:part.role==='body'?'E5E7EB':'93C5FD',align:'left',fit:'none',wrap:false,lineSpacingMultiple:1,
1243
1363
  tabStops:tabStops.length?tabStops:undefined,objectName,
1244
1364
  });
@@ -1246,10 +1366,31 @@ function addCodePayload(slide, value, layout, region, context, path) {
1246
1366
  }
1247
1367
  }
1248
1368
 
1249
- function addMetricPayload(slide, value, region, context, options, path) {
1250
- const metric = isPlainObject(value) ? value : {value}, box = pixelBox(region);
1251
- 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});
1252
- 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});
1369
+ function addMetricPayload(slide,value,layout,context,path) {
1370
+ if (!layout) throw new OPFPptxError('missing-metric-layout','Metric export requires coordinated core metric geometry.',{path});
1371
+ const group=String(context.metricTags.size+1),manifest=metricManifest(value,layout,group);
1372
+ for (const [partIndex,part] of layout.parts.entries()) {
1373
+ const invalid=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF\uFFFE\uFFFF]/u.exec(part.text);
1374
+ 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});
1375
+ if (!part.visible) continue;
1376
+ 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});
1377
+ for (const [index,line] of part.fit.sourceLines.entries()) {
1378
+ const origin=part.linePositions[index],objectName=`OPF metric ${group} ${part.role} line ${index+1}`;
1379
+ context.metricTags.set(objectName,partIndex===0&&index===0?manifest:{v:1,group,role:'line',part:partIndex,line:index});
1380
+ const tabStops=line.segments.filter(segment=>segment.kind==='tab').map(segment=>({position:(segment.x+segment.width)/96,alignment:'l'}));
1381
+ // Anchor the native paragraph to the same accepted left/center/right point.
1382
+ // Left-aligning at a measured glyph origin loses the intended edge when the
1383
+ // native shaper has a slightly different advance. No re-fitting is needed.
1384
+ const factor=layout.alignment==='right'?1:layout.alignment==='center'?.5:0;
1385
+ const anchor=origin.x+line.width*factor;
1386
+ slide.addText(part.text.slice(line.start,line.end),{
1387
+ ...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),
1388
+ ...nativeFontOptions(part.style),
1389
+ color:part.role==='value'?context.colors.accent:context.colors.text,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
1390
+ tabStops:tabStops.length?tabStops:undefined,objectName,
1391
+ });
1392
+ }
1393
+ }
1253
1394
  }
1254
1395
 
1255
1396
  function addQuotePayload(slide, layout, context, options, path) {
@@ -1263,20 +1404,23 @@ function addQuotePayload(slide, layout, context, options, path) {
1263
1404
  }
1264
1405
  }
1265
1406
 
1266
- function addTimelinePayload(slide, value, region, context, options, path) {
1267
- const timeline = Array.isArray(value) ? {events: value} : value;
1268
- const events = Array.isArray(timeline?.events) ? timeline.events : [];
1269
- if (!events.length) return;
1270
- const box = pixelBox(region), labelWidth = box.width / Math.max(2, events.length);
1271
- const gap = (box.width - labelWidth) / Math.max(1, events.length - 1);
1272
- const start = events.length === 1 ? box.x + box.width / 2 : box.x + labelWidth / 2;
1273
- const end = events.length === 1 ? start : box.x + box.width - labelWidth / 2;
1274
- const y = box.y + box.height * .46, radius = Math.min(9, labelWidth / 5, box.height * .04);
1275
- slide.addShape('line', {x: start / 96, y: y / 96, w: (end - start) / 96, h: 0, line: {color: context.colors.border, pt: 3 * .75}});
1276
- for (const [index, event] of events.entries()) {
1277
- const x = start + index * gap;
1278
- 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}});
1279
- 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'});
1407
+ function addTimelinePayload(slide, value, layout, context, options, path) {
1408
+ if(!layout)throw new OPFPptxError('missing-timeline-layout','Timeline export requires a coordinated core build with shared timeline geometry.',{path});
1409
+ // Validate optional field fits before the provenance manifest dereferences
1410
+ // source lines or any drawing/tag mutation is performed.
1411
+ 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});
1412
+ const scale=Math.min(context.dimensions.widthInches,context.dimensions.heightInches)*96/720;
1413
+ const group=String(context.timelineTags.size),anchor=timelineManifest(value,layout),connectorName=`OPF timeline ${group} connector`;
1414
+ const {x1,y1,x2,y2}=layout.connector;
1415
+ 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}});
1416
+ context.timelineTags.set(connectorName,{v:1,group,role:'connector'});
1417
+ for(const marker of layout.markers){
1418
+ const objectName=`OPF timeline ${group} marker ${marker.eventIndex}`;
1419
+ 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}});
1420
+ context.timelineTags.set(objectName,{v:1,group,role:'marker',eventIndex:marker.eventIndex});
1421
+ }
1422
+ for(const [index,part]of layout.parts.entries()){
1423
+ 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}});
1280
1424
  }
1281
1425
  }
1282
1426
 
@@ -1304,6 +1448,16 @@ function addPlaceholderPayload(slide, label, value, region, context) {
1304
1448
  });
1305
1449
  }
1306
1450
 
1451
+ function nativeFontOptions(style) {
1452
+ const face=style.fontFace;
1453
+ if(face!==undefined) {
1454
+ if(!face || typeof face.family!=='string' || !face.family.trim() || typeof face.bold!=='boolean' || typeof face.italic!=='boolean')
1455
+ throw new OPFPptxError('invalid-font-selection','The font provider must supply a family and explicit bold/italic style-link flags.',{path:style.path});
1456
+ return {fontFace:face.family,bold:face.bold,italic:face.italic};
1457
+ }
1458
+ return {fontFace:style.fontFamily,bold:style.fontWeight>=600,italic:style.italic};
1459
+ }
1460
+
1307
1461
  function textBoxOptions(region, context, fontSize) {
1308
1462
  return {
1309
1463
  x: region.x,
@@ -1575,6 +1729,27 @@ async function normalizePptxZip(raw, context) {
1575
1729
  }
1576
1730
 
1577
1731
  attachCodeTags(entries, context.codeTags);
1732
+ attachMetricTags(entries,context.metricTags);
1733
+ attachCardTags(entries,context.cardTags);
1734
+ attachHeadingTags(entries,context.headingTags);
1735
+ attachPlainTextTags(entries,context.plainTextTags);
1736
+ attachTimelineTags(entries,context.timelineTags);
1737
+ attachFurnitureTags(entries,context.furnitureTags,context.furnitureManifests);
1738
+ for(const [part,bytes]of Object.entries(entries)){
1739
+ if(!/^ppt\/slides\/slide\d+\.xml$/.test(part))continue;
1740
+ const relationships=parseRelationships(entries,part);
1741
+ for(const [frame]of decodeText(bytes).matchAll(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g)){
1742
+ const name=frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
1743
+ if(!context.chartHeadings.has(name))continue;
1744
+ const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
1745
+ if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
1746
+ const {heading,labelColor}=context.chartHeadings.get(name);
1747
+ if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
1748
+ // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
1749
+ // Normalize only our generated chart text styles; point/series fills stay intact.
1750
+ 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>`)));
1751
+ }
1752
+ }
1578
1753
  const imageSources = new Map();
1579
1754
  for (const [part, bytes] of Object.entries(entries)) {
1580
1755
  if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
@@ -1650,6 +1825,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
1650
1825
  if (isXmlPart(path)) {
1651
1826
  let xml=decodeText(bytes);
1652
1827
  if (path === '[Content_Types].xml') {
1828
+ // PptxGenJS 4.0.1 emits one slide-master override per slide even
1829
+ // though it creates only the actual master parts. Omit phantom master
1830
+ // declarations without changing any existing part or relationship.
1831
+ xml = xml.replace(/<Override PartName="\/(ppt\/slideMasters\/slideMaster\d+\.xml)"[^>]*\/>/g,
1832
+ (override, part) => Object.hasOwn(entries, part) ? override : '');
1653
1833
  // Explicit per-part types also correct PptxGenJS's image/jpg default.
1654
1834
  const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
1655
1835
  `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');