@openpresentation/opf-pptx 0.11.0 → 0.11.2

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,7 @@ 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';
6
7
  import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
7
8
  import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
8
9
  import {attachCardTags,importCardFrames} from './card-provenance.js';
@@ -15,9 +16,11 @@ import {applyDocumentProvenance, attachDocumentProvenance, documentProvenance, r
15
16
  import {attachFurnitureFields, furniturePartFields, lineFields, nativeFieldType} from './furniture-fields.js';
16
17
  import {importImageOrientation} from './image-import.js';
17
18
  import {placeSlideImages, importSlideImage, slideImageName, slideImageOverlayName} from './slide-image-provenance.js';
19
+ import {dedupeMedia} from './media-dedupe.js';
20
+ import {placeWatermarks, importWatermark, watermarkName, watermarkBox, watermarkOpacity} from './watermark-provenance.js';
18
21
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
19
22
  import {importBackground} from './background-import.js';
20
- import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
23
+ import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
21
24
  import {languageDiagnostics, observeLanguage, partScriptFonts, planScriptFonts, reconcileLanguage} from './script-fonts.js';
22
25
  import { webpToPng } from '#image-fallback';
23
26
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
@@ -205,6 +208,7 @@ export async function toPptx(input, options = {}) {
205
208
  context.tableCells = new Map();
206
209
  context.imagePlacements = new Map();
207
210
  context.slideImages = new Map();
211
+ context.watermarks = new Map();
208
212
  context.backgroundFills = new Map();
209
213
  context.notesWithCarriageReturns = new Map();
210
214
  context.cardTags = new Map();
@@ -230,6 +234,8 @@ export async function toPptx(input, options = {}) {
230
234
  report: diagnostic => options.onDiagnostic?.(diagnostic)
231
235
  });
232
236
  context.scriptFonts = planScriptFonts(presentation, options.onDiagnostic);
237
+ context.masterBackground = masterBackground(context);
238
+ context.linkSentinels = linkSentinels(presentation);
233
239
  const pptx = new PptxGenJS();
234
240
  configurePresentation(pptx, presentation, {...context,fonts:resolveSlideContext(presentation,presentation.slides[0],context,options).fonts});
235
241
 
@@ -309,6 +315,12 @@ export async function fromPptx(input, options = {}) {
309
315
  furnitureContexts[index].mediaRegistry = mediaRegistry;
310
316
  imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, options, furniture.slides[index], furnitureContexts[index]));
311
317
  }
318
+ // A watermark carried identically by every slide is the deck's design.watermark.
319
+ const carried = imported.slides.map(slide => slide.design?.watermark);
320
+ if (carried.length && carried[0] && carried.every(value => JSON.stringify(value) === JSON.stringify(carried[0]))) {
321
+ imported.design = {...imported.design, watermark: carried[0]};
322
+ for (const slide of imported.slides) { delete slide.design.watermark; if (!Object.keys(slide.design).length) delete slide.design; }
323
+ }
312
324
  // Conflicting media asset IDs fall back to their current native URLs during import.
313
325
  for (const context of furnitureContexts) for (const [id, asset] of Object.entries(context.mediaAssets ?? {})) if (!Object.hasOwn(imported.assets ?? {}, id)) imported.assets = {...imported.assets, [id]: asset};
314
326
  // Report after the slides so slide diagnostics keep their established order.
@@ -502,6 +514,15 @@ function importSlide(entries, slidePath, slideIndex, presentationDimensions, opt
502
514
  if (slideImage.design) slide.design = {...slide.design, ...slideImage.design};
503
515
  nativeContext.slideImagePictures = slideImage.consumed;
504
516
  nativeContext.slideImageShapes = slideImage.consumedShapes;
517
+ const watermarkPath = `slides.${slideIndex}.design.watermark`;
518
+ const watermark = importWatermark(nativeContext.pictures, relationships, entries, slideIndex,
519
+ picture => importPicture(entries, picture, slidePath, relationships, diagnostic => {
520
+ // The recorded fit is re-derived from design.watermark.
521
+ if (diagnostic.code !== 'unsupported-image-crop') options.onDiagnostic?.({...diagnostic, path: watermarkPath});
522
+ }),
523
+ diagnostic => options.onDiagnostic?.({...diagnostic, path: watermarkPath}));
524
+ if (watermark.design) slide.design = {...slide.design, ...watermark.design};
525
+ nativeContext.watermarkPictures = watermark.consumed;
505
526
 
506
527
  const items = collectSlideItems(entries, slideRoot, slidePath, relationships, dimensions, options, slideIndex, furniture, nativeContext)
507
528
  .sort(comparePositionedItems);
@@ -594,7 +615,7 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
594
615
  }
595
616
 
596
617
  for (const [index, picture] of nativeContext.pictures.entries()) {
597
- if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index)) continue;
618
+ if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
598
619
  const report = diagnostic => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.pictures.${index}`});
599
620
  const item = importPicture(entries, picture, slidePath, relationships, report);
600
621
  if (item) items.push(item);
@@ -833,6 +854,7 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
833
854
 
834
855
  const budget = { cells: 0 };
835
856
  const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
857
+ if (chartNode.type === 'scatter') return scatterFromSeries(entries, relationship.path, series, budget, cachePath);
836
858
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
837
859
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
838
860
  const values = series.map((entry, index) => {
@@ -862,19 +884,31 @@ function chartFromRelationship(entries, slidePath, relationships, relId) {
862
884
  };
863
885
  }
864
886
 
887
+ // Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
888
+ // them category-major: [point label, X, Y1, Y2, ...]; native charts carry no
889
+ // point labels, so points are numbered.
890
+ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
891
+ // The same cache helpers as every other chart: bounded, indexed by c:pt@idx, with gaps kept as null (never plotted as 0).
892
+ const xRole = series[0]?.["c:xVal"];
893
+ const xs = cachedValues(xRole, cachePath(0, "c:xVal"), budget, numericCacheValue);
894
+ const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, "c:tx"), budget) ?? `Series ${index + 1}`);
895
+ const values = series.map((entry, index) => cachedValues(entry?.["c:yVal"], cachePath(index, "c:yVal"), budget, numericCacheValue));
896
+ const rowCount = values.reduce((count, row) => Math.max(count, row.length), xs.length);
897
+ if (rowCount === 0) return null;
898
+ if (rowCount * (series.length + 2) > MAX_CHART_CACHE_CELLS) {
899
+ 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});
900
+ }
901
+ const rows = [];
902
+ // A chart without c:xVal plots against 1..n natively; a c:xVal gap stays a gap.
903
+ 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)]);
904
+ return { type: "scatter", data: { columns: ["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], rows } };
905
+ }
906
+
865
907
  function firstChartNode(plotArea) {
866
- const candidates = [
867
- ["c:barChart", (node) => node?.["c:barDir"]?.val === "bar" ? "bar" : "column"],
868
- ["c:lineChart", () => "line"],
869
- ["c:pieChart", () => "pie"],
870
- ["c:doughnutChart", () => "doughnut"],
871
- ["c:areaChart", () => "area"],
872
- ["c:scatterChart", () => "scatter"],
873
- ["c:radarChart", () => "radar"]
874
- ];
875
- for (const [key, type] of candidates) {
876
- const node = asArray(plotArea[key])[0];
877
- if (node) return { node, type: type(node) };
908
+ // Map the native construct back to the kept OPF chart type id (FF-22).
909
+ for (const element of NATIVE_CHART_ELEMENTS) {
910
+ const node = asArray(plotArea[`c:${element}`])[0];
911
+ if (node) return { node, type: chartTypeFromNative(element, node) };
878
912
  }
879
913
  return null;
880
914
  }
@@ -1115,8 +1149,48 @@ function exportColor(entry, context, fallback) {
1115
1149
  // PptxGenJS color for a document color reference: a theme scheme value when the
1116
1150
  // reference names a scheme slot or role that the deck theme holds exactly,
1117
1151
  // otherwise the resolved literal RRGGBB. Alpha stays in the caller's transparency.
1118
- function nativeColor(reference, hex, context) {
1119
- return schemeColorValue(reference, hex, context, {vendor: true}) ?? normalizeHex(hex);
1152
+ function nativeColor(reference, hex, context, fallback) {
1153
+ if (fallback !== undefined && (reference === undefined || reference === null || reference === '')) return pptxColor(fallback);
1154
+ const value = schemeColorValue(reference, hex, context, {vendor: true});
1155
+ if (value) return value;
1156
+ // PptxGenJS cannot write hlink or folHlink: a reserved literal stands in and
1157
+ // is rewritten to the scheme color in the finished slide part.
1158
+ const link = schemeColorValue(reference, hex, context);
1159
+ return (link && context.linkSentinels?.[link]) ?? normalizeHex(hex);
1160
+ }
1161
+
1162
+ // FF-24c: two literals no document color uses stand in for hlink and folHlink
1163
+ // (see nativeColor). When the document uses every candidate, those colors stay literal.
1164
+ const LINK_SENTINELS = [['FE01A0', 'FE01A1'], ['FE02B0', 'FE02B1'], ['FE03C0', 'FE03C1']];
1165
+ function linkSentinels(presentation) {
1166
+ const text = JSON.stringify(presentation).toUpperCase();
1167
+ const pair = LINK_SENTINELS.find(hexes => hexes.every(hex => !text.includes(hex)));
1168
+ return pair ? {hlink: pair[0], folHlink: pair[1]} : {};
1169
+ }
1170
+
1171
+ function writeLinkSentinels(xml, sentinels) {
1172
+ const values = Object.entries(sentinels ?? {});
1173
+ if (!values.length) return xml;
1174
+ return xml.replace(/<a:srgbClr val="([0-9A-F]{6})"(\/>|>[\s\S]*?<\/a:srgbClr>)/g, (node, hex, rest) => {
1175
+ const link = values.find(([, value]) => value === hex)?.[0];
1176
+ return link ? `<a:schemeClr val="${link}"${rest.replace(/<\/a:srgbClr>$/, '</a:schemeClr>')}` : node;
1177
+ });
1178
+ }
1179
+
1180
+ // PptxGenJS color option: a scheme value it can emit (tx1, bg2, ...) or RRGGBB.
1181
+ function pptxColor(value) {
1182
+ return /^(?:tx[12]|bg[12])$/.test(value) ? value : normalizeHex(value);
1183
+ }
1184
+
1185
+ // Deck-level theme background for the slide master, so slides added in
1186
+ // PowerPoint match. The vendored layout already uses bg1, so only a theme
1187
+ // background on another slot (dark themes, light2, accents) changes the master.
1188
+ function masterBackground(context) {
1189
+ const deck = {...context, schemeOverride: false};
1190
+ const value = schemeBackgroundValue(deck.backgroundDefinition, deck);
1191
+ const fill = schemeBackgroundFill(deck.backgroundDefinition, deck);
1192
+ if (!value || value === 'bg1' || !fill || fill.includes('<a:alpha')) return null;
1193
+ return {fill, text: defaultTextSchemeValues(deck).text ?? normalizeHex(context.colors.text)};
1120
1194
  }
1121
1195
 
1122
1196
  // The theme part carries the deck-level scheme (slide overrides stay literal)
@@ -1207,7 +1281,7 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1207
1281
  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.'});
1208
1282
  }
1209
1283
  }
1210
- slide.color = slideContext.colors.text;
1284
+ slide.color = slideContext.textColor;
1211
1285
  if (opfSlide.hidden === true) slide.hidden = true;
1212
1286
 
1213
1287
  const { widthInches, heightInches } = slideContext.dimensions;
@@ -1225,51 +1299,55 @@ async function addSlide(pptx, presentation, opfSlide, slideIndex, context, optio
1225
1299
  const alignmentFor=item=>item.alignment??fieldAlignment(item.field);
1226
1300
  const geometry = composeSlide(opfSlide, { width: widthInches * 96, height: heightInches * 96, layout, presentation, slideIndex, fonts: slideContext.fonts, contentAlignment, titleAlignment, textRasterPadding:options.textRasterPadding, contentBox:opfSlide.design?.contentBox??presentation.design?.contentBox, textMeasurement: options.textMeasurement, date: options.date, socialPlatforms: socialPlatformRecords(presentation, options) });
1227
1301
  for (const diagnostic of geometry.diagnostics) options.onDiagnostic?.(diagnostic);
1302
+ // The preview paints background, slide image (and its overlay), then design.watermark, then content.
1228
1303
  if (geometry.slideImage) await addSlideImage(slide, presentation, geometry.slideImage, slideIndex, slideContext, options);
1304
+ await addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options);
1229
1305
  for (const item of geometry.items) {
1306
+ // Card text sits on a literal card fill, not the slide background: keep it literal.
1307
+ const itemContext = item.frameBox ? {...slideContext, textColor: slideContext.colors.text, mutedColor: slideContext.colors.mutedText} : slideContext;
1230
1308
  const region = { x: item.box.x / 96, y: item.box.y / 96, w: item.box.width / 96, h: item.box.height / 96 };
1231
1309
  if (item.frameBox) {
1232
1310
  const frame=item.frameBox;
1233
1311
  context.cardTags.set(`OPF card ${item.path}`,item.path);
1234
1312
  const paint=value=>({color:normalizeHex(value),transparency:/^#[0-9a-f]{8}$/i.test(value)?(1-parseInt(value.slice(7),16)/255)*100:0});
1235
- const surface=slideContext.colorScheme[isDarkHex(slideContext.colors.background)?'dark2':'light2']??`#${slideContext.colors.surface}`;
1313
+ const surface=itemContext.colorScheme[isDarkHex(itemContext.colors.background)?'dark2':'light2']??`#${itemContext.colors.surface}`;
1236
1314
  slide.addShape('roundRect',{x:frame.x/96,y:frame.y/96,w:frame.width/96,h:frame.height/96,
1237
1315
  rectRadius:8*Math.min(widthInches,heightInches)/720,
1238
- fill:paint(surface),line:{...paint(slideContext.colorScheme.accent5??`#${slideContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
1316
+ fill:paint(surface),line:{...paint(itemContext.colorScheme.accent5??`#${itemContext.colors.border}`),width:.75},objectName:`OPF card ${item.path}`});
1239
1317
  }
1240
1318
  if(['text','title','subtitle','tag'].includes(item.field)&&(item.text?.placement||item.text?.sourceLines)&&!item.text.richLines) {
1241
- 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});
1319
+ 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});
1242
1320
  } else if (["title", "subtitle", "tag"].includes(item.field)) {
1243
1321
  // Estimated-font headings use one native text box, which still needs a
1244
1322
  // role tag. Role recovery must not depend on outline measurement support.
1245
1323
  const objectName = `OPF heading ${item.path} line 0`;
1246
1324
  context.headingTags.set(objectName,{v:1,group:item.path,field:item.field,line:0,count:1});
1247
1325
  slide.addText(item.text.lines.join("\n"), {
1248
- ...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),
1326
+ ...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),
1249
1327
  ...nativeFontOptions(item.textStyle),
1250
- color: item.field === "tag" ? slideContext.colors.accent : slideContext.colors.text,
1328
+ color: item.field === "tag" ? itemContext.colors.accent : itemContext.textColor,
1251
1329
  align: alignmentFor(item),
1252
1330
  objectName,
1253
1331
  breakLine: false
1254
1332
  });
1255
1333
  } else if ((item.field === "items" || item.field === "bullets") && item.text?.listEntries) {
1256
- addMeasuredList(slide,item.text,slideContext,item.path);
1334
+ addMeasuredList(slide,item.text,itemContext,item.path);
1257
1335
  } else if (item.field === "text" && item.text?.richLines) {
1258
1336
  const alignment=item.text.placement?.alignment??alignmentFor(item)??'left';
1259
1337
  for(const [index,line] of item.text.richLines.entries()){
1260
1338
  const runs=line.fragments.map(fragment=>{
1261
- const runColor=exportColor(fragment.run.color,slideContext,slideContext.colors.text);
1262
- const color=nativeColor(fragment.run.color,runColor,slideContext);
1339
+ const runColor=exportColor(fragment.run.color,itemContext,itemContext.colors.text);
1340
+ const color=nativeColor(fragment.run.color,runColor,itemContext,itemContext.textColor);
1263
1341
  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}};
1264
1342
  });
1265
1343
  const placed=item.text.placement?.lines[index],factor=alignment==='right'?1:alignment==='center'?.5:0;
1266
1344
  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};
1267
- if(runs.length)slide.addText(runs,{...textBoxOptions(area,slideContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1345
+ if(runs.length)slide.addText(runs,{...textBoxOptions(area,itemContext,item.text.fontSize*.75),align:alignment,fit:'none',wrap:false,lineSpacingMultiple:1});
1268
1346
  }
1269
1347
  } else if (item.field === "text" && typeof item.value === "string") {
1270
- slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, slideContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
1348
+ slide.addText(item.text.lines.join("\n"), {...textBoxOptions(region, itemContext, item.text.fontSize * 0.75),...nativeFontOptions(item.textStyle),align:alignmentFor(item)});
1271
1349
  } else {
1272
- 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);
1350
+ 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);
1273
1351
  }
1274
1352
  }
1275
1353
  // Core composes furniture above all content and opf-render paints it last, so
@@ -1301,7 +1379,17 @@ function resolveSlideContext(presentation, slide, baseContext, options, slideInd
1301
1379
  || Math.abs(resolved.dimensions.heightInches - baseContext.dimensions.heightInches) > 1e-6) {
1302
1380
  throw new OPFPptxError("mixed-slide-dimensions", "PowerPoint requires one canvas size per presentation. Set dimensions on the deck or export this slide separately.");
1303
1381
  }
1304
- return { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
1382
+ const slideContext = { ...baseContext, backgroundDefinition: resolved.backgroundDefinition, colorScheme: resolved.colorScheme, fonts: resolved.fonts, colors: resolved.colors, variables: resolved.variables, imageFill: effective.design.imageFill ?? "fit" };
1383
+ // A slide-level color scheme (directly or through a slide theme) pins every
1384
+ // named color on that slide to literal sRGB; see schemeColorValue().
1385
+ const slideScheme = themeSlotColors(resolved.colorScheme), deckScheme = baseContext.themeColors ?? {};
1386
+ slideContext.schemeOverride = slide.design?.colorScheme !== undefined
1387
+ || Object.keys({...slideScheme, ...deckScheme}).some(slot => slideScheme[slot] !== deckScheme[slot]);
1388
+ // Default and muted text follow the theme only where the background does.
1389
+ const text = defaultTextSchemeValues(slideContext);
1390
+ slideContext.textColor = text.text ?? resolved.colors.text;
1391
+ slideContext.mutedColor = text.muted ?? resolved.colors.mutedText;
1392
+ return slideContext;
1305
1393
  }
1306
1394
 
1307
1395
  function fieldToType(field) {
@@ -1369,10 +1457,10 @@ function addTextPayload(slide, value, region, context) {
1369
1457
  slide.addText(stringifyText(value), textBoxOptions(region, context, 18));
1370
1458
  }
1371
1459
 
1372
- function richLineRuns(line,color,context) {
1460
+ function richLineRuns(line,color,native,context) {
1373
1461
  const fallback=color.replace(/^#/,'');
1374
1462
  return line.fragments.map(fragment=>{
1375
- const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context);
1463
+ const runColor=exportColor(fragment.run.color,context,fallback),color=nativeColor(fragment.run.color,runColor,context,native);
1376
1464
  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}};
1377
1465
  });
1378
1466
  }
@@ -1384,14 +1472,14 @@ function richLineRuns(line,color,context) {
1384
1472
  function addMeasuredList(slide,fit,context,path) {
1385
1473
  let lineNumber=0;
1386
1474
  for(const entry of fit.listEntries){
1387
- const addLines=(text,box,color,withBullet)=>{
1475
+ const addLines=(text,box,color,native,withBullet)=>{
1388
1476
  text.richLines.forEach((line,index)=>{
1389
1477
  const first=withBullet&&index===0,level=Math.min(8,entry.level),inset=first?entry.marker.indent*(level+1):0;
1390
1478
  const region={x:(box.x-inset)/96,y:(box.y+line.y)/96,w:(box.width+inset)/96,h:line.height/96};
1391
1479
  const objectName=`OPF list ${path} line ${lineNumber++}`;
1392
- if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:normalizeHex(context.colors.text)});
1480
+ if(first)context.listMarkers.set(objectName,{fontFamily:entry.marker.style.fontFamily,fontSize:entry.marker.fontSize*.75,color:pptxColor(context.textColor)});
1393
1481
  const paragraph=first?{bullet:{characterCode:entry.marker.text.codePointAt(0).toString(16).padStart(4,'0'),indent:entry.marker.indent*.75},indentLevel:level}:{bullet:false};
1394
- const runs=richLineRuns(line,color,context);
1482
+ const runs=richLineRuns(line,color,native,context);
1395
1483
  if(!runs.length)runs.push({text:'',options:{}});
1396
1484
  // Keep paragraph intent identical across runs. ZIP normalization below
1397
1485
  // removes the duplicate paragraph-property nodes emitted by PptxGenJS.
@@ -1399,8 +1487,8 @@ function addMeasuredList(slide,fit,context,path) {
1399
1487
  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});
1400
1488
  });
1401
1489
  };
1402
- addLines(entry.text,entry.textBox,context.colors.text,true);
1403
- if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,false);
1490
+ addLines(entry.text,entry.textBox,context.colors.text,context.textColor,true);
1491
+ if(entry.description)addLines(entry.description,entry.descriptionBox,context.colors.mutedText,context.mutedColor,false);
1404
1492
  }
1405
1493
  }
1406
1494
 
@@ -1420,7 +1508,7 @@ function addListPayload(slide, items, region, context) {
1420
1508
  options: {
1421
1509
  bullet: { type: "bullet", indent: 14 + level * 14 },
1422
1510
  breakLine: index < list.length - 1 || Boolean(isPlainObject(item) && item.description),
1423
- color: context.colors.text,
1511
+ color: context.textColor,
1424
1512
  fontFace: context.fonts.body,
1425
1513
  fontSize: 15,
1426
1514
  hanging: 4 + level * 10
@@ -1431,7 +1519,7 @@ function addListPayload(slide, items, region, context) {
1431
1519
  text: stringifyText(item.description),
1432
1520
  options: {
1433
1521
  breakLine: index < list.length - 1,
1434
- color: context.colors.mutedText,
1522
+ color: context.mutedColor,
1435
1523
  fontFace: context.fonts.body,
1436
1524
  fontSize: 11,
1437
1525
  margin: [0, 0, 0, 18 + level * 14]
@@ -1463,6 +1551,33 @@ async function addImagePayload(slide, presentation, asset, region, path, context
1463
1551
  return objectName;
1464
1552
  }
1465
1553
 
1554
+ // design.watermark: one native picture per slide, added after the slide image
1555
+ // (and its overlay) and before all content, the preview's paint order. The
1556
+ // slide's own design.watermark replaces the deck's, and false suppresses it.
1557
+ // Frame, fit and opacity are written after PptxGenJS embeds the bytes.
1558
+ async function addWatermark(slide, presentation, opfSlide, slideIndex, slideContext, context, options) {
1559
+ const local = opfSlide.design?.watermark !== undefined;
1560
+ const watermark = local ? opfSlide.design.watermark : presentation.design?.watermark;
1561
+ if (watermark === undefined || watermark === null || watermark === false) return;
1562
+ const path = local ? `slides.${slideIndex}.design.watermark` : 'design.watermark';
1563
+ const notExported = message => options.onDiagnostic?.({code: 'watermark-not-exported', path, message});
1564
+ const asset = isPlainObject(watermark) || typeof watermark === 'string' ? watermark : null;
1565
+ if (asset === null || (isPlainObject(asset) && typeof asset.src !== 'string')) {
1566
+ notExported('The watermark needs an image source (a string, or an object with src); no watermark was exported for this slide.');
1567
+ return;
1568
+ }
1569
+ const resolved = await resolveImage(asset, presentation, options, path);
1570
+ if (!resolved) {
1571
+ options.onDiagnostic?.({code: 'unresolved-asset', path, message: 'The watermark image needs an embedded raster, a declared asset or a host imageResolver; no watermark was exported for this slide.'});
1572
+ return;
1573
+ }
1574
+ const {widthInches, heightInches} = slideContext.dimensions;
1575
+ const box = watermarkBox(widthInches, heightInches);
1576
+ const opacity = watermarkOpacity(watermark);
1577
+ context.watermarks.set(`ppt/slides/slide${slideIndex + 1}.xml`, {slide: `slides.${slideIndex}`, box, opacity, path});
1578
+ slide.addImage({...resolved, objectName: watermarkName(), ...box, altText: assetAlt(asset, presentation) ?? 'Watermark'});
1579
+ }
1580
+
1466
1581
  // design.slideImage: one native picture at the shared frame, beneath content.
1467
1582
  // Crop/fit and treatments are written after PptxGenJS embeds the bytes.
1468
1583
  async function addSlideImage(slide, presentation, image, slideIndex, context, options) {
@@ -1506,7 +1621,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1506
1621
  addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
1507
1622
  return;
1508
1623
  }
1509
- if (chartData.adapted) options.onDiagnostic?.({code: "chart-data-adapted", path, message: chartData.message, adaptation: chartData.adapted});
1624
+ for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
1510
1625
 
1511
1626
  // Keep the resolved palette surface (including alpha) explicit in native
1512
1627
  // chart/plot areas, so inherited labels are assessed against their own panel.
@@ -1516,15 +1631,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1516
1631
  const fill = {color:normalizeHex(panelFill),transparency};
1517
1632
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
1518
1633
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
1519
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor});
1634
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec});
1520
1635
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
1636
+ const percent = chartData.spec.grouping === 'percentStacked';
1521
1637
  slide.addChart(chartData.type, chartData.series, {
1522
1638
  objectName,
1523
1639
  x: region.x,
1524
1640
  y: region.y,
1525
1641
  w: region.w,
1526
1642
  h: region.h,
1527
- showLegend: circular || chartData.series.length > 1,
1643
+ showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
1528
1644
  showTitle: false,
1529
1645
  chartColors: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
1530
1646
  chartArea: {fill:{...fill},roundedCorners:false},
@@ -1542,7 +1658,12 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
1542
1658
  showValue: false,
1543
1659
  valGridLine: { color: context.colors.border, transparency: 30, size: 1 },
1544
1660
  barDir: chartData.barDir,
1545
- barGrouping: chartData.barGrouping
1661
+ barGrouping: chartData.barGrouping,
1662
+ ...(percent ? { valAxisLabelFormatCode: '0%' } : {}),
1663
+ ...(chartData.spec.markers === undefined ? {} : { lineDataSymbol: chartData.spec.markers ? 'circle' : 'none' }),
1664
+ ...(chartData.spec.radarStyle ? { radarStyle: chartData.spec.radarStyle } : {}),
1665
+ // ScatterWithMarkers: markers only, no connecting line.
1666
+ ...(chartData.type === 'scatter' ? { lineSize: 0, lineDataSymbol: 'circle' } : {})
1546
1667
  });
1547
1668
  }
1548
1669
 
@@ -1565,8 +1686,13 @@ function addTablePayload(slide, table, region, context, options, path) {
1565
1686
  const cellStyle = cell.style ?? {};
1566
1687
  const defaultFillHex = header ? context.colors.accent : context.colors.surface;
1567
1688
  const baseFill = exportColor(cellStyle.fill ?? defaultFillHex, context, defaultFillHex);
1689
+ // FF-24c: engine chrome resolves from scheme roles (header accent1, body surface), so it follows the deck theme like a named fill.
1690
+ const fillReference = cellStyle.fill ?? (header ? 'accent1' : 'surface');
1691
+ const fillValue = schemeColorValue(fillReference, baseFill, context);
1568
1692
  const inheritedText = textColorForFill(`#${baseFill}`, header ? "#FFFFFF" : `#${context.colors.text}`);
1569
1693
  const baseColor = exportColor(cellStyle.color ?? inheritedText.replace(/^#/, ""), context, inheritedText.replace(/^#/, ""));
1694
+ // 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.
1695
+ const defaultText = cellStyle.color === undefined ? tableTextSchemeValue(fillValue, baseColor, context) : undefined;
1570
1696
  const alpha = value => value.length === 8 ? (1 - parseInt(value.slice(6), 16) / 255) * 100 : 0;
1571
1697
  const text = stringifyText(cell.value), style = cell.textStyle;
1572
1698
  const fragments = rich ? fit.richLines.flatMap(line => line.fragments) : [];
@@ -1575,7 +1701,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1575
1701
  const fragment = fragments.find(item => item.runIndex === index);
1576
1702
  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);
1577
1703
  const rawColor = run.color !== undefined && run.color !== '' ? exportColor(run.color, context, baseColor) : baseColor;
1578
- 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;
1704
+ 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;
1579
1705
  const runOptions = {
1580
1706
  ...nativeFontOptions(runStyle),fontSize:fragment ? fragment.fontSize * .75 : fit.fontSize * .75,
1581
1707
  underline:run.underline ? {style:'sng',color} : undefined,strike:run.strikethrough ? 'sngStrike' : undefined,
@@ -1605,17 +1731,17 @@ function addTablePayload(slide, table, region, context, options, path) {
1605
1731
  valign: cellStyle.verticalAlign ?? 'top',
1606
1732
  ...(cell.colSpan > 1 ? {colspan:cell.colSpan} : {}),
1607
1733
  ...(cell.rowSpan > 1 ? {rowspan:cell.rowSpan} : {}),
1608
- color: nativeColor(cellStyle.color, baseColor, context),
1734
+ color: nativeColor(cellStyle.color, baseColor, context, defaultText),
1609
1735
  // Rich runs carry resolved alpha. A translucent cell default would
1610
1736
  // overwrite an explicit opaque run because PptxGenJS inherits falsy 0.
1611
1737
  ...(cellStyle.color && !rich ? {transparency:alpha(baseColor)} : {}),
1612
- fill: { color: nativeColor(cellStyle.fill, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1738
+ fill: { color: nativeColor(fillReference, baseFill, context), ...(cellStyle.fill ? {transparency:alpha(baseFill)} : {}) },
1613
1739
  },
1614
1740
  };
1615
1741
  }));
1616
1742
  const objectName = `OPF table ${context.tableHeaders.size + 1}`;
1617
1743
  context.tableHeaders.set(objectName, hasHeaders);
1618
- 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}});
1744
+ 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}});
1619
1745
  slide.addTable(rows, {
1620
1746
  objectName,
1621
1747
  x: region.x,
@@ -1631,7 +1757,7 @@ function addTablePayload(slide, table, region, context, options, path) {
1631
1757
  fontFace: context.fonts.body,
1632
1758
  fontSize: 15 * scale * 0.75,
1633
1759
  color: context.colors.text,
1634
- border: { type: "solid", color: context.colors.border, pt: 0.75 },
1760
+ border: { type: "solid", color: nativeColor("accent5", context.colors.border, context), pt: 0.75 },
1635
1761
  margin: [6 * scale, 7.5 * scale, 3 * scale, 7.5 * scale],
1636
1762
  valign: "top"
1637
1763
  });
@@ -1673,7 +1799,7 @@ function addMeasuredPayloadText(slide, text, box, context, options, config) {
1673
1799
  else if(config.sourceText)context.plainTextTags.set(objectName,{v:1,group:config.path,line:index,count:fit.lines.length,...boundary});
1674
1800
  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};
1675
1801
  // A run-level link keeps the muted, non-underlined furniture look of the preview (hlinkClr=tx, u=none).
1676
- const lineColor = normalizeHex(config.color ?? context.colors.text);
1802
+ const lineColor = pptxColor(config.color ?? context.textColor);
1677
1803
  slide.addText(link?.href && line ? [{text: line, options: {hyperlink: {url: link.href}, color: lineColor, underline: {style: 'none'}}}] : line, {
1678
1804
  ...textBoxOptions(area, context, fit.fontSize * .75),
1679
1805
  ...nativeFontOptions(style),
@@ -1721,7 +1847,7 @@ async function addFurniture(slide,presentation,source,layout,context,options,sli
1721
1847
  if(marker) staticDates.set(index,marker);
1722
1848
  }
1723
1849
  }
1724
- 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});
1850
+ 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});
1725
1851
  }
1726
1852
  }
1727
1853
  const manifest = furnitureManifest(presentation, source, layout, slideIndex, staticDates);
@@ -1776,7 +1902,7 @@ function addMetricPayload(slide,value,layout,context,path) {
1776
1902
  slide.addText(part.text.slice(line.start,line.end),{
1777
1903
  ...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),
1778
1904
  ...nativeFontOptions(part.style),
1779
- color:part.role==='value'?context.colors.accent:context.colors.text,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
1905
+ color:part.role==='value'?context.colors.accent:context.textColor,align:layout.alignment,fit:'none',wrap:false,lineSpacingMultiple:1,
1780
1906
  tabStops:tabStops.length?tabStops:undefined,objectName,
1781
1907
  });
1782
1908
  }
@@ -1789,7 +1915,7 @@ function addQuotePayload(slide, layout, context, options, path) {
1789
1915
  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});
1790
1916
  addMeasuredPayloadText(slide,part.text,part.box,context,options,{
1791
1917
  path:part.path,fit:part.fit,textStyle:part.style,diagnosticsHandled:true,
1792
- color:part.role==='footer'?context.colors.mutedText:context.colors.text,
1918
+ color:part.role==='footer'?context.mutedColor:context.textColor,
1793
1919
  });
1794
1920
  }
1795
1921
  }
@@ -1925,7 +2051,7 @@ function addPlaceholderPayload(slide, label, description, region, context) {
1925
2051
  margin: 0,
1926
2052
  fontFace: context.fonts.body,
1927
2053
  fontSize: 11,
1928
- color: context.colors.mutedText,
2054
+ color: context.mutedColor,
1929
2055
  fit: "shrink",
1930
2056
  valign: "mid",
1931
2057
  align: "center"
@@ -1993,7 +2119,7 @@ function textBoxOptions(region, context, fontSize) {
1993
2119
  lineSpacingMultiple: 1.22,
1994
2120
  fontFace: context.fonts.body,
1995
2121
  fontSize,
1996
- color: context.colors.text,
2122
+ color: context.textColor,
1997
2123
  breakLine: false,
1998
2124
  fit: "shrink",
1999
2125
  valign: "top"
@@ -2007,7 +2133,7 @@ function textRuns(value, context, fallbackFontSize) {
2007
2133
  return {
2008
2134
  text: run,
2009
2135
  options: {
2010
- color: context.colors.text,
2136
+ color: context.textColor,
2011
2137
  fontFace: context.fonts.body,
2012
2138
  fontSize: fallbackFontSize
2013
2139
  }
@@ -2018,9 +2144,9 @@ function textRuns(value, context, fallbackFontSize) {
2018
2144
  options: {
2019
2145
  bold: run?.bold,
2020
2146
  italic: run?.italic,
2021
- underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context) } : undefined,
2147
+ underline: run?.underline ? { style: "sng", color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor) } : undefined,
2022
2148
  strike: run?.strikethrough ? "sngStrike" : undefined,
2023
- color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context),
2149
+ color: nativeColor(run?.color, exportColor(run?.color, context, context.colors.text), context, context.textColor),
2024
2150
  fontFace: run?.fontFamily ?? context.fonts.body,
2025
2151
  fontSize: run?.fontSize ?? fallbackFontSize,
2026
2152
  superscript: run?.superscript,
@@ -2046,7 +2172,16 @@ function toPptxChartData(chart) {
2046
2172
  }
2047
2173
  if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2048
2174
  if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
2049
- const mapped = mapChartType(chart.type);
2175
+ const resolved = resolveChartType(chart.type).spec;
2176
+ // Chartex types (treemap, box & whisker, waterfall, ...) have no classic construct here: they export as a clustered column chart, never silently.
2177
+ const chartex = resolved.family === 'chartex';
2178
+ const spec = chartex ? CHARTEX_FALLBACK : resolved;
2179
+ const typeName = stringifyText(chart.type);
2180
+ const adaptations = [];
2181
+ if (chartex) {
2182
+ 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.`});
2183
+ }
2184
+ const mapped = {type: spec.pptx, spec, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
2050
2185
  if (data.columns.length === 1) {
2051
2186
  const heading = stringifyText(data.columns[0]);
2052
2187
  const cell = (row) => Array.isArray(row) ? row[0] : row;
@@ -2060,10 +2195,12 @@ function toPptxChartData(chart) {
2060
2195
  if (String(chart.type ?? "").toLowerCase() === "histogram") {
2061
2196
  const bins = histogramBins(points.map((point) => point.value));
2062
2197
  return {
2063
- type: "bar", barDir: "col", barGrouping: "clustered", heading: "Bin",
2198
+ type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin",
2064
2199
  series: [{name: "Frequency", labels: bins.map((bin) => bin.label), values: bins.map((bin) => bin.count)}],
2065
- adapted: "histogram-binned",
2066
- 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.`
2200
+ adaptations: [{
2201
+ adaptation: "histogram-binned",
2202
+ 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.`
2203
+ }]
2067
2204
  };
2068
2205
  }
2069
2206
  // Rows keep their own row numbers, so a skipped cell leaves a gap in the numbering.
@@ -2072,22 +2209,31 @@ function toPptxChartData(chart) {
2072
2209
  const series = mapped.type === "scatter"
2073
2210
  ? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
2074
2211
  : [{name: heading, labels, values: numbers}];
2075
- return {
2076
- ...mapped, series, heading: "Row", adapted: "row-numbers",
2077
- message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`
2078
- };
2212
+ 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.`});
2213
+ return {...mapped, series, heading: "Row", adaptations};
2079
2214
  }
2080
2215
 
2081
2216
  const labels = data.rows.map((row) => stringifyText(row?.[0]));
2082
- const series = data.columns.slice(1).map((name, seriesIndex) => ({
2217
+ let series = data.columns.slice(1).map((name, seriesIndex) => ({
2083
2218
  name: stringifyText(name),
2084
2219
  labels,
2085
2220
  values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
2086
2221
  }));
2087
-
2088
- return { ...mapped, series };
2222
+ if (spec.family === 'circular' && series.length > 1) {
2223
+ // A pie or doughnut plots one series; name the ones left out instead of dropping them silently.
2224
+ const dropped = series.slice(1).map((entry) => `'${entry.name}'`);
2225
+ 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.`});
2226
+ series = series.slice(0, 1);
2227
+ }
2228
+ if (spec.family === 'xy' && series.length === 1) {
2229
+ // [Point, X, Y...] carries its own X column; a lone value column is plotted against the row numbers.
2230
+ series = [{name: 'X', labels, values: data.rows.map((_, index) => index + 1)}, ...series];
2231
+ 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.`});
2232
+ }
2233
+ return {type: spec.pptx, spec, series, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, adaptations};
2089
2234
  }
2090
2235
 
2236
+
2091
2237
  /**
2092
2238
  * Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
2093
2239
  * Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
@@ -2113,28 +2259,6 @@ function histogramBins(values) {
2113
2259
  }
2114
2260
  }
2115
2261
 
2116
- function mapChartType(type) {
2117
- const normalized = String(type ?? "").toLowerCase();
2118
- if (normalized.includes("pie")) return { type: "pie" };
2119
- if (normalized.includes("doughnut") || normalized.includes("donut")) return { type: "doughnut" };
2120
- if (normalized.includes("area")) return { type: "area" };
2121
- if (normalized.includes("line") || normalized.includes("sparkline")) return { type: "line" };
2122
- if (normalized.includes("scatter")) return { type: "scatter" };
2123
- if (normalized.includes("radar")) return { type: "radar" };
2124
- if (normalized.includes("bar")) {
2125
- return {
2126
- type: "bar",
2127
- barDir: "bar",
2128
- barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
2129
- };
2130
- }
2131
- return {
2132
- type: "bar",
2133
- barDir: "col",
2134
- barGrouping: normalized.includes("stacked") ? "stacked" : "clustered"
2135
- };
2136
- }
2137
-
2138
2262
  async function resolveImage(asset, presentation, options, path) {
2139
2263
  const assetObject = dereferenceAsset(asset, presentation, path);
2140
2264
  const src = typeof assetObject === "string" ? assetObject : assetObject?.src;
@@ -2353,8 +2477,9 @@ async function normalizePptxZip(raw, context) {
2353
2477
  if(!context.chartHeadings.has(name))continue;
2354
2478
  const id=frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1],chartPart=relationships.get(id)?.path;
2355
2479
  if(!chartPart)throw new OPFPptxError('packaging-failed','Generated chart relationship is missing.');
2356
- const {heading,labelColor}=context.chartHeadings.get(name);
2480
+ const {heading,labelColor,spec}=context.chartHeadings.get(name);
2357
2481
  if(heading!==undefined)writeChartCategoryHeading(entries,chartPart,heading);
2482
+ entries[chartPart]=encodeText(applyChartConstruct(decodeText(entries[chartPart]),spec));
2358
2483
  // PptxGenJS hardcodes a black fallback in pie/doughnut label properties.
2359
2484
  // Normalize only our generated chart text styles; point/series fills stay intact.
2360
2485
  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>`)));
@@ -2368,7 +2493,7 @@ async function normalizePptxZip(raw, context) {
2368
2493
  const relationships = parseRelationships(entries, part);
2369
2494
  for (const [picture] of decodeText(bytes).matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
2370
2495
  const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
2371
- const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name);
2496
+ const placement = context.imagePlacements.get(name) ?? context.slideImages.get(name) ?? (name === watermarkName() ? context.watermarks.get(part) : undefined);
2372
2497
  const id = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
2373
2498
  if (placement) imageSources.set(relationships.get(id)?.path, placement.path);
2374
2499
  }
@@ -2398,6 +2523,11 @@ async function normalizePptxZip(raw, context) {
2398
2523
  placeSlideImages(entries, context.slideImages, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
2399
2524
  throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
2400
2525
  });
2526
+ placeWatermarks(entries, context.watermarks, (part, id) => imageMetadata.get(parseRelationships(entries, part).get(id)?.path), path => {
2527
+ throw new OPFPptxError("unsupported-image-dimensions", "Image fitting requires readable PNG, JPEG, GIF or WebP dimensions. Supply a supported raster image through imageResolver.", { path });
2528
+ });
2529
+ // A picture repeated across slides embeds once (after fitting, which needs each slide's own relationship).
2530
+ dedupeMedia(entries, imageMetadata, resolveRelationshipTarget);
2401
2531
  // Charts and notes follow the language and fonts of the slide they belong to.
2402
2532
  context.partSlides = new Map();
2403
2533
  for (const part of Object.keys(entries)) {
@@ -2507,8 +2637,11 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2507
2637
  xml = xml.replace('</Types>', `${overrides}</Types>`);
2508
2638
  }
2509
2639
  if (/^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(path)) xml = themeMasterBulletFonts(xml);
2640
+ if (context.masterBackground && path === 'ppt/slideMasters/slideMaster1.xml') xml = writeMasterBackground(xml, context.masterBackground);
2641
+ if (context.masterBackground && /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(path)) xml = inheritLayoutBackground(xml);
2510
2642
  if (path === 'ppt/theme/theme1.xml') xml = writeThemeColors(xml, {colors: context.themeColors, schemeName: context.schemeName, themeName: context.themeName});
2511
2643
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
2644
+ xml = writeLinkSentinels(xml, context.linkSentinels);
2512
2645
  const fill = context.backgroundFills.get(path);
2513
2646
  if (typeof fill === 'string') xml = xml.replace(/<p:bg>[\s\S]*?<\/p:bg>/, `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`);
2514
2647
  else if (fill?.image) {
@@ -2579,10 +2712,10 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2579
2712
  for (const [edge, native] of [['left','lnL'],['right','lnR'],['top','lnT'],['bottom','lnB']]) {
2580
2713
  const border = style.borders?.[edge];
2581
2714
  if (!border) continue;
2582
- const borderHex = exportColor(border.color, context, context.colors.border);
2715
+ const slideContext = table.context, borderHex = exportColor(border.color, slideContext, slideContext.colors.border);
2583
2716
  const borderOpacity = borderHex.length === 8 ? parseInt(borderHex.slice(6), 16) / 255 : 1;
2584
- const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, context) : undefined;
2585
- 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);
2717
+ const borderScheme = borderOpacity === 1 ? schemeColorValue(border.color, borderHex, slideContext) : undefined;
2718
+ 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);
2586
2719
  const dash = {solid:'solid',dash:'dash',dot:'sysDot'}[border.dash ?? 'solid'];
2587
2720
  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}>`;
2588
2721
  const existing = new RegExp(`<a:${native}\\b[^>]*>[\\s\\S]*?<\\/a:${native}>`);
@@ -2618,7 +2751,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
2618
2751
  const marker=context.listMarkers.get(shape.match(/name="(OPF list [^"]* line \d+)"/)?.[1]);
2619
2752
  if(!marker)return shape;
2620
2753
  const family=marker.fontFamily.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;'}[char]));
2621
- 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}"/>`);
2754
+ 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}"/>`);
2622
2755
  });
2623
2756
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
2624
2757
  // carries the accessible name the preview gives its group.