@openpresentation/opf-pptx 0.11.1 → 0.11.3

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
@@ -3,6 +3,8 @@ import {isFaceStyleSuffix} from './font-weights.js';
3
3
  import {importTableFrames} from './table-import.js';
4
4
  import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
5
5
  import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
6
+ import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
7
+ import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
6
8
  import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
7
9
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
8
10
  import {attachCardTags,importCardFrames} from './card-provenance.js';
@@ -19,7 +21,7 @@ import {dedupeMedia} from './media-dedupe.js';
19
21
  import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
20
22
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
21
23
  import {importBackground} from './background-import.js';
22
- import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
24
+ import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
23
25
  import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
24
26
  import { webpToPng } from '#image-fallback';
25
27
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
@@ -223,7 +225,14 @@ export async function toPptx(input, options = {}) {
223
225
  context.metricTags = new Map();
224
226
  context.chartHeadings = new Map();
225
227
  context.chartFonts = new Map();
228
+ context.chartex = new Map();
226
229
  context.imageFormat = options.imageFormat ?? "compatible";
230
+ // Native chartex parts are opt-in until the native PowerPoint check confirms them (FF-22b); the default keeps the
231
+ // clustered column export of the chartex chart types.
232
+ if (options.chartex !== undefined && !['native', 'fallback'].includes(options.chartex)) {
233
+ throw new OPFPptxError('invalid-chartex-mode', 'chartex must be native or fallback.', {path: 'options.chartex'});
234
+ }
235
+ context.chartexMode = options.chartex ?? 'fallback';
227
236
  Object.assign(context, exportTheme(presentation, context));
228
237
  context.reportedFontSchemes = new Set();
229
238
  // Document references and metadata tags (FF-32, docs/document-roundtrip.md).
@@ -233,6 +242,8 @@ export async function toPptx(input, options = {}) {
233
242
  report: diagnostic => options.onDiagnostic?.(diagnostic)
234
243
  });
235
244
  context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
245
+ context.masterBackground = masterBackground(context);
246
+ context.linkSentinels = linkSentinels(presentation);
236
247
  const pptx = new PptxGenJS();
237
248
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
238
249
 
@@ -610,6 +621,15 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
610
621
  const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
611
622
  if (item) items.push(item);
612
623
  }
624
+ // A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
625
+ // (this exporter) or a text shape (PowerPoint). The choice is read first and the fallback only when it names no OPF chart type.
626
+ for (const alternate of asArray(tree?.["mc:AlternateContent"])) {
627
+ const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
628
+ const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
629
+ const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
630
+ const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
631
+ if (item && item.kind !== "unknown") items.push(item);
632
+ }
613
633
 
614
634
  for (const [index, picture] of nativeContext.pictures.entries()) {
615
635
  if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
@@ -665,9 +685,12 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
665
685
  };
666
686
  }
667
687
 
668
- const chartRelId = graphicData?.["c:chart"]?.["r:id"];
688
+ const chartExRelId = graphicData?.uri === CHARTEX_GRAPHIC_DATA_URI ? graphicData?.["cx:chart"]?.["r:id"] : undefined;
689
+ const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
669
690
  if (chartRelId) {
670
- const chart = chartFromRelationship(entries, slidePath, relationships, chartRelId);
691
+ const chart = chartExRelId
692
+ ? chartexFromRelationship(entries, relationships, chartExRelId)
693
+ : chartFromRelationship(entries, slidePath, relationships, chartRelId);
671
694
  return {
672
695
  kind: "chart",
673
696
  bounds,
@@ -851,6 +874,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
851
874
 
852
875
  const budget = { cells: 0 };
853
876
  const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
877
+ if (chartNode.type === 'scatter') return scatterFromSeries(entries, relationship.path, series, budget, cachePath);
854
878
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
855
879
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
856
880
  const values = series.map((entry, index) => {
@@ -880,19 +904,48 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
880
904
  };
881
905
  }
882
906
 
907
+ // Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
908
+ // them category-major: [point label, X, Y1, Y2, ...]; native charts carry no
909
+ // point labels, so points are numbered.
910
+ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
911
+ // The same cache helpers as every other chart: bounded, indexed by c:pt@idx, with gaps kept as null (never plotted as 0).
912
+ const xRole = series[0]?.["c:xVal"];
913
+ const xs = cachedValues(xRole, cachePath(0, "c:xVal"), budget, numericCacheValue);
914
+ const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, "c:tx"), budget) ?? `Series ${index + 1}`);
915
+ const values = series.map((entry, index) => cachedValues(entry?.["c:yVal"], cachePath(index, "c:yVal"), budget, numericCacheValue));
916
+ const rowCount = values.reduce((count, row) => Math.max(count, row.length), xs.length);
917
+ if (rowCount === 0) return null;
918
+ if (rowCount * (series.length + 2) > MAX_CHART_CACHE_CELLS) {
919
+ throw new OPFPptxError('invalid-chart-cache', 'Chart cache exceeds the 1,000,000-cell import limit; reduce its rows or series before importing.', {path: chartPart});
920
+ }
921
+ const rows = [];
922
+ // A chart without c:xVal plots against 1..n natively; a c:xVal gap stays a gap.
923
+ for (let index = 0; index < rowCount; index += 1) rows.push([String(index + 1), xRole === undefined ? index + 1 : xs[index] ?? null, ...values.map((row) => row[index] ?? null)]);
924
+ return { type: "scatter", data: { columns: ["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], rows } };
925
+ }
926
+
927
+ // A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
928
+ // type and the cached dimensions restore the category-major data, under the
929
+ // same bounds as the classic chart cache.
930
+ function chartexFromRelationship(entries, relationships, relId) {
931
+ const relationship = relationships.get(relId);
932
+ if (!relationship?.path || !entries[relationship.path]) return null;
933
+ const doc = parseRequiredXml(entries, relationship.path);
934
+ return chartFromChartex(doc, {
935
+ heading: readChartCategoryHeading(entries, relationship.path),
936
+ limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
937
+ path: relationship.path,
938
+ invalid: (message, location) => {
939
+ throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
940
+ }
941
+ });
942
+ }
943
+
883
944
  function firstChartNode(plotArea) {
884
- const candidates = [
885
- ["c:barChart", (node) => node?.["c:barDir"]?.val === "bar" ? "bar" : "column"],
886
- ["c:lineChart", () => "line"],
887
- ["c:pieChart", () => "pie"],
888
- ["c:doughnutChart", () => "doughnut"],
889
- ["c:areaChart", () => "area"],
890
- ["c:scatterChart", () => "scatter"],
891
- ["c:radarChart", () => "radar"]
892
- ];
893
- for (const [key, type] of candidates) {
894
- const node = asArray(plotArea[key])[0];
895
- if (node) return { node, type: type(node) };
945
+ // Map the native construct back to the kept OPF chart type id (FF-22).
946
+ for (const element of NATIVE_CHART_ELEMENTS) {
947
+ const node = asArray(plotArea[`c:${element}`])[0];
948
+ if (node) return { node, type: chartTypeFromNative(element, node) };
896
949
  }
897
950
  return null;
898
951
  }
@@ -1133,8 +1186,48 @@ function exportColor(entry, context, fallback) {
1133
1186
  // PptxGenJS color for a document color reference: a theme scheme value when the
1134
1187
  // reference names a scheme slot or role that the deck theme holds exactly,
1135
1188
  // otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
1136
- function nativeColor(reference, hex, context) {
1137
- return schemeColorValue(reference, hex, context, {vendor: true}) ?? normalizeHex(hex);
1189
+ function nativeColor(reference, hex, context, fallback) {
1190
+ if (fallback !== undefined && (reference === undefined || reference === null || reference === '')) return pptxColor(fallback);
1191
+ const value = schemeColorValue(reference, hex, context, {vendor: true});
1192
+ if (value) return value;
1193
+ // PptxGenJS cannot write hlink or folHlink: a reserved literal stands in and
1194
+ // is rewritten to the scheme color in the finished slide part.
1195
+ const link = schemeColorValue(reference, hex, context);
1196
+ return (link && context.linkSentinels?.[link]) ?? normalizeHex(hex);
1197
+ }
1198
+
1199
+ // FF-24c: two literals no document color uses stand in for hlink and folHlink
1200
+ // (see nativeColor). When the document uses every candidate, those colors stay literal.
1201
+ const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
1202
+ function linkSentinels(presentation) {
1203
+ const text = JSON.stringify(presentation).toUpperCase();
1204
+ const pair = LINK_SENTINELS.find(hexes => hexes.every(hex => !text.includes(hex)));
1205
+ return pair ? {hlink: pair[0], folHlink: pair[1]} : {};
1206
+ }
1207
+
1208
+ function writeLinkSentinels(xml, sentinels) {
1209
+ const values = Object.entries(sentinels ?? {});
1210
+ if (!values.length) return xml;
1211
+ return xml.replace(/<a:srgbClr val="([0-9A-F]{6})"(\/>|>[\s\S]*?<\/a:srgbClr>)/g, (node, hex, rest) => {
1212
+ const link = values.find(([, value]) => value === hex)?.[0];
1213
+ return link ? `<a:schemeClr val="${link}"${rest.replace(/<\/a:srgbClr>$/, '</a:schemeClr>')}` : node;
1214
+ });
1215
+ }
1216
+
1217
+ // PptxGenJS color option: a scheme value it can emit (tx1, bg2, ...) or RRGGBB.
1218
+ function pptxColor(value) {
1219
+ return /^(?:tx[12]|bg[12])$/.test(value) ? value : normalizeHex(value);
1220
+ }
1221
+
1222
+ // Deck-level theme background for the slide master, so slides added in
1223
+ // PowerPoint match. The vendored layout already uses bg1, so only a theme
1224
+ // background on another slot (dark themes, light2, accents) changes the master.
1225
+ function masterBackground(context) {
1226
+ const deck = {...context, schemeOverride: false};
1227
+ const value = schemeBackgroundValue(deck.backgroundDefinition, deck);
1228
+ const fill = schemeBackgroundFill(deck.backgroundDefinition, deck);
1229
+ if (!value || value === 'bg1' || !fill || fill.includes('<a:alpha')) return null;
1230
+ return {fill, text: defaultTextSchemeValues(deck).text ?? normalizeHex(context.colors.text)};
1138
1231
  }
1139
1232
 
1140
1233
  // The theme part carries the deck-level scheme (slide overrides stay literal)
@@ -1225,7 +1318,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1225
1318
  options.onDiagnostic?.({code: 'unresolved-asset', path: imagePath, message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
1226
1319
  }
1227
1320
  }
1228
- slide.color = slideContext.colors.text;
1321
+ slide.color = slideContext.textColor;
1229
1322
  if (opfSlide.hidden === true) slide.hidden = true;
1230
1323
 
1231
1324
  const { widthInches, heightInches } = slideContext.dimensions;
@@ -1247,49 +1340,51 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1247
1340
  if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
1248
1341
  await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
1249
1342
  for (const item of geometry.items) {
1343
+ // Card text sits on a literal card fill, not the slide background: keep it literal.
1344
+ const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
1250
1345
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
1251
1346
  if (item.frameBox) {
1252
1347
  const frame=item.frameBox;
1253
1348
  context.cardTags.set(`OPF card ${item.path}`,item.path);
1254
1349
  const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
1255
- const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
1350
+ const surface=itemContext.colorScheme[isDarkHex(itemContext.colors.background)?'dark2':'light2']??`#${itemContext.colors.surface}`;
1256
1351
  slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
1257
1352
  rectRadius:8*Math.min(widthInches,heightInches)/720,
1258
- fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
1353
+ fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
1259
1354
  }
1260
1355
  if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
1261
- addMeasuredPayloadText(slide,item.value,item.box,slideContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:alignmentFor(item),diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?slideContext.colors.accent:slideContext.colors.text});
1356
+ addMeasuredPayloadText(slide,item.value,item.box,itemContext,options,{path:item.path,fit:item.text,textStyle:item.textStyle,sourceText:item.field==='text'&&!!item.text.sourceLines,align:alignmentFor(item),diagnosticsHandled:true,heading:['title','subtitle','tag'].includes(item.field)?item.field:undefined,color:item.field==='tag'?itemContext.colors.accent:itemContext.textColor});
1262
1357
  } else if (["title", "subtitle", "tag"].includes(item.field)) {
1263
1358
  // Estimated-font headings use one native text box, which still needs a
1264
1359
  // role tag. Role recovery must not depend on outline measurement support.
1265
1360
  const objectName = `OPF heading ${item.path} line 0`;
1266
1361
  context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
1267
1362
  slide.addText(item.text.lines.join("\n"), {
1268
- ...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
1363
+ ...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
1269
1364
  ...nativeFontOptions(item.textStyle),
1270
- color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
1365
+ color: item.field === "tag" ? itemContext.colors.accent : itemContext.textColor,
1271
1366
  align: alignmentFor(item),
1272
1367
  objectName,
1273
1368
  breakLine: false
1274
1369
  });
1275
1370
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1276
- addMeasuredList(slide,item.text,slideContext,item.path);
1371
+ addMeasuredList(slide,item.text,itemContext,item.path);
1277
1372
  } else if (item.field === "text" && item.text?.richLines) {
1278
1373
  const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1279
1374
  for(const [index,line] of item.text.richLines.entries()){
1280
1375
  const runs=line.fragments.map(fragment=>{
1281
- const runColor=exportColor(fragment.run.color,slideContext,slideContext.colors.text);
1282
- const color=nativeColor(fragment.run.color,runColor,slideContext);
1376
+ const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
1377
+ const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
1283
1378
  return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',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}};
1284
1379
  });
1285
1380
  const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1286
1381
  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};
1287
- if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1382
+ if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1288
1383
  }
1289
1384
  } else if (item.field === "text" && typeof item.value === "string") {
1290
- slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
1385
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
1291
1386
  } else {
1292
- await addPayload(slide, presentation, item.payload, region, item.path, { ...slideContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
1387
+ await addPayload(slide, presentation, item.payload, region, item.path, { ...itemContext, composition: item.composition, contentAlignment: alignmentFor(item) ?? "left" }, options, item.quoteLayout, item.codeLayout,item.metricLayout,item.timelineLayout);
1293
1388
  }
1294
1389
  }
1295
1390
  // Core composes furniture above all content and opf-render paints it last, so
@@ -1321,7 +1416,17 @@ function resolveSlideContext(presentation, slide, baseContext, options, slideInd
1321
1416
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
1322
1417
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
1323
1418
  }
1324
- return { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
1419
+ const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
1420
+ // A slide-level color scheme (directly or through a slide theme) pins every
1421
+ // named color on that slide to literal sRGB; see schemeColorValue().
1422
+ const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
1423
+ slideContext.schemeOverride = slide.design?.colorScheme !== undefined
1424
+ || Object.keys({...slideScheme, ...deckScheme}).some(slot => slideScheme[slot] !== deckScheme[slot]);
1425
+ // Default and muted text follow the theme only where the background does.
1426
+ const text = defaultTextSchemeValues(slideContext);
1427
+ slideContext.textColor = text.text ?? resolved.colors.text;
1428
+ slideContext.mutedColor = text.muted ?? resolved.colors.mutedText;
1429
+ return slideContext;
1325
1430
  }
1326
1431
 
1327
1432
  function fieldToType(field) {
@@ -1389,10 +1494,10 @@ function addTextPayload(slide, value, region, context) {
1389
1494
  slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
1390
1495
  }
1391
1496
 
1392
- function richLineRuns(line,color,context) {
1497
+ function richLineRuns(line,color,native,context) {
1393
1498
  const fallback=color.replace(/^#/,'');
1394
1499
  return line.fragments.map(fragment=>{
1395
- const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
1500
+ const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
1396
1501
  return {text:fragment.text,options:{...nativeFontOptions(fragment.style),fontSize:fragment.fontSize*.75,color,underline:fragment.run.underline?{style:'sng',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}};
1397
1502
  });
1398
1503
  }
@@ -1404,14 +1509,14 @@ function richLineRuns(line,color,context) {
1404
1509
  function addMeasuredList(slide,fit,context,path) {
1405
1510
  let lineNumber=0;
1406
1511
  for(const entry of fit.listEntries){
1407
- const addLines=(text,box,color,withBullet)=>{
1512
+ const addLines=(text,box,color,native,withBullet)=>{
1408
1513
  text.richLines.forEach((line,index)=>{
1409
1514
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1410
1515
  const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1411
1516
  const objectName=`OPF list ${path} line ${lineNumber++}`;
1412
- if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
1517
+ if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor)});
1413
1518
  const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1414
- const runs=richLineRuns(line,color,context);
1519
+ const runs=richLineRuns(line,color,native,context);
1415
1520
  if(!runs.length)runs.push({text:'',options:{}});
1416
1521
  // Keep paragraph intent identical across runs. ZIP normalization below
1417
1522
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
@@ -1419,8 +1524,8 @@ function addMeasuredList(slide,fit,context,path) {
1419
1524
  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});
1420
1525
  });
1421
1526
  };
1422
- addLines(entry.text,entry.textBox,context.colors.text,true);
1423
- if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,false);
1527
+ addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
1528
+ if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,context.mutedColor,false);
1424
1529
  }
1425
1530
  }
1426
1531
 
@@ -1440,7 +1545,7 @@ function addListPayload(slide, items, region, context) {
1440
1545
  options: {
1441
1546
  bullet: { type: "bullet", indent: 14 + level * 14 },
1442
1547
  breakLine: index < list.length - 1 || Boolean(isPlainObject(item) && item.description),
1443
- color: context.colors.text,
1548
+ color: context.textColor,
1444
1549
  fontFace: context.fonts.body,
1445
1550
  fontSize: 15,
1446
1551
  hanging: 4 + level * 10
@@ -1451,7 +1556,7 @@ function addListPayload(slide, items, region, context) {
1451
1556
  text: stringifyText(item.description),
1452
1557
  options: {
1453
1558
  breakLine: index < list.length - 1,
1454
- color: context.colors.mutedText,
1559
+ color: context.mutedColor,
1455
1560
  fontFace: context.fonts.body,
1456
1561
  fontSize: 11,
1457
1562
  margin: [0, 0, 0, 18 + level * 14]
@@ -1546,14 +1651,14 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
1546
1651
  }
1547
1652
 
1548
1653
  function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
1549
- const chartData = toPptxChartData(chart);
1654
+ const chartData = toPptxChartData(chart, context.chartexMode);
1550
1655
  if (!chartData.series) {
1551
1656
  // Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
1552
1657
  options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
1553
1658
  addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
1554
1659
  return;
1555
1660
  }
1556
- if (chartData.adapted) options.onDiagnostic?.({code: "chart-data-adapted", path, message: chartData.message, adaptation: chartData.adapted});
1661
+ for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
1557
1662
 
1558
1663
  // Keep the resolved palette surface (including alpha) explicit in native
1559
1664
  // chart/plot areas, so inherited labels are assessed against their own panel.
@@ -1563,17 +1668,28 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1563
1668
  const fill = {color:normalizeHex(panelFill),transparency};
1564
1669
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1565
1670
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1566
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
1671
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec});
1567
1672
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1673
+ const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
1674
+ if (chartData.chartex) {
1675
+ // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
1676
+ // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
1677
+ const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
1678
+ context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette});
1679
+ if (chartData.chartex.layoutId === 'regionMap') {
1680
+ options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography: PowerPoint matches the category names to regions and fetches their shapes from Bing Maps when the deck is opened online, so the map shows no regions offline or where Office map data is disabled. The clustered column fallback shows the same values in other readers.`});
1681
+ }
1682
+ }
1683
+ const percent = chartData.spec.grouping === 'percentStacked';
1568
1684
  slide.addChart(chartData.type, chartData.series, {
1569
1685
  objectName,
1570
1686
  x: region.x,
1571
1687
  y: region.y,
1572
1688
  w: region.w,
1573
1689
  h: region.h,
1574
- showLegend: circular || chartData.series.length > 1,
1690
+ showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
1575
1691
  showTitle: false,
1576
- chartColors: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
1692
+ chartColors: palette,
1577
1693
  chartArea: {fill:{...fill},roundedCorners:false},
1578
1694
  // Paint alpha once in the chart area, rather than stacking two alpha fills.
1579
1695
  plotArea: {fill:{color:null}},
@@ -1589,7 +1705,12 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1589
1705
  showValue: false,
1590
1706
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1591
1707
  barDir: chartData.barDir,
1592
- barGrouping: chartData.barGrouping
1708
+ barGrouping: chartData.barGrouping,
1709
+ ...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
1710
+ ...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
1711
+ ...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
1712
+ // ScatterWithMarkers: markers only, no connecting line.
1713
+ ...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
1593
1714
  });
1594
1715
  }
1595
1716
 
@@ -1612,8 +1733,13 @@ function addTablePayload(slide, table, region, context, options, path) {
1612
1733
  const cellStyle = cell.style ?? {};
1613
1734
  const defaultFillHex = header ? context.colors.accent : context.colors.surface;
1614
1735
  const baseFill = exportColor(cellStyle.fill ?? defaultFillHex, context, defaultFillHex);
1736
+ // FF-24c: engine chrome resolves from scheme roles (header accent1, body surface), so it follows the deck theme like a named fill.
1737
+ const fillReference = cellStyle.fill ?? (header ? 'accent1' : 'surface');
1738
+ const fillValue = schemeColorValue(fillReference, baseFill, context);
1615
1739
  const inheritedText = textColorForFill(`#${baseFill}`, header ? "#FFFFFF" : `#${context.colors.text}`);
1616
1740
  const baseColor = exportColor(cellStyle.color ?? inheritedText.replace(/^#/, ""), context, inheritedText.replace(/^#/, ""));
1741
+ // Default cell text follows the theme only with a theme-referenced fill: paired slot on a light or dark fill, light1/dark1 on an accent fill.
1742
+ const defaultText = cellStyle.color === undefined ? tableTextSchemeValue(fillValue, baseColor, context) : undefined;
1617
1743
  const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
1618
1744
  const text = stringifyText(cell.value), style = cell.textStyle;
1619
1745
  const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
@@ -1622,7 +1748,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1622
1748
  const fragment = fragments.find(item => item.runIndex === index);
1623
1749
  const runStyle = fragment?.style ?? resolveTextStyle({...style,fontFamily:run.fontFamily ?? style.fontFamily,fontWeight:run.bold === undefined ? style.fontWeight : run.bold ? 700 : 400,italic:run.italic ?? style.italic}, options.textMeasurement);
1624
1750
  const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
1625
- const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1751
+ const color = nativeColor(run.color !== undefined && run.color !== '' ? run.color : cellStyle.color, rawColor, context, defaultText), transparency = rawColor.length === 8 ? (1 - parseInt(rawColor.slice(6), 16) / 255) * 100 : 0;
1626
1752
  const runOptions = {
1627
1753
  ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1628
1754
  underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
@@ -1652,17 +1778,17 @@ function addTablePayload(slide, table, region, context, options, path) {
1652
1778
  valign: cellStyle.verticalAlign ?? 'top',
1653
1779
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
1654
1780
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
1655
- color: nativeColor(cellStyle.color, baseColor, context),
1781
+ color: nativeColor(cellStyle.color, baseColor, context, defaultText),
1656
1782
  // Rich runs carry resolved alpha. A translucent cell default would
1657
1783
  // overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
1658
1784
  ...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
1659
- fill: { color: nativeColor(cellStyle.fill, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1785
+ fill: { color: nativeColor(fillReference, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1660
1786
  },
1661
1787
  };
1662
1788
  }));
1663
1789
  const objectName = `OPF table ${context.tableHeaders.size + 1}`;
1664
1790
  context.tableHeaders.set(objectName, hasHeaders);
1665
- if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, defaultBorder:{color:"#"+context.colors.border,width:1/scale}});
1791
+ if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color:"accent5",width:1/scale}});
1666
1792
  slide.addTable(rows, {
1667
1793
  objectName,
1668
1794
  x: region.x,
@@ -1678,7 +1804,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1678
1804
  fontFace: context.fonts.body,
1679
1805
  fontSize: 15 * scale * 0.75,
1680
1806
  color: context.colors.text,
1681
- border: { type: "solid", color: context.colors.border, pt: 0.75 },
1807
+ border: { type: "solid", color: nativeColor("accent5", context.colors.border, context), pt: 0.75 },
1682
1808
  margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
1683
1809
  valign: "top"
1684
1810
  });
@@ -1720,7 +1846,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1720
1846
  else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1721
1847
  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};
1722
1848
  // A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
1723
- const lineColor = normalizeHex(config.color ?? context.colors.text);
1849
+ const lineColor = pptxColor(config.color ?? context.textColor);
1724
1850
  slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
1725
1851
  ...textBoxOptions(area, context, fit.fontSize * .75),
1726
1852
  ...nativeFontOptions(style),
@@ -1768,7 +1894,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1768
1894
  if(marker) staticDates.set(index,marker);
1769
1895
  }
1770
1896
  }
1771
- 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},liveFields,links:part.links});
1897
+ addMeasuredPayloadText(slide,part.text,part.box,context,options,{path:part.path,fit:part.fit,textStyle:part.style,align:part.alignment,diagnosticsHandled:true,color:context.mutedColor,keepEmpty:true,objectName:`OPF furniture ${slideIndex} part ${index}`,furniture:{group:String(slideIndex),part:index},liveFields,links:part.links});
1772
1898
  }
1773
1899
  }
1774
1900
  const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
@@ -1823,7 +1949,7 @@ function addMetricPayload(slide,value,layout,context,path) {
1823
1949
  slide.addText(part.text.slice(line.start,line.end),{
1824
1950
  ...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),
1825
1951
  ...nativeFontOptions(part.style),
1826
- color:part.role==='value'?context.colors.accent:context.colors.text,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
1952
+ color:part.role==='value'?context.colors.accent:context.textColor,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
1827
1953
  tabStops:tabStops.length?tabStops:undefined,objectName,
1828
1954
  });
1829
1955
  }
@@ -1836,7 +1962,7 @@ function addQuotePayload(slide, layout, context, options, path) {
1836
1962
  if (!part.fit) throw new OPFPptxError('layout-overflow', 'Quote content has no usable internal space; increase its cell size before exporting.', {path:part.path,issues:layout.diagnostics});
1837
1963
  addMeasuredPayloadText(slide,part.text,part.box,context,options,{
1838
1964
  path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
1839
- color:part.role==='footer'?context.colors.mutedText:context.colors.text,
1965
+ color:part.role==='footer'?context.mutedColor:context.textColor,
1840
1966
  });
1841
1967
  }
1842
1968
  }
@@ -1972,7 +2098,7 @@ function addPlaceholderPayload(slide, label, description, region, context) {
1972
2098
  margin: 0,
1973
2099
  fontFace: context.fonts.body,
1974
2100
  fontSize: 11,
1975
- color: context.colors.mutedText,
2101
+ color: context.mutedColor,
1976
2102
  fit: "shrink",
1977
2103
  valign: "mid",
1978
2104
  align: "center"
@@ -2040,7 +2166,7 @@ function textBoxOptions(region, context, fontSize) {
2040
2166
  lineSpacingMultiple: 1.22,
2041
2167
  fontFace: context.fonts.body,
2042
2168
  fontSize,
2043
- color: context.colors.text,
2169
+ color: context.textColor,
2044
2170
  breakLine: false,
2045
2171
  fit: "shrink",
2046
2172
  valign: "top"
@@ -2054,7 +2180,7 @@ function textRuns(value, context, fallbackFontSize) {
2054
2180
  return {
2055
2181
  text: run,
2056
2182
  options: {
2057
- color: context.colors.text,
2183
+ color: context.textColor,
2058
2184
  fontFace: context.fonts.body,
2059
2185
  fontSize: fallbackFontSize
2060
2186
  }
@@ -2065,9 +2191,9 @@ function textRuns(value, context, fallbackFontSize) {
2065
2191
  options: {
2066
2192
  bold: run?.bold,
2067
2193
  italic: run?.italic,
2068
- underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context) } : undefined,
2194
+ underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
2069
2195
  strike: run?.strikethrough ? "sngStrike" : undefined,
2070
- color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context),
2196
+ color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
2071
2197
  fontFace: run?.fontFamily ?? context.fonts.body,
2072
2198
  fontSize: run?.fontSize ?? fallbackFontSize,
2073
2199
  superscript: run?.superscript,
@@ -2085,7 +2211,7 @@ function textRuns(value, context, fallbackFontSize) {
2085
2211
  * into equal-width bins and exported as a column chart of the counts, every other type plots the values against
2086
2212
  * their row numbers. `adapted` names that transformation so it is reported, never silent.
2087
2213
  */
2088
- function toPptxChartData(chart) {
2214
+ function toPptxChartData(chart, chartexMode = 'fallback') {
2089
2215
  const data = chart?.data;
2090
2216
  const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
2091
2217
  if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
@@ -2093,7 +2219,20 @@ function toPptxChartData(chart) {
2093
2219
  }
2094
2220
  if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2095
2221
  if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
2096
- const mapped = mapChartType(chart.type);
2222
+ const resolved = resolveChartType(chart.type).spec;
2223
+ // A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. With
2224
+ // `chartex: 'native'` it is written as its cx:chartSpace part and the clustered column chart of the same data is its
2225
+ // mc:Fallback (src/chartex.js); by default (pending the native PowerPoint check, FF-22b) it exports as the clustered
2226
+ // column chart alone and reports chartex-fallback, never silently.
2227
+ const native = chartexMode === 'native';
2228
+ const chartex = resolved.family === 'chartex' && native ? resolved : null;
2229
+ const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
2230
+ const typeName = stringifyText(chart.type);
2231
+ const adaptations = [];
2232
+ if (resolved.family === 'chartex' && !native) {
2233
+ adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart that this exporter does not write; its data is exported as a native clustered column chart instead.`});
2234
+ }
2235
+ const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
2097
2236
  if (data.columns.length === 1) {
2098
2237
  const heading = stringifyText(data.columns[0]);
2099
2238
  const cell = (row) => Array.isArray(row) ? row[0] : row;
@@ -2104,13 +2243,15 @@ function toPptxChartData(chart) {
2104
2243
  }
2105
2244
  const skipped = data.rows.length - points.length;
2106
2245
  const skippedNote = skipped ? ` (${skipped} non-numeric ${skipped === 1 ? "cell was" : "cells were"} skipped)` : "";
2107
- if (String(chart.type ?? "").toLowerCase() === "histogram") {
2246
+ if (!native && String(chart.type ?? "").toLowerCase() === "histogram") {
2108
2247
  const bins = histogramBins(points.map((point) => point.value));
2109
2248
  return {
2110
- type: "bar", barDir: "col", barGrouping: "clustered", heading: "Bin",
2249
+ type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin",
2111
2250
  series: [{name: "Frequency", labels: bins.map((bin) => bin.label), values: bins.map((bin) => bin.count)}],
2112
- adapted: "histogram-binned",
2113
- message: `The histogram's single data column '${heading}' (${points.length} values${skippedNote}) was binned into ${bins.length} equal-width bins and exported as a column chart of the counts; PowerPoint's own histogram chart is not exported, and the binned counts do not restore the raw values on re-import.`
2251
+ adaptations: [{
2252
+ adaptation: "histogram-binned",
2253
+ message: `The histogram's single data column '${heading}' (${points.length} values${skippedNote}) was binned into ${bins.length} equal-width bins and exported as a column chart of the counts; PowerPoint's own histogram chart is not exported, and the binned counts do not restore the raw values on re-import.`
2254
+ }]
2114
2255
  };
2115
2256
  }
2116
2257
  // Rows keep their own row numbers, so a skipped cell leaves a gap in the numbering.
@@ -2119,26 +2260,39 @@ function toPptxChartData(chart) {
2119
2260
  const series = mapped.type === "scatter"
2120
2261
  ? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
2121
2262
  : [{name: heading, labels, values: numbers}];
2122
- return {
2123
- ...mapped, series, heading: "Row", adapted: "row-numbers",
2124
- message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`
2125
- };
2263
+ // A native histogram or Pareto chart bins the values themselves (PowerPoint's automatic bins); only its classic fallback plots them against row numbers.
2264
+ if (chartex?.binning) return {...mapped, series, heading: "Row", hasCategories: false, adaptations};
2265
+ adaptations.push({adaptation: "row-numbers", message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`});
2266
+ return {...mapped, series, heading: "Row", hasCategories: true, adaptations};
2126
2267
  }
2127
2268
 
2128
2269
  const labels = data.rows.map((row) => stringifyText(row?.[0]));
2129
- const series = data.columns.slice(1).map((name, seriesIndex) => ({
2270
+ let series = data.columns.slice(1).map((name, seriesIndex) => ({
2130
2271
  name: stringifyText(name),
2131
2272
  labels,
2132
2273
  values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
2133
2274
  }));
2134
-
2135
- return { ...mapped, series };
2275
+ const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
2276
+ if (series.length > plotted) {
2277
+ // A pie, doughnut or single-series chartex construct plots one series; name the ones left out instead of dropping them silently.
2278
+ const dropped = series.slice(plotted).map((entry) => `'${entry.name}'`);
2279
+ adaptations.push({adaptation: "series-dropped", message: `The ${typeName} chart plots one series, so its first series '${series[0].name}' is exported and the other ${dropped.length} (${dropped.join(", ")}) ${dropped.length === 1 ? "is" : "are"} not.`});
2280
+ series = series.slice(0, plotted);
2281
+ }
2282
+ if (spec.family === 'xy' && series.length === 1) {
2283
+ // [Point, X, Y...] carries its own X column; a lone value column is plotted against the row numbers.
2284
+ series = [{name: 'X', labels, values: data.rows.map((_, index) => index + 1)}, ...series];
2285
+ adaptations.push({adaptation: "row-numbers", message: `The scatter chart has no X column (a point label column and one value column), so its ${data.rows.length} values are plotted against their row numbers.`});
2286
+ }
2287
+ return {...mapped, series, hasCategories: true, adaptations};
2136
2288
  }
2137
2289
 
2290
+
2138
2291
  /**
2139
2292
  * Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
2140
2293
  * Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
2141
2294
  * computed by interpolating the edges rather than from a width, and a value is placed by comparing it with the edges.
2295
+ * Used by the default (fallback) histogram export only; the native chartex histogram bins in PowerPoint.
2142
2296
  */
2143
2297
  function histogramBins(values) {
2144
2298
  const min = values.reduce((a, b) => Math.min(a, b)), max = values.reduce((a, b) => Math.max(a, b));
@@ -2154,34 +2308,12 @@ function histogramBins(values) {
2154
2308
  // Labels carry six significant digits, and more when that would make two bins read alike.
2155
2309
  for (let precision = 6; ; precision++) {
2156
2310
  const format = (value) => String(Number(value.toPrecision(precision)));
2157
- const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}\u2013${format(bin.high)}`);
2311
+ const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}–${format(bin.high)}`);
2158
2312
  if (new Set(labels).size === labels.length) return bins.map((bin, index) => ({...bin, label: labels[index]}));
2159
2313
  if (precision === 17) return bins.map((bin, index) => ({...bin, label: `${labels[index]} (bin ${index + 1})`}));
2160
2314
  }
2161
2315
  }
2162
2316
 
2163
- function mapChartType(type) {
2164
- const normalized = String(type ?? "").toLowerCase();
2165
- if (normalized.includes("pie")) return { type: "pie" };
2166
- if (normalized.includes("doughnut") || normalized.includes("donut")) return { type: "doughnut" };
2167
- if (normalized.includes("area")) return { type: "area" };
2168
- if (normalized.includes("line") || normalized.includes("sparkline")) return { type: "line" };
2169
- if (normalized.includes("scatter")) return { type: "scatter" };
2170
- if (normalized.includes("radar")) return { type: "radar" };
2171
- if (normalized.includes("bar")) {
2172
- return {
2173
- type: "bar",
2174
- barDir: "bar",
2175
- barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
2176
- };
2177
- }
2178
- return {
2179
- type: "bar",
2180
- barDir: "col",
2181
- barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
2182
- };
2183
- }
2184
-
2185
2317
  async function resolveImage(asset, presentation, options, path) {
2186
2318
  const assetObject = dereferenceAsset(asset, presentation, path);
2187
2319
  const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
@@ -2400,14 +2532,21 @@ async function normalizePptxZip(raw, context) {
2400
2532
  if(!context.chartHeadings.has(name))continue;
2401
2533
  const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
2402
2534
  if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
2403
- const {heading,labelColor}=context.chartHeadings.get(name);
2535
+ const {heading,labelColor,spec}=context.chartHeadings.get(name);
2404
2536
  if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
2537
+ entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
2405
2538
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
2406
2539
  // Normalize only our generated chart text styles; point/series fills stay intact.
2407
2540
  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>`)));
2408
2541
  }
2409
2542
  }
2410
2543
  applyChartFonts(entries,context.chartFonts,parseRelationships);
2544
+ // Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
2545
+ try {
2546
+ attachChartexParts(entries, context.chartex, parseRelationships);
2547
+ } catch (error) {
2548
+ throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
2549
+ }
2411
2550
  applyPitchFamilies(entries,context.fontPitch);
2412
2551
  const imageSources = new Map();
2413
2552
  for (const [part, bytes] of Object.entries(entries)) {
@@ -2457,7 +2596,7 @@ async function normalizePptxZip(raw, context) {
2457
2596
  if (!slide) continue;
2458
2597
  context.partSlides.set(part, Number(slide[1]) - 1);
2459
2598
  for (const relationship of parseRelationships(entries, part).values()) {
2460
- if (/\/(?:chart|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
2599
+ if (/\/(?:chart|chartEx|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
2461
2600
  }
2462
2601
  }
2463
2602
  const output = {};
@@ -2559,8 +2698,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2559
2698
  xml = xml.replace('</Types>', `${overrides}</Types>`);
2560
2699
  }
2561
2700
  if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
2701
+ if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
2702
+ if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
2562
2703
  if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
2563
2704
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
2705
+ xml = writeLinkSentinels(xml, context.linkSentinels);
2564
2706
  const fill = context.backgroundFills.get(path);
2565
2707
  if (typeof fill === 'string') xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
2566
2708
  else if (fill?.image) {
@@ -2631,10 +2773,10 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2631
2773
  for (const [edge, native] of [['left','lnL'],['right','lnR'],['top','lnT'],['bottom','lnB']]) {
2632
2774
  const border = style.borders?.[edge];
2633
2775
  if (!border) continue;
2634
- const borderHex = exportColor(border.color, context, context.colors.border);
2776
+ const slideContext = table.context, borderHex = exportColor(border.color, slideContext, slideContext.colors.border);
2635
2777
  const borderOpacity = borderHex.length === 8 ? parseInt(borderHex.slice(6), 16) / 255 : 1;
2636
- const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, context) : undefined;
2637
- const fill = border.width === 0 ? '<a:noFill/>' : borderScheme ? `<a:solidFill><a:schemeClr val="${borderScheme}"/></a:solidFill>` : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1}, context.colors.border);
2778
+ const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, slideContext) : undefined;
2779
+ const fill = border.width === 0 ? '<a:noFill/>' : borderScheme ? `<a:solidFill><a:schemeClr val="${borderScheme}"/></a:solidFill>` : nativeBackgroundFill({type:'solid',color:'#' + borderHex.slice(0, 6), opacity: borderOpacity},{width:1,height:1}, slideContext.colors.border);
2638
2780
  const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
2639
2781
  const line = `<a:${native} w="${Math.round(border.width * table.scale * 9525)}" cap="flat" cmpd="sng" algn="ctr">${fill}<a:prstDash val="${dash}"/></a:${native}>`;
2640
2782
  const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
@@ -2670,7 +2812,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2670
2812
  const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
2671
2813
  if(!marker)return shape;
2672
2814
  const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[char]));
2673
- return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr><a:srgbClr val="${marker.color}"/></a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
2815
+ return shape.replace(/<a:buSzPct val="100000"\/>/g,`<a:buClr>${solidColorXml(marker.color)}</a:buClr><a:buSzPts val="${Math.round(marker.fontSize*100)}"/><a:buFont typeface="${family}"/>`);
2674
2816
  });
2675
2817
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
2676
2818
  // carries the accessible name the preview gives its group.