@openpresentation/opf-pptx 0.11.1 → 0.11.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ // Native Office 2016 chartex export and import (FF-22b).
2
+ //
3
+ // PptxGenJS cannot write a `cx:chartSpace` part, so a chartex chart (treemap,
4
+ // histogram, pareto, box & whisker, waterfall, funnel, map) is exported in two
5
+ // steps. PptxGenJS first writes the classic clustered column chart of the same
6
+ // data, with its embedded workbook. The package is then post-processed here:
7
+ // a `ppt/charts/chartExN.xml` part is added with `cx:chartData` (the workbook
8
+ // ranges and their cached values) and one `cx:series` per plotted column,
9
+ // together with its chart style and colour style parts, and the slide's chart
10
+ // frame is wrapped in `mc:AlternateContent`: the `mc:Choice` (which requires
11
+ // the chartex namespace of the construct) references the chartex part and the
12
+ // `mc:Fallback` keeps the classic chart, so a reader without chartex support
13
+ // still shows the data. Both parts share the workbook.
14
+ //
15
+ // Import reads the chartex part back: the `cx:series` layoutIds name the OPF
16
+ // chart type (an owned `paretoLine` marks the Office Pareto chart), and the
17
+ // cached dimensions restore the category-major data.
18
+ import {CHARTEX_NAMESPACES, chartTypeFromChartex} from './chart-types.js';
19
+
20
+ const NS = Object.freeze({
21
+ a: 'http://schemas.openxmlformats.org/drawingml/2006/main',
22
+ r: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
23
+ mc: 'http://schemas.openxmlformats.org/markup-compatibility/2006',
24
+ cs: 'http://schemas.microsoft.com/office/drawing/2012/chartStyle',
25
+ cx: CHARTEX_NAMESPACES.cx,
26
+ });
27
+
28
+ export const CHARTEX_CONTENT_TYPES = Object.freeze({
29
+ chart: 'application/vnd.ms-office.chartex+xml',
30
+ style: 'application/vnd.ms-office.chartstyle+xml',
31
+ colors: 'application/vnd.ms-office.chartcolorstyle+xml',
32
+ });
33
+
34
+ export const CHARTEX_RELATIONSHIP_TYPES = Object.freeze({
35
+ chart: 'http://schemas.microsoft.com/office/2014/relationships/chartEx',
36
+ style: 'http://schemas.microsoft.com/office/2011/relationships/chartStyle',
37
+ colors: 'http://schemas.microsoft.com/office/2011/relationships/chartColorStyle',
38
+ package: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/package',
39
+ });
40
+
41
+ export const CHARTEX_GRAPHIC_DATA_URI = NS.cx;
42
+
43
+ const decoder = new TextDecoder(), encoder = new TextEncoder();
44
+ const text = bytes => decoder.decode(bytes);
45
+ const escapeXml = value => String(value).replace(/[&<>"']/g, char => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'}[char]));
46
+
47
+ /** Excel column letters for a 1-based column index (1 = A, 27 = AA). */
48
+ export function columnLetters(index) {
49
+ let name = '';
50
+ for (let n = index; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name;
51
+ return name;
52
+ }
53
+
54
+ /**
55
+ * Office's automatic histogram bins: Scott's normal reference rule gives the bin
56
+ * width 3.49 * sd * n^(-1/3) (sample standard deviation), and the bins cover
57
+ * [min, max] from the minimum. Returns the bin count PowerPoint is told to use
58
+ * (`cx:binCount`), so the preview and the native chart agree on the bins.
59
+ */
60
+ export function scottBinCount(values) {
61
+ const n = values.length;
62
+ if (n === 0) return 1;
63
+ let min = Infinity, max = -Infinity, mean = 0;
64
+ for (const value of values) { if (value < min) min = value; if (value > max) max = value; mean += value / n; }
65
+ if (!(max > min) || n < 2) return 1;
66
+ let variance = 0;
67
+ for (const value of values) variance += (value - mean) * (value - mean) / (n - 1);
68
+ const width = 3.49 * Math.sqrt(variance) * Math.cbrt(n) ** -1;
69
+ if (!(width > 0) || !Number.isFinite(width)) return 1;
70
+ const count = Math.ceil((max - min) / width - 1e-9);
71
+ return Number.isFinite(count) && count >= 1 ? count : 1;
72
+ }
73
+
74
+ // A deterministic GUID for cx:series uniqueId, from the chart number and
75
+ // series index (FNV-1a over the key, repeated for each hex group).
76
+ function seriesUniqueId(chart, index) {
77
+ const hex = (seed, length) => {
78
+ let hash = 0x811c9dc5 ^ seed;
79
+ const key = `chartEx${chart}:${index}:${seed}`;
80
+ for (let i = 0; i < key.length; i++) { hash ^= key.charCodeAt(i); hash = Math.imul(hash, 0x01000193) >>> 0; }
81
+ return hash.toString(16).toUpperCase().padStart(8, '0').repeat(2).slice(0, length);
82
+ };
83
+ return `{${hex(1, 8)}-${hex(2, 4)}-4${hex(3, 3)}-8${hex(4, 3)}-${hex(5, 12)}}`;
84
+ }
85
+
86
+ function solidFill(hex, alpha) {
87
+ return `<a:solidFill>${alpha === undefined ? `<a:srgbClr val="${hex}"/>` : `<a:srgbClr val="${hex}"><a:alpha val="${alpha}"/></a:srgbClr>`}</a:solidFill>`;
88
+ }
89
+
90
+ function numberText(value) {
91
+ return String(value);
92
+ }
93
+
94
+ // The layout properties of the plotted series (CT_SeriesLayoutProperties order:
95
+ // parentLabelLayout, regionLabelLayout, visibility, aggregation, binning,
96
+ // geography, statistics, subtotals).
97
+ function layoutProperties(spec, series, hasCategories) {
98
+ switch (spec.layoutId) {
99
+ case 'treemap':
100
+ return '<cx:layoutPr><cx:parentLabelLayout val="none"/></cx:layoutPr>';
101
+ case 'clusteredColumn':
102
+ case 'paretoLine':
103
+ // A category column bins by category (Office "By category"); a lone value column is binned automatically (Scott's rule, made explicit).
104
+ if (hasCategories) return '<cx:layoutPr><cx:aggregation/></cx:layoutPr>';
105
+ return `<cx:layoutPr><cx:binning intervalClosed="r" underflow="auto" overflow="auto"><cx:binCount val="${scottBinCount(series.values.filter(value => value !== null && Number.isFinite(value)))}"/></cx:binning></cx:layoutPr>`;
106
+ case 'boxWhisker':
107
+ return '<cx:layoutPr><cx:visibility meanLine="0" meanMarker="1" nonoutliers="0" outliers="1"/><cx:statistics quartileMethod="exclusive"/></cx:layoutPr>';
108
+ case 'waterfall':
109
+ return '<cx:layoutPr><cx:visibility connectorLines="1"/></cx:layoutPr>';
110
+ case 'regionMap':
111
+ // No cx:geoCache: PowerPoint fetches the region shapes from Bing Maps when the deck is opened online (see chart-map-geodata).
112
+ return '<cx:layoutPr><cx:geography cultureLanguage="en-US" cultureRegion="US" attribution="Powered by Bing"/></cx:layoutPr>';
113
+ default:
114
+ return '';
115
+ }
116
+ }
117
+
118
+ function dataLabels(spec) {
119
+ if (spec.layoutId === 'treemap') return '<cx:dataLabels pos="inEnd"><cx:visibility seriesName="0" categoryName="1" value="0"/></cx:dataLabels>';
120
+ if (spec.layoutId === 'funnel') return '<cx:dataLabels pos="ctr"><cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels>';
121
+ return '';
122
+ }
123
+
124
+ // Per-point fills where the construct colours points, not series: a treemap
125
+ // tile per category, a waterfall bar by sign. The preview paints the same.
126
+ export function chartexPointColors(layoutId, values, palette) {
127
+ if (layoutId === 'treemap') return values.map((value, index) => value === null ? null : palette[index % palette.length]);
128
+ if (layoutId === 'waterfall') return values.map(value => value === null ? null : palette[value < 0 ? 1 : 0]);
129
+ return values.map(() => null);
130
+ }
131
+
132
+ function axes(spec, gridColor) {
133
+ if (!spec.axes) return '';
134
+ const gridlines = `<cx:majorGridlines><cx:spPr><a:ln w="9525"><a:solidFill><a:srgbClr val="${gridColor}"><a:alpha val="70000"/></a:srgbClr></a:solidFill></a:ln></cx:spPr></cx:majorGridlines>`;
135
+ const gapWidth = {clusteredColumn: '0.06', paretoLine: '0.06', boxWhisker: '1', waterfall: '0.5', funnel: '0.06'}[spec.layoutId] ?? '1';
136
+ const category = `<cx:axis id="0"><cx:catScaling gapWidth="${gapWidth}"/><cx:tickLabels/></cx:axis>`;
137
+ if (spec.layoutId === 'funnel') return `${category}<cx:axis id="1" hidden="1"><cx:valScaling/><cx:tickLabels/></cx:axis>`;
138
+ const value = `<cx:axis id="1"><cx:valScaling/>${gridlines}<cx:tickLabels/></cx:axis>`;
139
+ const percentage = spec.layoutId === 'paretoLine' ? '<cx:axis id="2"><cx:valScaling max="1" min="0"/><cx:units unit="percentage"/><cx:tickLabels/></cx:axis>' : '';
140
+ return `${category}${value}${percentage}`;
141
+ }
142
+
143
+ /**
144
+ * The cx:chartSpace part for one chart. `series` is the exporter's plotted
145
+ * series ({name, labels, values}); `hasCategories` says whether the first data
146
+ * column held category labels (a lone value column has none). The workbook
147
+ * layout is PptxGenJS's: Sheet1, headings in row 1, categories in column A and
148
+ * one series per following column.
149
+ */
150
+ export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette}) {
151
+ const rows = series[0].labels.length;
152
+ const range = (letter) => `Sheet1!$${letter}$2:$${letter}$${rows + 1}`;
153
+ const categories = hasCategories
154
+ ? `<cx:strDim type="cat"><cx:f>${range('A')}</cx:f><cx:lvl ptCount="${rows}">${series[0].labels.map((label, index) => `<cx:pt idx="${index}">${escapeXml(label)}</cx:pt>`).join('')}</cx:lvl></cx:strDim>`
155
+ : '';
156
+ const data = series.map((entry, index) => {
157
+ const points = entry.values.map((value, row) => value === null || !Number.isFinite(value) ? '' : `<cx:pt idx="${row}">${numberText(value)}</cx:pt>`).join('');
158
+ return `<cx:data id="${index}">${categories}<cx:numDim type="${spec.dimension}"><cx:f>${range(columnLetters(index + 2))}</cx:f><cx:lvl ptCount="${rows}" formatCode="General">${points}</cx:lvl></cx:numDim></cx:data>`;
159
+ }).join('');
160
+ const plotted = series.map((entry, index) => {
161
+ const color = palette[index % palette.length];
162
+ const letter = columnLetters(index + 2);
163
+ const pointFills = chartexPointColors(spec.layoutId, entry.values, palette)
164
+ .map((pointColor, row) => pointColor ? `<cx:dataPt idx="${row}"><cx:spPr>${solidFill(pointColor)}</cx:spPr></cx:dataPt>` : '').join('');
165
+ return `<cx:series layoutId="${spec.owner ?? spec.layoutId}" uniqueId="${seriesUniqueId(number, index)}">` +
166
+ `<cx:tx><cx:txData><cx:f>Sheet1!$${letter}$1</cx:f><cx:v>${escapeXml(entry.name)}</cx:v></cx:txData></cx:tx>` +
167
+ `<cx:spPr>${solidFill(color)}</cx:spPr>${pointFills}${dataLabels(spec)}<cx:dataId val="${index}"/>${layoutProperties(spec, entry, hasCategories)}` +
168
+ (spec.axes ? '<cx:axisId val="0"/><cx:axisId val="1"/>' : '') + '</cx:series>';
169
+ });
170
+ if (spec.owner) {
171
+ // The Office Pareto chart: the cumulative-percentage line owned by the binned columns, on the percentage axis.
172
+ plotted.push(`<cx:series layoutId="${spec.layoutId}" ownerIdx="0" uniqueId="${seriesUniqueId(number, series.length)}"><cx:spPr><a:ln w="19050"><a:solidFill><a:srgbClr val="${palette[1 % palette.length]}"/></a:solidFill></a:ln></cx:spPr><cx:axisId val="0"/><cx:axisId val="2"/></cx:series>`);
173
+ }
174
+ const legend = spec.layoutId === 'boxWhisker' && series.length > 1 ? '<cx:legend pos="r" align="ctr" overlay="0"/>' : '';
175
+ const alpha = fill.transparency > 0 ? Math.round((100 - fill.transparency) * 1000) : undefined;
176
+ const face = escapeXml(font);
177
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
178
+ `<cx:chartSpace xmlns:a="${NS.a}" xmlns:r="${NS.r}" xmlns:cx="${NS.cx}">` +
179
+ `<cx:chartData><cx:externalData r:id="${workbookRelId}" cx:autoUpdate="0"/>${data}</cx:chartData>` +
180
+ `<cx:chart><cx:plotArea><cx:plotAreaRegion>${plotted.join('')}</cx:plotAreaRegion>${axes(spec, gridColor)}</cx:plotArea>${legend}</cx:chart>` +
181
+ `<cx:spPr>${solidFill(fill.color, alpha)}<a:ln><a:noFill/></a:ln></cx:spPr>` +
182
+ `<cx:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="900">${solidFill(labelColor)}<a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/></a:defRPr></a:pPr><a:endParaRPr lang="en-US"/></a:p></cx:txPr>` +
183
+ '</cx:chartSpace>';
184
+ }
185
+
186
+ export function chartexRelationshipsXml({workbookTarget, number}) {
187
+ return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
188
+ '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
189
+ `<Relationship Id="rId1" Type="${CHARTEX_RELATIONSHIP_TYPES.package}" Target="${escapeXml(workbookTarget)}"/>` +
190
+ `<Relationship Id="rId2" Type="${CHARTEX_RELATIONSHIP_TYPES.style}" Target="style${number}.xml"/>` +
191
+ `<Relationship Id="rId3" Type="${CHARTEX_RELATIONSHIP_TYPES.colors}" Target="colors${number}.xml"/>` +
192
+ '</Relationships>';
193
+ }
194
+
195
+ // Chart style part (cs:chartStyle, style 201): every CT_StyleEntry the schema
196
+ // requires, in schema order, with the Office 2013+ default references. Series
197
+ // and point fills in the chartex part are explicit, so the style only governs
198
+ // text and chrome PowerPoint draws itself.
199
+ const STYLE_ENTRIES = [
200
+ 'axisTitle', 'categoryAxis', 'chartArea', 'dataLabel', 'dataLabelCallout', 'dataPoint', 'dataPoint3D', 'dataPointLine', 'dataPointMarker',
201
+ 'dataPointMarkerLayout', 'dataPointWireframe', 'dataTable', 'downBar', 'dropLine', 'errorBar', 'floor', 'gridlineMajor', 'gridlineMinor', 'hiLoLine',
202
+ 'leaderLine', 'legend', 'plotArea', 'plotArea3D', 'seriesAxis', 'seriesLine', 'title', 'trendline', 'trendlineLabel', 'upBar', 'valueAxis', 'wall',
203
+ ];
204
+ export function chartStyleXml() {
205
+ const textColor = '<a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr>';
206
+ const faintLine = '<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="15000"/><a:lumOff val="85000"/></a:schemeClr></a:solidFill><a:round/></a:ln>';
207
+ const entry = (name, {fillIdx = '0', fillColor = '', fontColor = '<a:schemeClr val="tx1"/>', spPr = '', size = '', mods = ''} = {}) =>
208
+ `<cs:${name}${mods ? ` mods="${mods}"` : ''}><cs:lnRef idx="0"/><cs:fillRef idx="${fillIdx}">${fillColor}</cs:fillRef><cs:effectRef idx="0"/><cs:fontRef idx="minor">${fontColor}</cs:fontRef>${spPr ? `<cs:spPr>${spPr}</cs:spPr>` : ''}${size ? `<cs:defRPr sz="${size}" kern="1200"/>` : ''}</cs:${name}>`;
209
+ const entries = STYLE_ENTRIES.map(name => {
210
+ switch (name) {
211
+ case 'dataPointMarkerLayout': return '<cs:dataPointMarkerLayout symbol="circle" size="5"/>';
212
+ case 'axisTitle': return entry(name, {fontColor: textColor, size: '1000'});
213
+ case 'categoryAxis': case 'valueAxis': case 'seriesAxis': return entry(name, {fontColor: textColor, spPr: faintLine, size: '900'});
214
+ case 'chartArea': return entry(name, {mods: 'allowNoFillOverride allowNoLineOverride', spPr: `<a:solidFill><a:schemeClr val="bg1"/></a:solidFill>${faintLine}`, size: '1000'});
215
+ case 'dataLabel': case 'dataLabelCallout': case 'dataTable': case 'legend': case 'trendlineLabel': return entry(name, {fontColor: textColor, size: '900'});
216
+ case 'dataPoint': case 'dataPoint3D': case 'dataPointWireframe': case 'upBar': case 'floor': case 'wall':
217
+ return entry(name, {fillIdx: '1', fillColor: '<cs:styleClr val="auto"/>', spPr: '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'});
218
+ case 'dataPointLine': case 'dataPointMarker': case 'trendline': case 'seriesLine': case 'hiLoLine': case 'dropLine': case 'leaderLine': case 'errorBar':
219
+ return entry(name, {fillColor: '<cs:styleClr val="auto"/>', spPr: '<a:ln w="28575" cap="rnd"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:round/></a:ln>'});
220
+ case 'downBar': return entry(name, {fillIdx: '1', spPr: '<a:solidFill><a:schemeClr val="dk1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill>'});
221
+ case 'gridlineMajor': case 'gridlineMinor': return entry(name, {spPr: faintLine});
222
+ case 'title': return entry(name, {fontColor: textColor, size: '1400'});
223
+ default: return entry(name);
224
+ }
225
+ }).join('');
226
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cs:chartStyle xmlns:cs="${NS.cs}" xmlns:a="${NS.a}" id="201">${entries}</cs:chartStyle>`;
227
+ }
228
+
229
+ // Colour style part (cs:colorStyle, colourful palette 10): the six theme
230
+ // accents with the standard Office variations.
231
+ export function chartColorStyleXml() {
232
+ const variations = ['', '<a:lumMod val="60000"/>', '<a:lumMod val="80000"/><a:lumOff val="20000"/>', '<a:lumMod val="80000"/>', '<a:lumMod val="60000"/><a:lumOff val="40000"/>',
233
+ '<a:lumMod val="50000"/>', '<a:lumMod val="70000"/><a:lumOff val="30000"/>', '<a:lumMod val="70000"/>', '<a:lumMod val="50000"/><a:lumOff val="50000"/>'];
234
+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cs:colorStyle xmlns:cs="${NS.cs}" xmlns:a="${NS.a}" meth="cycle" id="10">` +
235
+ [1, 2, 3, 4, 5, 6].map(n => `<a:schemeClr val="accent${n}"/>`).join('') + variations.map(v => `<cs:variation>${v}</cs:variation>`).join('') + '</cs:colorStyle>';
236
+ }
237
+
238
+ /**
239
+ * Wrap the PptxGenJS chart frame: the mc:Choice references the chartex part,
240
+ * the mc:Fallback keeps the classic chart frame. The choice frame copies the
241
+ * name, id, description and transform of the original.
242
+ */
243
+ export function wrapChartexFrame(frame, relId, requires) {
244
+ const namespace = CHARTEX_NAMESPACES[requires];
245
+ if (!namespace) throw new Error(`Unknown chartex namespace ${requires}.`);
246
+ const head = frame.match(/^<p:graphicFrame>[\s\S]*?<\/p:xfrm>/)?.[0];
247
+ if (!head) throw new Error('Generated chart frame has no transform.');
248
+ const choice = `${head}<a:graphic xmlns:a="${NS.a}"><a:graphicData uri="${NS.cx}"><cx:chart xmlns:cx="${NS.cx}" xmlns:r="${NS.r}" r:id="${relId}"/></a:graphicData></a:graphic></p:graphicFrame>`;
249
+ return `<mc:AlternateContent xmlns:mc="${NS.mc}"><mc:Choice xmlns:${requires}="${namespace}" Requires="${requires}">${choice}</mc:Choice><mc:Fallback>${frame}</mc:Fallback></mc:AlternateContent>`;
250
+ }
251
+
252
+ /**
253
+ * Add the chartex parts for every chart the exporter registered in
254
+ * `context.chartex` (by frame name), in slide order. Runs on the unzipped
255
+ * package after the classic chart parts have their fonts and headings.
256
+ */
257
+ export function attachChartexParts(entries, chartex, parseRelationships) {
258
+ if (!chartex.size) return;
259
+ const slides = Object.keys(entries).filter(part => /^ppt\/slides\/slide\d+\.xml$/.test(part)).sort((a, b) => Number(a.match(/(\d+)\.xml$/)[1]) - Number(b.match(/(\d+)\.xml$/)[1]));
260
+ const overrides = [];
261
+ let number = 0;
262
+ for (const part of slides) {
263
+ const relationships = parseRelationships(entries, part);
264
+ const relsPart = part.replace(/([^/]+)$/, '_rels/$1.rels');
265
+ let rels = text(entries[relsPart]);
266
+ let nextId = Math.max(0, ...[...rels.matchAll(/\bId="rId(\d+)"/g)].map(match => Number(match[1]))) + 1;
267
+ let xml = text(entries[part]);
268
+ let changed = false;
269
+ xml = xml.replace(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g, frame => {
270
+ const name = frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
271
+ const chart = chartex.get(name);
272
+ if (!chart) return frame;
273
+ const chartPart = relationships.get(frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1])?.path;
274
+ if (!chartPart) throw new Error('Generated chart relationship is missing.');
275
+ const workbook = [...parseRelationships(entries, chartPart).values()].find(relationship => relationship.type === CHARTEX_RELATIONSHIP_TYPES.package);
276
+ if (!workbook) throw new Error('Generated chart has no embedded workbook.');
277
+ number += 1;
278
+ const base = `ppt/charts/chartEx${number}.xml`;
279
+ entries[base] = encoder.encode(chartexPartXml({...chart, number, workbookRelId: 'rId1'}));
280
+ entries[`ppt/charts/_rels/chartEx${number}.xml.rels`] = encoder.encode(chartexRelationshipsXml({workbookTarget: workbook.target, number}));
281
+ entries[`ppt/charts/style${number}.xml`] = encoder.encode(chartStyleXml());
282
+ entries[`ppt/charts/colors${number}.xml`] = encoder.encode(chartColorStyleXml());
283
+ overrides.push(`<Override PartName="/${base}" ContentType="${CHARTEX_CONTENT_TYPES.chart}"/>`,
284
+ `<Override PartName="/ppt/charts/style${number}.xml" ContentType="${CHARTEX_CONTENT_TYPES.style}"/>`,
285
+ `<Override PartName="/ppt/charts/colors${number}.xml" ContentType="${CHARTEX_CONTENT_TYPES.colors}"/>`);
286
+ const relId = `rId${nextId++}`;
287
+ rels = rels.replace('</Relationships>', `<Relationship Id="${relId}" Type="${CHARTEX_RELATIONSHIP_TYPES.chart}" Target="../charts/chartEx${number}.xml"/></Relationships>`);
288
+ changed = true;
289
+ return wrapChartexFrame(frame, relId, chart.spec.requires);
290
+ });
291
+ if (!changed) continue;
292
+ entries[part] = encoder.encode(xml);
293
+ entries[relsPart] = encoder.encode(rels);
294
+ }
295
+ if (overrides.length) entries['[Content_Types].xml'] = encoder.encode(text(entries['[Content_Types].xml']).replace('</Types>', `${overrides.join('')}</Types>`));
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Import
300
+
301
+ const asArray = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
302
+ const scalar = value => typeof value === 'string' ? value : value && typeof value === 'object' ? String(value['#text'] ?? '') : value === undefined ? '' : String(value);
303
+
304
+ /**
305
+ * Read a parsed cx:chartSpace (fast-xml-parser, prefixes kept, attributes as
306
+ * plain keys) back to an OPF chart. Returns null when no cx:series names a
307
+ * kept OPF chart type (sunburst has no OPF record). `heading` is the category
308
+ * heading recovered from the workbook, `limits` the cache bounds
309
+ * ({points, cells}) and `invalid(message, path)` throws the import error.
310
+ */
311
+ export function chartFromChartex(doc, {heading, limits, invalid, path}) {
312
+ const space = doc?.['cx:chartSpace'];
313
+ const region = space?.['cx:chart']?.['cx:plotArea']?.['cx:plotAreaRegion'];
314
+ const allSeries = asArray(region?.['cx:series']);
315
+ const type = chartTypeFromChartex(allSeries.map(series => series?.layoutId));
316
+ if (!type) return null;
317
+ const dataById = new Map(asArray(space?.['cx:chartData']?.['cx:data']).map(data => [String(data?.id ?? ''), data]));
318
+ const plotted = allSeries.filter(series => series?.ownerIdx === undefined && series?.['cx:dataId']?.val !== undefined);
319
+ if (!plotted.length) return null;
320
+ const integer = (raw, limit, location) => {
321
+ if (typeof raw !== 'string' || !/^[+-]?\d+$/.test(raw.trim())) invalid('expected an unsigned integer', location);
322
+ const value = Number(raw.trim());
323
+ if (!Number.isSafeInteger(value) || value < 0 || value > limit) invalid(`integer exceeds the supported range 0–${limit}`, location);
324
+ return value;
325
+ };
326
+ let cells = 0;
327
+ const level = (dimension, location, numeric) => {
328
+ const lvl = asArray(dimension?.['cx:lvl'])[0];
329
+ if (!lvl) return [];
330
+ const count = lvl.ptCount === undefined ? undefined : integer(lvl.ptCount, limits.points, `${location}/cx:lvl@ptCount`);
331
+ const points = asArray(lvl['cx:pt']);
332
+ if (points.length > limits.points) invalid('too many points', location);
333
+ let extent = count ?? 0;
334
+ const indexed = new Map();
335
+ for (const [position, point] of points.entries()) {
336
+ const at = `${location}/cx:lvl/cx:pt[${position}]@idx`;
337
+ const index = integer(point?.idx, limits.points - 1, at);
338
+ if (count !== undefined && index >= count) invalid('point index is outside its declared count', at);
339
+ if (indexed.has(index)) invalid('duplicate point index', at);
340
+ const value = scalar(point);
341
+ if (numeric) {
342
+ const parsed = value.trim() === '' ? null : Number(value);
343
+ if (parsed !== null && !Number.isFinite(parsed)) invalid('numeric point is not a finite number', at);
344
+ indexed.set(index, parsed);
345
+ } else indexed.set(index, value);
346
+ extent = Math.max(extent, index + 1);
347
+ }
348
+ cells += extent;
349
+ if (cells > limits.cells) invalid('combined caches exceed the 1,000,000-cell import limit', location);
350
+ const result = new Array(extent).fill(null);
351
+ for (const [index, value] of indexed) result[index] = value;
352
+ return result;
353
+ };
354
+ let labels = null;
355
+ const names = [], values = [];
356
+ plotted.forEach((series, index) => {
357
+ const data = dataById.get(String(series['cx:dataId'].val));
358
+ if (!data) invalid('series data is missing', `${path}#cx:series[${index}]/cx:dataId`);
359
+ const location = `${path}#cx:data[@id="${series['cx:dataId'].val}"]`;
360
+ const categories = asArray(data['cx:strDim']).find(dimension => dimension?.type === 'cat');
361
+ if (categories && !labels) labels = level(categories, `${location}/cx:strDim`, false);
362
+ const numeric = asArray(data['cx:numDim'])[0];
363
+ values.push(numeric ? level(numeric, `${location}/cx:numDim`, true) : []);
364
+ const name = series['cx:tx']?.['cx:txData']?.['cx:v'];
365
+ names.push(name === undefined ? `Series ${index + 1}` : scalar(name));
366
+ });
367
+ const rowCount = values.reduce((count, row) => Math.max(count, row.length), labels?.length ?? 0);
368
+ if (rowCount === 0) return null;
369
+ if (rowCount * (values.length + (labels ? 1 : 0)) > limits.cells) invalid('chart cache exceeds the 1,000,000-cell import limit', path);
370
+ // A histogram or Pareto chart binned from one value column has no category dimension: the column comes back as authored.
371
+ if (!labels) return {type, data: {columns: [names[0]], rows: values[0].slice(0, rowCount).map((value, index) => [values[0][index] ?? null])}};
372
+ const rows = [];
373
+ for (let index = 0; index < rowCount; index += 1) rows.push([labels[index] ?? null, ...values.map(row => row[index] ?? null)]);
374
+ return {type, data: {columns: [heading ?? 'Category', ...names], rows}};
375
+ }
package/dist/index.d.ts CHANGED
@@ -39,13 +39,28 @@ export interface MediaProvenanceDiagnostic { code: "media-provenance-omitted"; p
39
39
  export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"; path: string; message: string; reason: "data-not-inline" | "no-rows" | "no-columns" | "single-column-not-numeric" }
40
40
  /** Content with no PowerPoint export (an empty table, an unsupported payload) is replaced by a plain-language placeholder frame. */
41
41
  export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; path: string; message: string; reason: "table-has-no-rows" | "unsupported-payload" }
42
- /** The chart data was reshaped to export a native chart: a histogram's values were binned, or a single value column was plotted against row numbers. */
43
42
  export interface WatermarkNotExportedDiagnostic { code: "watermark-not-exported"; path: string; message: string }
44
- export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" }
43
+ /**
44
+ * The chart data was reshaped to export a native chart: a single value column was plotted against row numbers; a one-series construct
45
+ * (pie, doughnut, and with `chartex: "native"` treemap, histogram, pareto, waterfall, funnel, map) kept only its first series; by default
46
+ * a chartex chart type was written as a clustered column chart (`chartex-fallback`) and a one-column histogram was binned into counts
47
+ * (`histogram-binned`).
48
+ */
49
+ export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" | "series-dropped" | "chartex-fallback" }
50
+ /** With `chartex: "native"`, a map chart (`world`) is exported as a chartex regionMap without cached geography: PowerPoint fetches the region shapes from Bing Maps online, so the map may show no regions offline. */
51
+ export interface ChartMapGeodataDiagnostic { code: "chart-map-geodata"; path: string; message: string }
45
52
 
46
53
  export interface ToPptxOptions {
47
54
  /** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
48
55
  imageFormat?: "compatible" | "preserve";
56
+ /**
57
+ * How the chartex chart types (treemap, histogram, pareto, box-and-whisker, waterfall, funnel, world) are exported.
58
+ * "fallback" (default) writes the clustered column chart of the data and reports `chart-data-adapted` (`chartex-fallback`,
59
+ * `histogram-binned`). "native" writes Office 2016 chartex parts (`cx:chartSpace`, style parts, an `mc:AlternateContent`
60
+ * frame with the clustered column chart as fallback). Native chartex is opt-in pending native PowerPoint confirmation (FF-22b);
61
+ * the default flips once that check passes. Import of chartex parts is always on.
62
+ */
63
+ chartex?: "native" | "fallback";
49
64
  /**
50
65
  * OPF_DOCUMENT_V1 / OPF_SLIDE_V1 customer-data tags that let fromPptx restore
51
66
  * catalog references, layout ids and authoring metadata (docs/document-roundtrip.md).
@@ -58,7 +73,7 @@ export interface ToPptxOptions {
58
73
  /** Match preview/pagination clearance around supplied vector text outlines; default 1. */
59
74
  textRasterPadding?: number;
60
75
  /** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, plus `unresolved-font-scheme` (once per reference path) when a font-scheme id matches no record and the default `aptos` scheme is used as the base. */
61
- onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ContentPlaceholderDiagnostic | WatermarkNotExportedDiagnostic) => void;
76
+ onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ContentPlaceholderDiagnostic | WatermarkNotExportedDiagnostic) => void;
62
77
  baseDir?: string;
63
78
  compressionLevel?: number;
64
79
  imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;