@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/DEPENDENCY-NOTES.md +6 -4
- package/README.md +8 -5
- package/dist/chart-data.js +156 -0
- package/dist/chart-options.js +5 -4
- package/dist/chart-workbook.js +38 -1
- package/dist/chartex.js +22 -12
- package/dist/code-provenance.js +1 -1
- package/dist/data-provenance.js +385 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +260 -84
- package/dist/master-themes.js +85 -1
- package/dist/script-fonts.js +134 -13
- package/dist/theme-colors.js +2 -1
- package/dist/typeface-inventory.js +50 -6
- package/dist/vendor-compat.js +45 -0
- package/package.json +7 -5
- package/vendor/pptxgenjs/LICENSE +22 -21
- package/vendor/pptxgenjs/UPSTREAM.json +13 -8
- package/vendor/pptxgenjs/pptxgen.es.js +22740 -5860
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 {
|
|
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:
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
1264
|
-
//
|
|
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)
|
|
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
|
-
|
|
2137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
2756
|
-
if (
|
|
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 =
|
|
2777
|
-
|
|
2778
|
-
//
|
|
2779
|
-
|
|
2780
|
-
|
|
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 -
|
|
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(
|
|
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}' (${
|
|
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 =
|
|
2798
|
-
const numbers =
|
|
2799
|
-
const
|
|
2800
|
-
|
|
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 ${
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3393
|
-
// though it
|
|
3394
|
-
//
|
|
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
|
|
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={'&':'&','<':'<','>':'>','"':'"',"'":''','\r':' ','\n':' ','\t':'	'};
|
|
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
|
|
3541
|
-
// before all runs. Paragraph options belong
|
|
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
|
|
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
|
|
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);
|