@openpresentation/opf-pptx 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -3,8 +3,11 @@ import {autoNumScheme, deriveListNumbering, withDisplayedNumbers} from './number
3
3
  import {isFaceStyleSuffix} from './font-weights.js';
4
4
  import {importTableFrames} from './table-import.js';
5
5
  import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
6
- import {giveNotesMastersOwnThemes} from './master-themes.js';
7
- import {readChartCategoryHeading,writeChartCategoryHeading,repairChartWorkbookRanges} from './chart-workbook.js';
6
+ import {giveNotesMastersOwnThemes, giveSlidesScriptMasters} from './master-themes.js';
7
+ import {legacyVendorOutput} from './vendor-compat.js';
8
+ import {readChartCategoryHeading,writeChartCategoryHeading,writeChartWorkbookFormats,repairChartWorkbookRanges} from './chart-workbook.js';
9
+ import {applyChartNumberFormats,chartNumber,excelCode,formattedColumns,inlineChartData,inlineTableData,isDatasetRef,numericRanges,resolveChartData} from './chart-data.js';
10
+ import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord} from './data-provenance.js';
8
11
  import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
9
12
  import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
10
13
  import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
@@ -35,7 +38,7 @@ import {placeLogos, importLogo, importLogoPlaceholders, logoName, LOGO_TAG} from
35
38
  import {nativeBackgroundFill, nativeImageBackgroundFill, nativePatternPreset} from './background.js';
36
39
  import {importBackground} from './background-import.js';
37
40
  import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
38
- import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, reconcileLanguage, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
41
+ import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
39
42
  import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
40
43
  import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
41
44
  import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
@@ -261,6 +264,11 @@ export async function toPptx(input, options = {}) {
261
264
  context.chartFonts = new Map();
262
265
  context.chartPalettes = new Map();
263
266
  context.chartex = new Map();
267
+ // RR-54: the authored data form of charts and tables (OPF_DATA_V1) and the datasets map (OPF_DATASETS_V1), `full` mode only.
268
+ context.dataRecords = new Map();
269
+ context.dataRecordPaths = new Map();
270
+ context.datasets = presentation.datasets;
271
+ context.reportDiagnostic = options.onDiagnostic;
264
272
  context.imageFormat = options.imageFormat ?? "compatible";
265
273
  // Chartex export mode (FF-22b): 'auto' (default) writes native chartex parts for the constructs the native PowerPoint
266
274
  // check confirmed (treemap, histogram, pareto, box-and-whisker, waterfall, funnel) and the clustered column fallback
@@ -301,9 +309,9 @@ export async function toPptx(input, options = {}) {
301
309
 
302
310
  let raw;
303
311
  try {
312
+ // The package is unzipped and re-zipped by normalizePptxZip, so the intermediate ZIP is stored uncompressed.
304
313
  raw = await withDeterministicRandom(context.seed, () => pptx.write({
305
- outputType: "uint8array",
306
- compression: true
314
+ outputType: "uint8array"
307
315
  }));
308
316
  } catch (error) {
309
317
  throw new OPFPptxError("pptxgen-failed", "PPTX generation failed.", {
@@ -339,9 +347,17 @@ export async function fromPptx(input, options = {}) {
339
347
  };
340
348
 
341
349
  // FF-07: the language the runs carry. A stored FF-32 reference can still win below.
350
+ const languageThemePath = presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries);
351
+ // opf-pptx#168: each slide's theme, through its layout and master (several masters carry per-slide script fonts).
352
+ const relatedPart = (path, type) => path ? [...parseRelationships(entries, path).values()].find(rel => rel.type?.endsWith(`/${type}`) && rel.path && entries[rel.path])?.path : undefined;
342
353
  const observedLanguage = observeLanguage({
343
354
  slides: slidePaths.map(path => decodeText(entries[path])),
344
- theme: (path => path && entries[path] ? decodeText(entries[path]) : null)(presentationThemePath(presentationRoot, presentationRels, path => parseRelationships(entries, path), entries)),
355
+ theme: languageThemePath && entries[languageThemePath] ? decodeText(entries[languageThemePath]) : null,
356
+ themePath: languageThemePath,
357
+ slideThemes: slidePaths.map(path => {
358
+ const theme = relatedPart(relatedPart(relatedPart(path, 'slideLayout'), 'slideMaster'), 'theme');
359
+ return theme ? {path: theme, xml: decodeText(entries[theme])} : null;
360
+ }),
345
361
  catalogs: bundledCatalogs
346
362
  });
347
363
  if (observedLanguage.language !== undefined) imported.language = observedLanguage.language;
@@ -369,11 +385,15 @@ export async function fromPptx(input, options = {}) {
369
385
  if (Object.keys(furniture.design).length) imported.design = {...imported.design, ...furniture.design};
370
386
  if (furniture.organization) imported.organization = furniture.organization;
371
387
 
388
+ // RR-54: the datasets recorded at export (OPF_DATASETS_V1). Chart and table frames restore their dataset references against
389
+ // them, so the document carries them before document provenance validates the restored document.
390
+ const datasets = readDatasetsTag(entries, presentationRoot, presentationRels, options.onDiagnostic);
372
391
  const mediaRegistry = Object.create(null);
373
392
  for (let index = 0; index < slidePaths.length; index += 1) {
374
393
  furnitureContexts[index].mediaRegistry = mediaRegistry;
375
- imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage)}, furniture.slides[index], furnitureContexts[index]));
394
+ imported.slides.push(importSlide(entries, slidePaths[index], index, dimensions, {...options, rtlDeck: observedRtl(observedLanguage), dataProvenance: {datasets}}, furniture.slides[index], furnitureContexts[index]));
376
395
  }
396
+ if (datasets) imported.datasets = datasets;
377
397
  // Native sections (PowerPoint's own section list, `Default Section` = none)
378
398
  // are reconciled with the footer text and the stored value in restoreDocumentProvenance.
379
399
  const slideSections = nativeSections(presentationRoot, slideIdsInOrder(presentationRoot, presentationRels, entries, slidePaths));
@@ -797,8 +817,10 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
797
817
  ? importTableFrames(slidePath, {
798
818
  part: (path, parser) => parseRequiredXml(entries, path, parser), relationships: path => parseRelationships(entries, path), bytes: path => entries[path]
799
819
  }, relationships, (frame, cell, code, message) => options.onDiagnostic?.({code, message, path: `slides.${slideIndex}.tables.${frame}${cell ? '.' + cell : ''}`}), dimensions, {rtlDeck: options.rtlDeck === true}) : [];
820
+ // RR-54: chart and table data records (OPF_DATA_V1) restore against the stored datasets; their diagnostics name the frame.
821
+ const dataContext = label => ({datasets: options.dataProvenance?.datasets, report: (kind, diagnostic) => options.onDiagnostic?.({...diagnostic, path: `slides.${slideIndex}.${kind === 'table' ? 'tables' : 'charts'}.${label}`})});
800
822
  for (const [index, frame] of frames.entries()) {
801
- const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`));
823
+ const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index], chartReport(`charts.${index}`), dataContext(index));
802
824
  if (item) items.push({...item, sources: [`frame:${index}`]});
803
825
  }
804
826
  // A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
@@ -806,8 +828,8 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
806
828
  for (const [alternateIndex, alternate] of asArray(tree?.["mc:AlternateContent"]).entries()) {
807
829
  const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
808
830
  const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
809
- const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : null;
810
- const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`)) : chosen;
831
+ const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`), dataContext(`alt${alternateIndex}`)) : null;
832
+ const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships, undefined, chartReport(`charts.alt${alternateIndex}`), dataContext(`alt${alternateIndex}`)) : chosen;
811
833
  if (item && item.kind !== "unknown") items.push({...item, sources: [`alt:${alternateIndex}`]});
812
834
  }
813
835
 
@@ -849,19 +871,39 @@ function importShape(shape, dimensions, paragraphs = readParagraphs(shape["p:txB
849
871
  };
850
872
  }
851
873
 
852
- function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report) {
874
+ function importGraphicFrame(entries, frame, slidePath, relationships, importedTable, report, data = {}) {
853
875
  const bounds = shapeBounds(frame["p:xfrm"]);
854
876
  const name = scalarText(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]?.name).trim();
855
877
  const graphicData = frame["a:graphic"]?.["a:graphicData"];
856
878
  const table = graphicData?.["a:tbl"];
879
+ // RR-54: the frame's chart or table data record (src/data-provenance.js); an unreadable record is reported and ignored.
880
+ const record = kind => {
881
+ try {
882
+ const value = readDataTag(entries, frame, relationships);
883
+ return value?.kind === kind ? value : undefined;
884
+ } catch (error) {
885
+ data.report?.(kind, {code: 'invalid-data-provenance', message: `${errorMessage(error)} The ${kind} imports the values it shows.`});
886
+ return undefined;
887
+ }
888
+ };
889
+ // A record that passed its checks but cannot be restored (it fails validation in a way that throws) is reported too.
890
+ const restore = (kind, imported, apply) => {
891
+ try {
892
+ return apply();
893
+ } catch (error) {
894
+ data.report?.(kind, {code: 'invalid-data-provenance', message: `${errorMessage(error)} The ${kind} imports the values it shows.`});
895
+ return imported;
896
+ }
897
+ };
857
898
  if (table) {
899
+ const stored = importedTable && record('table');
858
900
  return {
859
901
  kind: "table",
860
902
  bounds,
861
903
  name,
862
904
  payload: {
863
905
  type: "table",
864
- table: importedTable
906
+ table: stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable
865
907
  }
866
908
  };
867
909
  }
@@ -869,9 +911,14 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
869
911
  const chartExRelId = graphicData?.uri === CHARTEX_GRAPHIC_DATA_URI ? graphicData?.["cx:chart"]?.["r:id"] : undefined;
870
912
  const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
871
913
  if (chartRelId) {
872
- const chart = chartExRelId
914
+ const imported = chartExRelId
873
915
  ? chartexFromRelationship(entries, relationships, chartExRelId, report)
874
916
  : chartFromRelationship(entries, slidePath, relationships, chartRelId, report);
917
+ const stored = imported && record('chart');
918
+ const part = relationships.get(chartRelId)?.path;
919
+ const chart = stored && part && entries[part]
920
+ ? restore('chart', imported, () => restoreChartData(imported, stored, chartEvidence(decodeText(entries[part])), {datasets: data.datasets, report: diagnostic => data.report?.('chart', diagnostic)}))
921
+ : imported;
875
922
  return {
876
923
  kind: "chart",
877
924
  bounds,
@@ -1174,18 +1221,22 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1174
1221
 
1175
1222
  const budget = { cells: 0 };
1176
1223
  const cachePath = (index, role) => `${relationship.path}#c:ser[${index}]/${role}`;
1224
+ // RR-54: each series' number format code (c:numCache or c:numLit formatCode); core maps the codes it can back to column formats.
1225
+ const valueCodes = series.map(entry => cacheFormatCode(entry?.["c:val"] ?? entry?.["c:yVal"]));
1226
+ const xCode = chartNode.type === 'scatter' ? cacheFormatCode(series[0]?.["c:xVal"]) : undefined;
1177
1227
  // RR-35: axis titles, legend position and data labels read back into the chart's option fields.
1178
1228
  const withOptions = chart => {
1179
- const {options, notes} = chartOptionsFromClassic(doc["c:chartSpace"], {chartNode: chartNode.node, target: chartTargetFor(chartNode.type), seriesCount: series.length, circular: chartNode.type === 'pie' || chartNode.type === 'doughnut', scatter: chartNode.type === 'scatter'});
1229
+ const {options, notes} = chartOptionsFromClassic(doc["c:chartSpace"], {chartNode: chartNode.node, target: chartTargetFor(chartNode.type), seriesCount: series.length, circular: chartNode.type === 'pie' || chartNode.type === 'doughnut', scatter: chartNode.type === 'scatter', seriesFormats: valueCodes});
1180
1230
  for (const note of notes) report?.(note);
1231
+ if (budget.rejected?.length) report?.({code: 'chart-value-not-numeric', option: 'data', message: `${budget.rejected.length} cached chart ${budget.rejected.length === 1 ? 'value is' : 'values are'} not a number (first at ${budget.rejected[0]}) and ${budget.rejected.length === 1 ? 'imports as a gap' : 'import as gaps'}, never as a guessed value.`});
1181
1232
  return chart && Object.keys(options).length ? {...chart, ...options} : chart;
1182
1233
  };
1183
- if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath));
1234
+ if (chartNode.type === 'scatter') return withOptions(scatterFromSeries(entries, relationship.path, series, budget, cachePath, {codes: valueCodes, xCode, report}));
1184
1235
  const labels = cachedValues(series[0]?.["c:cat"], cachePath(0, 'c:cat'), budget);
1185
1236
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, 'c:tx'), budget) ?? `Series ${index + 1}`);
1186
1237
  const values = series.map((entry, index) => {
1187
1238
  const role = entry?.["c:val"] !== undefined ? 'c:val' : 'c:yVal';
1188
- return cachedValues(entry?.[role], cachePath(index, role), budget, numericCacheValue);
1239
+ return cachedValues(entry?.[role], cachePath(index, role), budget, strictCacheValue(budget));
1189
1240
  });
1190
1241
  const rowCount = values.reduce((count, row) => Math.max(count, row.length), labels.length);
1191
1242
  if (rowCount === 0) return null;
@@ -1204,21 +1255,39 @@ function chartFromRelationship(entries, slidePath, relationships, relId, report)
1204
1255
  return withOptions({
1205
1256
  type: chartNode.type,
1206
1257
  data: {
1207
- columns: [readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names],
1258
+ columns: formattedColumns([readChartCategoryHeading(entries,relationship.path) ?? "Category", ...names], [undefined, ...valueCodes], report),
1208
1259
  rows
1209
1260
  }
1210
1261
  });
1211
1262
  }
1212
1263
 
1264
+ // The format code of a numeric cache (c:numRef/c:numCache or c:numLit), or undefined.
1265
+ function cacheFormatCode(node) {
1266
+ const cache = node?.["c:numRef"]?.["c:numCache"] ?? node?.["c:numLit"];
1267
+ const code = cache?.["c:formatCode"];
1268
+ return code === undefined ? undefined : scalarText(code);
1269
+ }
1270
+
1271
+ // RR-54: a cached value is a number in strict decimal syntax (core chartNumber) or another XML number form (a leading '+',
1272
+ // leading zeros, '.5'); anything else is a gap, counted for one chart-value-not-numeric diagnostic. Exponent tokens keep their
1273
+ // own range checks (numericCacheValue).
1274
+ function strictCacheValue(budget) {
1275
+ return (value, path) => {
1276
+ const number = numericCacheValue(value, path);
1277
+ if (number === null && value !== null && value.trim() !== '') (budget.rejected ??= []).push(path);
1278
+ return number;
1279
+ };
1280
+ }
1281
+
1213
1282
  // Scatter charts share X values (c:xVal) across Y series (c:yVal). OPF keeps
1214
1283
  // them category-major: [point label, X, Y1, Y2, ...]; native charts carry no
1215
1284
  // point labels, so points are numbered.
1216
- function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
1285
+ function scatterFromSeries(entries, chartPart, series, budget, cachePath, {codes = [], xCode, report} = {}) {
1217
1286
  // The same cache helpers as every other chart: bounded, indexed by c:pt@idx, with gaps kept as null (never plotted as 0).
1218
1287
  const xRole = series[0]?.["c:xVal"];
1219
- const xs = cachedValues(xRole, cachePath(0, "c:xVal"), budget, numericCacheValue);
1288
+ const xs = cachedValues(xRole, cachePath(0, "c:xVal"), budget, strictCacheValue(budget));
1220
1289
  const names = series.map((entry, index) => firstCachedValue(entry?.["c:tx"], cachePath(index, "c:tx"), budget) ?? `Series ${index + 1}`);
1221
- const values = series.map((entry, index) => cachedValues(entry?.["c:yVal"], cachePath(index, "c:yVal"), budget, numericCacheValue));
1290
+ const values = series.map((entry, index) => cachedValues(entry?.["c:yVal"], cachePath(index, "c:yVal"), budget, strictCacheValue(budget)));
1222
1291
  const rowCount = values.reduce((count, row) => Math.max(count, row.length), xs.length);
1223
1292
  if (rowCount === 0) return null;
1224
1293
  if (rowCount * (series.length + 2) > MAX_CHART_CACHE_CELLS) {
@@ -1227,7 +1296,7 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
1227
1296
  const rows = [];
1228
1297
  // A chart without c:xVal plots against 1..n natively; a c:xVal gap stays a gap.
1229
1298
  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)]);
1230
- return { type: "scatter", data: { columns: ["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], rows } };
1299
+ return { type: "scatter", data: { columns: formattedColumns(["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], [undefined, xCode, ...codes], report), rows } };
1231
1300
  }
1232
1301
 
1233
1302
  // A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
@@ -1260,13 +1329,17 @@ function firstChartNode(plotArea) {
1260
1329
  const MAX_CHART_CACHE_POINTS = 100_000;
1261
1330
  const MAX_CHART_CACHE_CELLS = 1_000_000;
1262
1331
 
1263
- // Import complete decimal exponent tokens without the exporter's legacy
1264
- // character stripping. Other numeric strings deliberately retain that policy.
1332
+ // Import complete decimal exponent tokens with their range checks. RR-54: every other token is an XML decimal (xsd:double
1333
+ // without an exponent: a leading '+', leading zeros, '.5', '5.', and any number of digits, so 1e20, which the exporter
1334
+ // writes as "100000000000000000000", reads back); anything else is a gap, never a stripped or guessed value.
1265
1335
  function numericCacheValue(value, path) {
1266
1336
  if (value === null || value.trim() === '') return null;
1267
1337
  const token = value.trim();
1268
1338
  const exponent = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))[eE][+-]?\d+$/.exec(token);
1269
- if (!exponent) return numericValue(value);
1339
+ if (!exponent) {
1340
+ const number = /^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(token) ? Number(token) : NaN;
1341
+ return Number.isFinite(number) ? number : null;
1342
+ }
1270
1343
  const parsed = Number(token);
1271
1344
  if (!Number.isFinite(parsed) || (parsed === 0 && /[1-9]/.test(exponent[1]))) {
1272
1345
  const reason = Number.isFinite(parsed) ? 'underflows to zero' : 'overflows';
@@ -1824,10 +1897,10 @@ async function addPayload(slide, presentation, payload, region, path, context, o
1824
1897
  addMediaPayload(slide, presentation, payload.video, region, path, context, options);
1825
1898
  break;
1826
1899
  case "chart":
1827
- addChartPayload(slide, payload.chart, region, context, options, path);
1900
+ addChartPayload(slide, payload.chart, region, context, options, path, presentation);
1828
1901
  break;
1829
1902
  case "table":
1830
- addTablePayload(slide, payload.table, region, context, options, path);
1903
+ addTablePayload(slide, payload.table, region, context, options, path, presentation);
1831
1904
  break;
1832
1905
  case "code":
1833
1906
  addCodePayload(slide, payload.code, codeLayout, region, context, path, options);
@@ -2111,14 +2184,17 @@ function applyChartTextSize(xml, size) {
2111
2184
  `<a:defRPr${/\bsz="[^"]*"/.test(attributes) ? attributes.replace(/\bsz="[^"]*"/, `sz="${size}"`) : `${attributes} sz="${size}"`}${close}>`));
2112
2185
  }
2113
2186
 
2114
- function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
2115
- const chartData = toPptxChartData(chart, context.chartexMode);
2187
+ function addChartPayload(slide, chart, region, context, options = {}, path = "chart", presentation) {
2188
+ const chartData = toPptxChartData(chart, context.chartexMode, presentation);
2116
2189
  if (!chartData.series) {
2117
2190
  // Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
2118
2191
  options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
2119
2192
  addPlaceholderPayload(slide, "Chart", chartData.summary, region, context);
2120
2193
  return;
2121
2194
  }
2195
+ // RR-54: one strict-number diagnostic per chart, and core's mapping warnings (chart-mapping-adapted) on the chart's path.
2196
+ if (chartData.notNumeric) options.onDiagnostic?.({code: "chart-value-not-numeric", path, message: chartData.notNumeric.message, count: chartData.notNumeric.count});
2197
+ for (const entry of chartData.diagnostics ?? []) options.onDiagnostic?.({code: entry.code, path, message: entry.message, pointer: entry.path});
2122
2198
  for (const {adaptation, message} of chartData.adaptations ?? []) options.onDiagnostic?.({code: "chart-data-adapted", path, message, adaptation});
2123
2199
  // RR-35: axis titles, legend position and data labels (core resolves them against what the chart type can show).
2124
2200
  const chartOptions = resolveChartOptionsFor(chart);
@@ -2133,8 +2209,13 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2133
2209
  const objectName = `OPF chart ${context.chartHeadings.size + 1}`;
2134
2210
  const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
2135
2211
  const textSize = chartTextSize(context);
2136
- context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec,textSize,
2137
- options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chart.data.rows.length});
2212
+ // RR-54: the columns' number formats (Excel codes per exported series) for the caches, labels, value axis and workbook.
2213
+ // The value axis shows the first plotted series' format (General when it has none), as the preview does.
2214
+ const numberFormats = chartData.formats && {...chartData.formats, axis: chartData.spec.grouping === 'percentStacked' ? undefined : chartData.formats.series[0],
2215
+ labels: !chartOptions?.dataLabels?.content?.includes('percent'), scatter: chartData.type === 'scatter'};
2216
+ context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading,labelColor,spec:chartData.spec,textSize,
2217
+ options:chartOptions,kind:chartTargetFor(chart.type)?.kind,pointCount:chartData.pointCount,numberFormats});
2218
+ recordPayloadData(context, objectName, 'chart', presentation, path);
2138
2219
  context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
2139
2220
  const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
2140
2221
  const palette = (typeof opfComposition.chartPaletteForFill === "function" ? opfComposition.chartPaletteForFill(panelFill, preferredPalette) : preferredPalette.map(color=>chartColorForFill(panelFill,color))).map(color=>normalizeHex(color));
@@ -2143,7 +2224,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2143
2224
  // The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
2144
2225
  // PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
2145
2226
  const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
2146
- context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize, options: chartOptions});
2227
+ context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette, textSize, options: chartOptions, ...(numberFormats ? {formats: numberFormats.series} : {})});
2147
2228
  if (chartData.chartex.layoutId === 'regionMap') {
2148
2229
  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 (no cx:geoCache; provider data is never fabricated): PowerPoint must fetch the region shapes from its online map service when the deck is opened, and until it does it shows "There was a problem getting the information for your map chart" and draws nothing. The clustered column fallback shows the same values in readers without chartex support.`});
2149
2230
  }
@@ -2193,7 +2274,25 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
2193
2274
  });
2194
2275
  }
2195
2276
 
2196
- function addTablePayload(slide, table, region, context, options, path) {
2277
+ // RR-54: a chart or table that uses a dataset, DataColumn objects, a mapping, a data source or number formats records its
2278
+ // authored data form on its native frame (src/data-provenance.js), in `full` provenance mode only. `path` is the composed
2279
+ // item's path ("slides.0.blocks.1.chart"), which names the authored value (composition hands over the inline copy).
2280
+ function recordPayloadData(context, objectName, kind, presentation, path, layout) {
2281
+ if (context.provenanceMode !== 'full' || !context.dataRecords || !presentation) return;
2282
+ let authored = presentation;
2283
+ for (const key of String(path).split('.')) authored = authored !== null && typeof authored === 'object' ? authored[Array.isArray(authored) ? Number(key) : key] : undefined;
2284
+ const record = kind === 'chart' ? chartDataRecord(authored, presentation.datasets) : tableDataRecord(authored, layout);
2285
+ if (record) {
2286
+ context.dataRecords.set(objectName, record);
2287
+ context.dataRecordPaths.set(objectName, String(path));
2288
+ }
2289
+ }
2290
+
2291
+ function addTablePayload(slide, authoredTable, region, context, options, path, presentation) {
2292
+ // RR-54: a dataset-backed table is laid out as its inline copy (composition normally hands that over already); core's
2293
+ // layout gives each body cell its display text (a number with a format becomes its formatted text) and each DataColumn
2294
+ // header its name.
2295
+ const table = inlineTableData(authoredTable, presentation);
2197
2296
  const scale = Math.min(context.dimensions.widthInches * 96, context.dimensions.heightInches * 96) / 720;
2198
2297
  const hasHeaders = Array.isArray(table?.columns) && table.columns.length > 0;
2199
2298
  const sourceRows = [...(hasHeaders ? [table.columns] : []), ...(table?.rows ?? [])];
@@ -2205,7 +2304,7 @@ function addTablePayload(slide, table, region, context, options, path) {
2205
2304
 
2206
2305
  const layout = layoutTable(table, {x:region.x*96,y:region.y*96,width:region.w*96,height:region.h*96}, {
2207
2306
  scale, minFontSize:context.composition?.minFontSize, fontFamily:context.fonts.body, textMeasurement:options.textMeasurement, path,
2208
- ...(context.direction === 'rtl' ? {direction: 'rtl'} : {})
2307
+ ...(context.direction === 'rtl' ? {direction: 'rtl'} : {}), ...(presentation ? {presentation} : {})
2209
2308
  });
2210
2309
  const columnCount = layout.columnCount;
2211
2310
  const rows = layout.rows.map(row => row.cells.map(cell => {
@@ -2268,6 +2367,7 @@ function addTablePayload(slide, table, region, context, options, path) {
2268
2367
  }));
2269
2368
  const objectName = `OPF table ${context.tableHeaders.size + 1}`;
2270
2369
  context.tableHeaders.set(objectName, hasHeaders);
2370
+ recordPayloadData(context, objectName, 'table', presentation, path, layout);
2271
2371
  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}});
2272
2372
  slide.addTable(rows, {
2273
2373
  objectName,
@@ -2745,15 +2845,31 @@ function textRuns(value, context, fallbackFontSize) {
2745
2845
  * (histogram, dot plot) have no category column, so their values are plotted directly: a histogram is binned
2746
2846
  * into equal-width bins and exported as a column chart of the counts, every other type plots the values against
2747
2847
  * their row numbers. `adapted` names that transformation so it is reported, never silent.
2848
+ *
2849
+ * RR-54: the data is resolved by core (`resolveChartData`, docs/chart-table-data.md): a dataset reference, DataColumn
2850
+ * objects and `chart.mapping` become the canonical positional table [category, (x,) ...series], and every value cell
2851
+ * goes through core's strict `chartNumber` (a string that is not a plain decimal is a gap, reported once per chart as
2852
+ * `chart-value-not-numeric`). `formats` holds the Excel codes of the columns' number formats per exported series.
2748
2853
  */
2749
- function toPptxChartData(chart, chartexMode = 'auto') {
2750
- const data = chart?.data;
2854
+ function toPptxChartData(chart, chartexMode = 'auto', presentation) {
2751
2855
  const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
2752
- if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
2856
+ // A dataset reference is inlined from the document first (composition normally hands over the inline copy already).
2857
+ const source = isDatasetRef(chart?.data) ? inlineChartData(chart, presentation) : chart;
2858
+ const authored = source?.data;
2859
+ if (isDatasetRef(authored)) {
2860
+ return unplottable("dataset-unknown", "The chart's dataset is missing, so it cannot be drawn here.", `The chart references dataset '${stringifyText(authored.dataset)}', which the document does not hold (or a field it names).`);
2861
+ }
2862
+ if (!authored || !Array.isArray(authored.columns) || !Array.isArray(authored.rows)) {
2753
2863
  return unplottable("data-not-inline", "Chart data is not inline, so it cannot be drawn here.", "The chart data is not inline columns and rows (for example an external data source). Supply inline columns and rows.");
2754
2864
  }
2755
- if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2756
- if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
2865
+ if (authored.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
2866
+ if (authored.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
2867
+ const resolvedData = resolveChartData(source, presentation);
2868
+ if (!resolvedData.ok) return unplottable(resolvedData.reason, "The chart data cannot be drawn here.", resolvedData.message);
2869
+ const data = {columns: resolvedData.columns, rows: resolvedData.rows};
2870
+ const columnCodes = (resolvedData.formats ?? []).map(excelCode);
2871
+ const diagnostics = resolvedData.diagnostics.filter((entry) => entry.severity === 'warning' && entry.code !== 'chart-value-not-numeric');
2872
+ const rejected = resolvedData.diagnostics.filter((entry) => entry.code === 'chart-value-not-numeric');
2757
2873
  const resolved = resolveChartType(chart.type).spec;
2758
2874
  // A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. Natively
2759
2875
  // it is written as its cx:chartSpace part and the clustered column chart of the same data is its mc:Fallback
@@ -2771,61 +2887,94 @@ function toPptxChartData(chart, chartexMode = 'auto') {
2771
2887
  : 'that PowerPoint has not yet accepted natively from this exporter (pass chartex: \'native\' to write its chartex part)';
2772
2888
  adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart ${reason}; its data is exported as a native clustered column chart instead.`});
2773
2889
  }
2774
- const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
2890
+ const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, diagnostics, heading: data.columns[0], pointCount: data.rows.length};
2891
+ // Number formats only when a column has one: a chart without formats carries no `formats` and is written as before.
2892
+ const withFormats = (result, formats) => formats.series.some((code) => code !== undefined) || formats.x !== undefined ? {...result, formats} : result;
2775
2893
  if (data.columns.length === 1) {
2776
- const heading = stringifyText(data.columns[0]);
2777
- const cell = (row) => Array.isArray(row) ? row[0] : row;
2778
- // The same parsing as multi-column charts (`numericValue`: "12%", "$5", "1,234"); a cell that holds no number is skipped, never plotted as 0.
2779
- const points = data.rows.map((row, index) => ({row: index + 1, value: parsedNumber(cell(row))})).filter((point) => point.value !== null);
2780
- if (points.length === 0) {
2894
+ const heading = data.columns[0];
2895
+ // The lone column holds the chart's values. Core (opf#376) already reads it with chartNumber and reports each
2896
+ // non-numeric cell; a core without RR-54 hands it over as authored, so it is read (and its rejects counted) here. A
2897
+ // cell that holds no number is skipped, never plotted as 0.
2898
+ const points = data.rows.map((row, index) => ({row: index + 1, value: chartNumber(row?.[0]), cell: row?.[0]}));
2899
+ for (const point of points) if (point.value === null && point.cell !== null && point.cell !== undefined && point.cell !== '') rejected.push({path: `/data/rows/${point.row - 1}/0`, cell: point.cell});
2900
+ const plottedPoints = points.filter((point) => point.value !== null);
2901
+ const notNumeric = notNumericDiagnostic(rejected);
2902
+ if (plottedPoints.length === 0) {
2781
2903
  return unplottable("single-column-not-numeric", "The chart's data column has no numbers.", `The only chart data column '${heading}' holds no numbers, and a chart needs values to plot.`);
2782
2904
  }
2783
- const skipped = data.rows.length - points.length;
2905
+ const skipped = data.rows.length - plottedPoints.length;
2784
2906
  const skippedNote = skipped ? ` (${skipped} non-numeric ${skipped === 1 ? "cell was" : "cells were"} skipped)` : "";
2785
2907
  if (!native && String(chart.type ?? "").toLowerCase() === "histogram") {
2786
- const bins = histogramBins(points.map((point) => point.value));
2908
+ const bins = histogramBins(plottedPoints.map((point) => point.value));
2787
2909
  return {
2788
- type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin",
2910
+ type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin", diagnostics, notNumeric, pointCount: bins.length,
2789
2911
  series: [{name: "Frequency", labels: bins.map((bin) => bin.label), values: bins.map((bin) => bin.count)}],
2790
2912
  adaptations: [{
2791
2913
  adaptation: "histogram-binned",
2792
- 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.`
2914
+ message: `The histogram's single data column '${heading}' (${plottedPoints.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.`
2793
2915
  }]
2794
2916
  };
2795
2917
  }
2796
2918
  // Rows keep their own row numbers, so a skipped cell leaves a gap in the numbering.
2797
- const labels = points.map((point) => String(point.row));
2798
- const numbers = points.map((point) => point.value);
2799
- const series = mapped.type === "scatter"
2800
- ? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
2919
+ const labels = plottedPoints.map((point) => String(point.row));
2920
+ const numbers = plottedPoints.map((point) => point.value);
2921
+ const scatter = mapped.type === "scatter";
2922
+ const series = scatter
2923
+ ? [{name: "Row", labels, values: plottedPoints.map((point) => point.row)}, {name: heading, labels, values: numbers}]
2801
2924
  : [{name: heading, labels, values: numbers}];
2925
+ const formats = {series: [columnCodes[0]]};
2802
2926
  // A native histogram or Pareto chart bins the values themselves (PowerPoint's automatic bins); only its classic fallback plots them against row numbers.
2803
- if (chartex?.binning) return {...mapped, series, heading: "Row", hasCategories: false, adaptations};
2804
- 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.`});
2805
- return {...mapped, series, heading: "Row", hasCategories: true, adaptations};
2927
+ if (chartex?.binning) return withFormats({...mapped, series, heading: "Row", hasCategories: false, adaptations, notNumeric}, formats);
2928
+ adaptations.push({adaptation: "row-numbers", message: `The chart's single data column '${heading}' has no category column, so its ${plottedPoints.length} values${skippedNote} are plotted against their row numbers.`});
2929
+ return withFormats({...mapped, series, heading: "Row", hasCategories: true, adaptations, notNumeric}, formats);
2806
2930
  }
2807
2931
 
2808
2932
  const labels = data.rows.map((row) => stringifyText(row?.[0]));
2809
2933
  let series = data.columns.slice(1).map((name, seriesIndex) => ({
2810
- name: stringifyText(name),
2934
+ name,
2811
2935
  labels,
2812
2936
  // A cell that holds no number (null, an empty or non-numeric string, a boolean) is a gap, as in the preview and the single-column
2813
- // path above: it is neither plotted as a zero nor written into the cache as one.
2814
- values: data.rows.map((row) => parsedNumber(row?.[seriesIndex + 1]))
2937
+ // path above: it is neither plotted as a zero nor written into the cache as one. Core already passed it through chartNumber.
2938
+ values: data.rows.map((row) => row?.[seriesIndex + 1] ?? null)
2815
2939
  }));
2940
+ let codes = columnCodes.slice(1);
2816
2941
  const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
2817
2942
  if (series.length > plotted) {
2818
2943
  // A pie, doughnut or single-series chartex construct plots one series; name the ones left out instead of dropping them silently.
2819
2944
  const dropped = series.slice(plotted).map((entry) => `'${entry.name}'`);
2820
2945
  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.`});
2821
2946
  series = series.slice(0, plotted);
2947
+ codes = codes.slice(0, plotted);
2822
2948
  }
2949
+ // Only the exported cells count. Core reports a cell at its authored column, which is its resolved position (1 for the
2950
+ // first series) unless a mapping reorders the columns; a series that a pie or a single-series construct drops is not counted.
2951
+ const position = (entry) => {
2952
+ const column = Number(/\/(\d+)$/.exec(entry.path ?? '')?.[1]);
2953
+ return source.mapping === undefined ? column : data.columns.indexOf(dataColumnName(authored.columns[column]));
2954
+ };
2955
+ const notNumeric = notNumericDiagnostic(rejected.filter((entry) => !(position(entry) > plotted)));
2823
2956
  if (spec.family === 'xy' && series.length === 1) {
2824
2957
  // [Point, X, Y...] carries its own X column; a lone value column is plotted against the row numbers.
2825
2958
  series = [{name: 'X', labels, values: data.rows.map((_, index) => index + 1)}, ...series];
2959
+ codes = [undefined, ...codes];
2826
2960
  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.`});
2827
2961
  }
2828
- return {...mapped, series, hasCategories: true, adaptations};
2962
+ // A scatter chart's first series is its X values (PptxGenJS writes one c:ser per Y series, each with c:xVal).
2963
+ const formats = spec.family === 'xy' ? {series: codes.slice(1), x: codes[0]} : {series: codes};
2964
+ return withFormats({...mapped, series, hasCategories: true, adaptations, notNumeric}, formats);
2965
+ }
2966
+
2967
+ /** A chart column's name: the string, or a DataColumn's `name`. */
2968
+ function dataColumnName(column) {
2969
+ return typeof column === 'string' ? column : column !== null && typeof column === 'object' && typeof column.name === 'string' ? column.name : undefined;
2970
+ }
2971
+
2972
+ /** One chart-value-not-numeric diagnostic per chart (core reports one per cell), or undefined. */
2973
+ function notNumericDiagnostic(rejected) {
2974
+ if (!rejected.length) return undefined;
2975
+ const first = rejected[0];
2976
+ const shown = Object.hasOwn(first, 'cell') ? JSON.stringify(first.cell) : /chart value (.*) is not a number/.exec(first.message ?? '')?.[1] ?? 'a value';
2977
+ return {count: rejected.length, pointer: first.path, message: `${rejected.length} chart ${rejected.length === 1 ? 'value is' : 'values are'} not ${rejected.length === 1 ? 'a number' : 'numbers'} (first: ${shown} at ${first.path}) and ${rejected.length === 1 ? 'is' : 'are'} exported as ${rejected.length === 1 ? 'a gap' : 'gaps'}. Chart values are numbers, or strings in plain decimal syntax (12, -3.5, 1e6); put units, currency and percent in the column's number format.`};
2829
2978
  }
2830
2979
 
2831
2980
 
@@ -2878,7 +3027,11 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
2878
3027
  }
2879
3028
  if (!resolved?.data) return resolved;
2880
3029
  const bytes = dataUriBytes(resolved.data);
2881
- if (bytes && rasterMetadata(bytes)) return resolved;
3030
+ const raster = bytes && rasterMetadata(bytes);
3031
+ // A raster declared as another type (for example a host that resolves an SVG asset to PNG bytes) is embedded as the
3032
+ // raster it is. PptxGenJS treats an image/svg+xml data URI as an SVG picture and adds its own fallback, a broken-image
3033
+ // placeholder in Node (pptxgenjs-plus; PptxGenJS 4.0.1 pointed the svgBlip at the raster itself).
3034
+ if (raster) return dataUriMediaType(resolved.data) === raster.mediaType ? resolved : {...resolved, data: `data:${raster.mediaType};base64,${bytesToBase64(bytes)}`};
2882
3035
  const svg = svgDataUriBytes(resolved.data);
2883
3036
  if (svg) return resolveSvgImage(svg, options, path, outcome);
2884
3037
  const message = `The image is not a readable PNG, JPEG, GIF or WebP (${dataUriMediaType(resolved.data) ?? "unknown type"}); supply a raster through imageResolver (for example opf-render svgToPng).`;
@@ -3186,7 +3339,8 @@ function writeNativeFurnitureMasters(output, context) {
3186
3339
  async function normalizePptxZip(raw, context) {
3187
3340
  let entries;
3188
3341
  try {
3189
- entries = unzipSync(raw);
3342
+ // RR-17: the pptxgenjs-plus output changes opf-pptx does not take (src/vendor-compat.js).
3343
+ entries = legacyVendorOutput(unzipSync(raw));
3190
3344
  } catch (error) {
3191
3345
  throw new OPFPptxError("packaging-failed", "Generated PPTX could not be read back as a ZIP.", {
3192
3346
  cause: errorMessage(error)
@@ -3226,6 +3380,13 @@ async function normalizePptxZip(raw, context) {
3226
3380
  // RR-35: data labels last, so their own text colours (contrast inside a mark) are not normalized away.
3227
3381
  const {options:chartOptions,kind:optionKind,pointCount}=context.chartHeadings.get(name);
3228
3382
  if(chartOptions?.dataLabels)entries[chartPart]=encodeText(applyDataLabels(decodeText(entries[chartPart]),{resolved:chartOptions,kind:optionKind,palette:context.chartPalettes.get(name),labelColor,font:context.chartFonts.get(name)?.body??'Arial',textSize,pointCount}));
3383
+ // RR-54: the columns' number formats, after the labels: caches, label and value-axis formats, then the workbook cells.
3384
+ const numberFormats=context.chartHeadings.get(name).numberFormats;
3385
+ if(numberFormats){
3386
+ entries[chartPart]=encodeText(applyChartNumberFormats(decodeText(entries[chartPart]),numberFormats));
3387
+ try{writeChartWorkbookFormats(entries,chartPart,numericRanges(decodeText(entries[chartPart]),numberFormats));}
3388
+ catch(error){throw new OPFPptxError('packaging-failed',`Chart workbook number formats could not be written: ${errorMessage(error)}`);}
3389
+ }
3229
3390
  }
3230
3391
  }
3231
3392
  applyChartFonts(entries,context.chartFonts,parseRelationships);
@@ -3324,6 +3485,17 @@ async function normalizePptxZip(raw, context) {
3324
3485
 
3325
3486
  writeNativeFurnitureMasters(output, context);
3326
3487
  giveNotesMastersOwnThemes(output);
3488
+ // opf-pptx#168: one slide master and theme per script profile, so each slide's own East Asian / complex-script fonts
3489
+ // reach PowerPoint through its master's theme (runs name none, FF-05). One profile: no change.
3490
+ if (context.scriptFonts && output['ppt/theme/theme1.xml']) {
3491
+ const slideThemes = planSlideThemes(context.scriptFonts, decodeText(output['ppt/theme/theme1.xml'][0]));
3492
+ try {
3493
+ giveSlidesScriptMasters(output, slideThemes);
3494
+ } catch (error) {
3495
+ throw new OPFPptxError('packaging-failed', 'Slide masters for per-slide script fonts could not be written.', {cause: errorMessage(error)});
3496
+ }
3497
+ reportPerSlideNotesScriptFonts(context.scriptFonts, slideThemes);
3498
+ }
3327
3499
  finalizeFontsUsed(output);
3328
3500
  // Document references record evidence from the final normalized parts.
3329
3501
  if (context.documentProvenance) {
@@ -3333,6 +3505,15 @@ async function normalizePptxZip(raw, context) {
3333
3505
  } catch (error) {
3334
3506
  throw new OPFPptxError("packaging-failed", "Document provenance tags could not be attached.", {cause: errorMessage(error)});
3335
3507
  }
3508
+ // RR-54: chart and table data records (on the final chart parts, so their cache evidence is what import reads) and the
3509
+ // datasets map beside the document tag.
3510
+ if (context.provenanceMode === 'full') {
3511
+ try {
3512
+ attachDataProvenance(parts, {records: context.dataRecords, paths: context.dataRecordPaths, datasets: context.datasets, parseRelationships, report: context.reportDiagnostic});
3513
+ } catch (error) {
3514
+ throw new OPFPptxError("packaging-failed", "Chart and table data tags could not be attached.", {cause: errorMessage(error)});
3515
+ }
3516
+ }
3336
3517
  for (const [path, bytes] of Object.entries(parts)) output[path] = [bytes, {level: context.compressionLevel, mtime: context.zipDate}];
3337
3518
  }
3338
3519
 
@@ -3389,12 +3570,14 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3389
3570
  let xml=decodeText(bytes);
3390
3571
  if (context.notesWithCarriageReturns.has(path)) xml = preserveGeneratedNotes(xml, context.notesWithCarriageReturns.get(path), path);
3391
3572
  if (path === '[Content_Types].xml') {
3392
- // PptxGenJS 4.0.1 emits one slide-master override per slide even
3393
- // though it creates only the actual master parts. Omit phantom master
3394
- // declarations without changing any existing part or relationship.
3573
+ // PptxGenJS 4.0.1 emitted one slide-master override per slide even
3574
+ // though it created only the actual master parts (pptxgenjs-plus fixed
3575
+ // this, #1444). Omit any phantom master declaration without changing
3576
+ // any existing part or relationship.
3395
3577
  xml = xml.replace(/<Override PartName="\/(ppt\/slideMasters\/slideMaster\d+\.xml)"[^>]*\/>/g,
3396
3578
  (override, part) => Object.hasOwn(entries, part) ? override : '');
3397
- // Explicit per-part types also correct PptxGenJS's image/jpg default.
3579
+ // Explicit per-part types describe the actual bytes (PptxGenJS 4.0.1's
3580
+ // image/jpg default; pptxgenjs-plus writes image/jpeg).
3398
3581
  const overrides = [...imageMetadata].filter(([, metadata]) => metadata).map(([part, metadata]) =>
3399
3582
  `<Override PartName="/${part}" ContentType="${metadata.mediaType}"/>`).join('');
3400
3583
  xml = xml.replace('</Types>', `${overrides}</Types>`);
@@ -3531,14 +3714,15 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3531
3714
  });
3532
3715
  // PptxGenJS gives shapes no alternative text. An unavailable image's panel
3533
3716
  // carries the accessible name the preview gives its group.
3534
- xml=xml.replace(/<p:cNvPr id="(\d+)" name="(OPF image placeholder \d+)">/g,(node,id,name)=>{
3717
+ xml=xml.replace(/<p:cNvPr id="(\d+)" name="(OPF image placeholder \d+)"(\/?)>/g,(node,id,name,close)=>{
3535
3718
  const description=context.imagePlaceholders.get(name);
3536
3719
  if(description===undefined)return node;
3537
3720
  const escapes={'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&apos;','\r':'&#13;','\n':'&#10;','\t':'&#9;'};
3538
- return `<p:cNvPr id="${id}" name="${name}" descr="${description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char])}">`;
3721
+ return `<p:cNvPr id="${id}" name="${name}" descr="${description.replace(/[&<>"'\r\n\t]/g,char=>escapes[char])}"${close}>`;
3539
3722
  });
3540
- // PptxGenJS 4 emits pPr before each rich run. OOXML allows one pPr,
3541
- // before all runs. Paragraph options belong to the first run.
3723
+ // PptxGenJS 4.0.1 emitted pPr before each rich run (pptxgenjs-plus writes
3724
+ // one). OOXML allows one pPr, before all runs. Paragraph options belong
3725
+ // to the first run.
3542
3726
  xml=xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g,(_,body)=>{
3543
3727
  let properties='';
3544
3728
  const content=body.replace(/<a:pPr\b[^>]*(?:\/>|>[\s\S]*?<\/a:pPr>)/g,node=>{properties ||= node;return '';});
@@ -3554,7 +3738,7 @@ function normalizePartBytes(path, bytes, context, renameMaps, entries, imageMeta
3554
3738
  return bytes;
3555
3739
  }
3556
3740
 
3557
- // The vendored PptxGenJS 4.0.1 master hard-codes Arial as the bullet font on
3741
+ // The vendored PptxGenJS master (4.0.1 and pptxgenjs-plus 4.3.4) hard-codes Arial as the bullet font on
3558
3742
  // all nine bodyStyle levels, while each level's text already uses +mn-lt.
3559
3743
  // Point those bullets at the same theme minor (body) font so a document's
3560
3744
  // fontScheme also governs master bullets. a:buFont is CT_TextFont, like
@@ -3567,7 +3751,7 @@ function themeMasterBulletFonts(xml) {
3567
3751
  bodyStyle.split(VENDOR_MASTER_BULLET_FONT).join(THEME_MINOR_BULLET_FONT));
3568
3752
  }
3569
3753
 
3570
- // FF-05: PptxGenJS 4.0.1 writes p:notesMasterIdLst after p:sldIdLst, but CT_Presentation is a sequence
3754
+ // FF-05: PptxGenJS (4.0.1 and pptxgenjs-plus 4.3.4) writes p:notesMasterIdLst after p:sldIdLst, but CT_Presentation is a sequence
3571
3755
  // (sldMasterIdLst, notesMasterIdLst, handoutMasterIdLst, sldIdLst, sldSz, ...). PowerPoint reads the out-of-order
3572
3756
  // list as no notes master and lists a default-theme font (Aptos) in Presentation.Fonts; schema order restores
3573
3757
  // the exporter's own notes master. Only the list's position changes.
@@ -3627,11 +3811,14 @@ function normalizeNestedZip(bytes, context) {
3627
3811
  const entries = repairChartWorkbookRanges(unzipSync(bytes));
3628
3812
  const output = {};
3629
3813
  for (const path of Object.keys(entries).sort()) {
3630
- // A gap in the chart data is a blank workbook cell, not a numeric cell with an empty value.
3814
+ // A gap in the chart data is a blank workbook cell, not a numeric cell with an empty value. RR-54 (opf-pptx#172): the
3815
+ // engine writes a scatter (and bubble) X gap as `<v>${val}</v>`, so a null X value became `<v>null</v>`, a numeric cell
3816
+ // that is no number; it is a gap too. Zero values are kept as `<v>0</v>` (pptxgenjs-plus fixed the `values[idx] || ''`
3817
+ // of PptxGenJS 4.0.1, upstream issue #1430; test/chart-workbook-values.mjs).
3631
3818
  const entryBytes = path === "docProps/core.xml"
3632
3819
  ? encodeText(normalizeCoreProperties(decodeText(entries[path]), context.timestamp))
3633
3820
  : /^xl\/worksheets\/sheet\d+\.xml$/.test(path)
3634
- ? encodeText(decodeText(entries[path]).replace(/<c ((?:r|s)="[^"]*"(?: (?:r|s)="[^"]*")*)><v><\/v><\/c>/g, "<c $1/>"))
3821
+ ? encodeText(decodeText(entries[path]).replace(/<c ((?:r|s)="[^"]*"(?: (?:r|s)="[^"]*")*)><v>(?:null|undefined)?<\/v><\/c>/g, "<c $1/>"))
3635
3822
  : entries[path];
3636
3823
  output[path] = [entryBytes, {
3637
3824
  level: context.compressionLevel,
@@ -3752,23 +3939,12 @@ function stringifyText(value) {
3752
3939
  return String(value);
3753
3940
  }
3754
3941
 
3755
- /** The number a chart cell holds ("12%", "$5" and "1,234" parse), or null when it holds none. */
3756
- function parsedNumber(value) {
3757
- if (typeof value === "number" && Number.isFinite(value)) return value;
3758
- const parsed = Number.parseFloat(String(value ?? "").replace(/[^0-9.-]/g, ""));
3759
- return Number.isFinite(parsed) ? parsed : null;
3760
- }
3761
-
3762
3942
  // PptxGenJS writes a gap (null) as `<c:pt idx="n"><c:v></c:v></c:pt>`. A native gap is a numeric cache with no point at that
3763
3943
  // index (ptCount keeps the row count); an empty value is not a number, so drop those points, leaving the index unwritten.
3764
3944
  function omitEmptyNumberPoints(xml) {
3765
3945
  return xml.replace(/<c:numCache>[\s\S]*?<\/c:numCache>/g, cache => cache.replace(/<c:pt idx="\d+"><c:v><\/c:v><\/c:pt>/g, ""));
3766
3946
  }
3767
3947
 
3768
- function numericValue(value) {
3769
- return parsedNumber(value) ?? 0;
3770
- }
3771
-
3772
3948
  function normalizeAuthor(author) {
3773
3949
  if (typeof author === "string") return author;
3774
3950
  if (Array.isArray(author)) return joinAuthors(author);