@openpresentation/opf-pptx 0.11.9 → 0.12.1

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,276 @@
1
+ // RR-35: chart options (axis titles, legend position, data labels) in the PPTX export and import.
2
+ //
3
+ // Core owns the normalisation and the per-type support table (`resolveChartOptions`, `chartOptionSupport`); the preview
4
+ // (opf-render) reads the same resolved options. This module writes them natively and reads them back:
5
+ //
6
+ // classic charts c:catAx/c:title and c:valAx/c:title (through PptxGenJS options), c:legend/c:legendPos (through PptxGenJS
7
+ // options), and a c:dLbls block per series and per chart group (post-processed XML, see `applyDataLabels`);
8
+ // chartex charts cx:axis/cx:title, cx:legend pos and cx:dataLabels pos (see chartex.js, which calls the helpers here).
9
+ //
10
+ // A chart that carries none of the three fields resolves to `active: false`; nothing below runs and its parts are unchanged.
11
+ import * as opfCore from '@openpresentation/opf';
12
+
13
+ const INACTIVE = Object.freeze({active: false, axisTitles: {}, diagnostics: []});
14
+
15
+ /** True when the installed core knows the chart option fields (older cores ignore them on export and import). */
16
+ export const coreKnowsChartOptions = () => typeof opfCore.resolveChartOptions === 'function' && typeof opfCore.chartOptionSupport === 'function';
17
+
18
+ /** The resolved options of a chart (an older core without `resolveChartOptions` ignores the fields). */
19
+ export function resolveChartOptionsFor(chart) {
20
+ if (typeof opfCore.resolveChartOptions !== 'function' || !chart || typeof chart !== 'object') return INACTIVE;
21
+ return opfCore.resolveChartOptions(chart, opfCore.chartOptionTarget(chart.type));
22
+ }
23
+
24
+ export function reportChartOptionDiagnostics(resolved, path, onDiagnostic) {
25
+ for (const diagnostic of resolved.diagnostics) onDiagnostic?.({...diagnostic, path: `${path}.${diagnostic.option}`});
26
+ }
27
+
28
+ const LEGEND_POS = Object.freeze({top: 't', bottom: 'b', left: 'l', right: 'r'});
29
+ const LEGEND_POS_BACK = Object.freeze({t: 'top', b: 'bottom', l: 'left', r: 'right'});
30
+
31
+ /** PptxGenJS addChart options for the legend and the axis titles; `{}` for a chart that uses neither. */
32
+ export function classicChartOptions(resolved, {labelColor, font, textSize}) {
33
+ const out = {};
34
+ if (resolved.legend === 'none') out.showLegend = false;
35
+ else if (resolved.legend) Object.assign(out, {showLegend: true, legendPos: LEGEND_POS[resolved.legend]});
36
+ const title = (axis, text) => ({
37
+ [`show${axis}AxisTitle`]: true, [`${axis.toLowerCase()}AxisTitle`]: text,
38
+ [`${axis.toLowerCase()}AxisTitleColor`]: labelColor, [`${axis.toLowerCase()}AxisTitleFontFace`]: font, [`${axis.toLowerCase()}AxisTitleFontSize`]: textSize / 100,
39
+ });
40
+ if (resolved.axisTitles.category) Object.assign(out, title('Cat', resolved.axisTitles.category));
41
+ if (resolved.axisTitles.value) Object.assign(out, title('Val', resolved.axisTitles.value));
42
+ return out;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Classic data labels
47
+
48
+ const DLBL_POS = Object.freeze({center: 'ctr', 'inside-end': 'inEnd', 'inside-base': 'inBase', 'outside-end': 'outEnd', above: 't', below: 'b', left: 'l', right: 'r'});
49
+ const DLBL_POS_BACK = Object.freeze(Object.fromEntries(Object.entries(DLBL_POS).map(([key, value]) => [value, key])));
50
+ const INSIDE = new Set(['center', 'inside-end', 'inside-base']);
51
+ const escapeXml = value => String(value).replace(/[&<>"']/g, char => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'}[char]));
52
+
53
+ function labelText(color, size, font) {
54
+ const face = escapeXml(font);
55
+ return `<c:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="${size}" b="0" i="0" u="none" strike="noStrike"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill>` +
56
+ `<a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/></a:defRPr></a:pPr><a:endParaRPr lang="en-US"/></a:p></c:txPr>`;
57
+ }
58
+
59
+ // The flags and position of a label set, in CT_DLbls order after the text properties.
60
+ function labelFlags(labels, {leaderLines}) {
61
+ const has = part => labels.content.includes(part) ? 1 : 0;
62
+ return (labels.position === null ? '' : `<c:dLblPos val="${DLBL_POS[labels.position]}"/>`) +
63
+ '<c:showLegendKey val="0"/>' +
64
+ `<c:showVal val="${has('value')}"/><c:showCatName val="${has('category')}"/><c:showSerName val="0"/><c:showPercent val="${has('percent')}"/><c:showBubbleSize val="0"/>` +
65
+ `<c:separator>${escapeXml(labels.separator)}</c:separator>` +
66
+ (leaderLines ? '<c:showLeaderLines val="0"/>' : '');
67
+ }
68
+
69
+ const dLbl = (index, color, labels, {size, font}) =>
70
+ `<c:dLbl><c:idx val="${index}"/><c:numFmt formatCode="General" sourceLinked="0"/><c:spPr><a:noFill/><a:ln><a:noFill/></a:ln></c:spPr>${labelText(color, size, font)}${labelFlags(labels, {leaderLines: false})}</c:dLbl>`;
71
+
72
+ /**
73
+ * Rewrite the c:dLbls of a generated classic chart for the resolved data labels. PptxGenJS writes labels per series and per
74
+ * chart group, with a "General" or "#,##0" number format that differs by type and, on pie and doughnut charts, per point
75
+ * overrides that hide every label; this writes one consistent block everywhere:
76
+ *
77
+ * - the General number format (the preview prints values the same way) and an explicit separator;
78
+ * - c:dLblPos only where the chart type has a position choice (core's support table; area, doughnut and radar have none);
79
+ * - the label text colour: the chart text colour outside a mark, the contrasting colour (core's `textColorForFill`, as the
80
+ * preview) inside one, per series, and per point on pie and doughnut charts.
81
+ *
82
+ * `info`: {resolved, kind, palette (series colours, hex without #), labelColor, font, textSize, pointCount}.
83
+ */
84
+ export function applyDataLabels(xml, info) {
85
+ const {resolved, kind, palette, labelColor, font, textSize, pointCount} = info;
86
+ const labels = resolved.dataLabels;
87
+ // Where the label sits on its mark: a bar or pie label placed inside the mark, and the labels of an area or a doughnut ring,
88
+ // take the colour that contrasts with the mark; every other label keeps the chart text colour (as the preview does).
89
+ const inside = kind === 'area' || kind === 'doughnut' || (['bar', 'pie', 'histogram', 'pareto', 'waterfall'].includes(kind) && INSIDE.has(labels.position));
90
+ const contrast = fill => typeof opfCore.textColorForFill === 'function' ? String(opfCore.textColorForFill(`#${fill}`, `#${labelColor}`)).replace(/^#/, '').toUpperCase() : labelColor;
91
+ const block = (color, {points = 0, leaderLines = false} = {}) =>
92
+ `<c:dLbls>${Array.from({length: points}, (_, index) => dLbl(index, inside ? contrast(palette[index % palette.length]) : labelColor, labels, {size: textSize, font})).join('')}` +
93
+ `<c:numFmt formatCode="General" sourceLinked="0"/><c:spPr><a:noFill/><a:ln><a:noFill/></a:ln></c:spPr>${labelText(color, textSize, font)}${labelFlags(labels, {leaderLines})}</c:dLbls>`;
94
+ const circular = kind === 'pie' || kind === 'doughnut';
95
+ const replaceBlocks = (text, make) => text.replace(/<c:dLbls>[\s\S]*?<\/c:dLbls>/g, existing => make(existing));
96
+ // Series-level blocks first (the colour follows the series), then the group-level block each chart group carries.
97
+ let seriesIndex = 0;
98
+ const withSeries = xml.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => {
99
+ const index = seriesIndex++;
100
+ const color = inside && !circular ? contrast(palette[index % palette.length]) : labelColor;
101
+ return replaceBlocks(ser, existing => block(color, {points: circular ? pointCount : 0, leaderLines: /<c:showLeaderLines\b/.test(existing)}));
102
+ });
103
+ const outside = withSeries.split(/(<c:ser>[\s\S]*?<\/c:ser>)/);
104
+ const groupLevel = outside.map((part, index) => index % 2 ? part : replaceBlocks(part, existing => block(labelColor, {leaderLines: /<c:showLeaderLines\b/.test(existing)}))).join('');
105
+ // A chart type without a series- or group-level c:dLbls (scatter and radar carry only the group one) is covered above; one with
106
+ // neither is left as written (every generated type has at least one).
107
+ return groupLevel;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Import (classic)
112
+
113
+ const asArray = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
114
+ const text = node => {
115
+ if (node === undefined || node === null) return '';
116
+ if (typeof node === 'string') return node;
117
+ if (typeof node === 'number') return String(node);
118
+ if (Array.isArray(node)) return node.map(text).join('');
119
+ return node['#text'] !== undefined ? String(node['#text']) : '';
120
+ };
121
+ const flag = node => node !== undefined && node?.val !== '0' && node?.val !== 'false' && node?.val !== 0;
122
+
123
+ // The rich-text paragraphs of a c:title (or cx:txData), joined into one plain string; undefined when the title carries no text.
124
+ function titleString(title) {
125
+ const rich = title?.['c:tx']?.['c:rich'];
126
+ if (!rich) return undefined;
127
+ const value = asArray(rich['a:p']).map(paragraph => asArray(paragraph['a:r']).map(run => text(run['a:t'])).join('')).join(' ').trim();
128
+ return value || undefined;
129
+ }
130
+
131
+ /**
132
+ * The options of a parsed classic chart (fast-xml-parser, prefixes kept, attributes plain): `{axisTitles?, legend?, dataLabels?}`
133
+ * plus `notes` naming what the three fields cannot express. `defaults` is what an export writes without the field:
134
+ * `{legend: 'right' | undefined}` (a legend at the right of multi-series, pie and doughnut charts) and the chart kind.
135
+ */
136
+ export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCount, circular, scatter}) {
137
+ // An older core's schema has no such fields (additionalProperties is false), so nothing is added to the imported chart.
138
+ if (!coreKnowsChartOptions()) return {options: {}, notes: []};
139
+ const chart = chartSpace?.['c:chart'];
140
+ const plotArea = chart?.['c:plotArea'];
141
+ const out = {};
142
+ const notes = [];
143
+
144
+ const axisTitles = {};
145
+ if (scatter) {
146
+ const [x, y] = asArray(plotArea?.['c:valAx']);
147
+ const xTitle = titleString(x?.['c:title']), yTitle = titleString(y?.['c:title']);
148
+ if (xTitle) axisTitles.category = xTitle;
149
+ if (yTitle) axisTitles.value = yTitle;
150
+ } else if (!circular) {
151
+ const category = titleString(asArray(plotArea?.['c:catAx'])[0]?.['c:title']), value = titleString(asArray(plotArea?.['c:valAx'])[0]?.['c:title']);
152
+ if (category) axisTitles.category = category;
153
+ if (value) axisTitles.value = value;
154
+ }
155
+ if (Object.keys(axisTitles).length) out.axisTitles = axisTitles;
156
+
157
+ const defaultLegend = circular || seriesCount > 1;
158
+ const legend = chart?.['c:legend'];
159
+ if (legend) {
160
+ const pos = legend['c:legendPos']?.val ?? 'r';
161
+ const mapped = LEGEND_POS_BACK[pos];
162
+ if (!mapped) notes.push({option: 'legend', message: `The legend position '${pos}' (top right) has no OPF equivalent; it imports as 'right'.`});
163
+ const position = mapped ?? 'right';
164
+ if (!(defaultLegend && position === 'right')) out.legend = position;
165
+ } else if (defaultLegend) out.legend = 'none';
166
+
167
+ const dataLabels = classicDataLabels(chartNode, target, notes);
168
+ if (dataLabels !== undefined) out.dataLabels = dataLabels;
169
+ return {options: out, notes};
170
+ }
171
+
172
+ function classicDataLabels(chartNode, target, notes) {
173
+ const series = asArray(chartNode?.['c:ser']);
174
+ // The labels the chart shows: the first series' block when it has one (PptxGenJS writes pie and doughnut visibility as per
175
+ // point overrides), else the chart group's block.
176
+ const serLabels = series.map(entry => entry?.['c:dLbls']).find(block => block !== undefined);
177
+ const block = serLabels ?? chartNode?.['c:dLbls'];
178
+ if (!block) return undefined;
179
+ const points = asArray(block['c:dLbl']);
180
+ const source = points.length ? points.find(point => String(point?.['c:idx']?.val) === '0') ?? points[0] : block;
181
+ const content = [];
182
+ if (flag(source['c:showCatName'])) content.push('category');
183
+ if (flag(source['c:showVal'])) content.push('value');
184
+ if (flag(source['c:showPercent'])) content.push('percent');
185
+ if (!content.length) return undefined;
186
+ for (const hidden of ['c:showSerName', 'c:showLegendKey', 'c:showBubbleSize']) {
187
+ if (flag(source[hidden])) notes.push({option: 'dataLabels', message: `Data labels that show ${hidden.slice(6)} cannot be expressed; only category, value and percent import.`});
188
+ }
189
+ const support = target && typeof opfCore.chartOptionSupport === 'function' ? opfCore.chartOptionSupport(target) : undefined;
190
+ const numberFormat = source['c:numFmt']?.formatCode ?? block['c:numFmt']?.formatCode;
191
+ if (numberFormat && numberFormat !== 'General' && numberFormat !== '#,##0' && numberFormat !== '0%') notes.push({option: 'dataLabels', message: `The label number format '${numberFormat}' cannot be expressed; labels import in the General format.`});
192
+ const out = {};
193
+ const position = DLBL_POS_BACK[source['c:dLblPos']?.val ?? block['c:dLblPos']?.val];
194
+ const supported = support?.dataLabels.positions ?? [];
195
+ if (position && position !== support?.dataLabels.defaultPosition) {
196
+ if (!support || supported.includes(position)) out.position = position;
197
+ else notes.push({option: 'dataLabels.position', message: `The label position '${position}' is not available on this chart type; it imports as the default.`});
198
+ }
199
+ const separator = text(source['c:separator'] ?? block['c:separator']);
200
+ if (content.length > 1 && separator && separator !== ', ') out.separator = separator;
201
+ const nonDefaultContent = !(content.length === 1 && content[0] === 'value');
202
+ if (nonDefaultContent) out.content = content;
203
+ return Object.keys(out).length ? out : true;
204
+ }
205
+
206
+ /** The core option target of a chart type id (undefined for an id outside the catalog or an older core). */
207
+ export function chartTargetFor(typeId) {
208
+ return typeof opfCore.chartOptionTarget === 'function' ? opfCore.chartOptionTarget(typeId) : undefined;
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Import (chartex)
213
+
214
+ const CX_POSITION_BACK = Object.freeze({ctr: 'center', inEnd: 'inside-end', inBase: 'inside-base', outEnd: 'outside-end'});
215
+ const cxFlag = value => value === '1' || value === 'true' || value === true || value === 1;
216
+
217
+ function axisTitleText(axis) {
218
+ const value = axis?.['cx:title'];
219
+ // Rich text (what PowerPoint writes for a typed title, and what this exporter writes) or cached formula text (cx:txData/cx:v).
220
+ const rich = value?.['cx:tx']?.['cx:rich'];
221
+ if (rich) {
222
+ const typed = asArray(rich['a:p']).map(paragraph => asArray(paragraph['a:r']).map(run => text(run['a:t'])).join('')).join(' ').trim();
223
+ if (typed) return typed;
224
+ }
225
+ const v = value?.['cx:tx']?.['cx:txData']?.['cx:v'];
226
+ return v === undefined ? undefined : (text(v).trim() || undefined);
227
+ }
228
+
229
+ /**
230
+ * The options of a parsed cx:chartSpace: `{axisTitles?, legend?, dataLabels?}` plus `notes`. A chartex export without the option
231
+ * writes the construct's defaults (a right legend on a multi-series box-and-whisker chart; category names on a treemap, values
232
+ * on a funnel), so only what differs from them is returned.
233
+ */
234
+ export function chartOptionsFromChartex(space, {type, seriesCount}) {
235
+ if (!coreKnowsChartOptions()) return {options: {}, notes: []};
236
+ const chart = space?.['cx:chart'];
237
+ const out = {};
238
+ const notes = [];
239
+ const axes = asArray(chart?.['cx:plotArea']?.['cx:axis']);
240
+ const axisTitles = {};
241
+ const category = axisTitleText(axes.find(axis => String(axis?.id) === '0')), value = axisTitleText(axes.find(axis => String(axis?.id) === '1'));
242
+ if (category) axisTitles.category = category;
243
+ if (value) axisTitles.value = value;
244
+ if (Object.keys(axisTitles).length) out.axisTitles = axisTitles;
245
+
246
+ const boxWhisker = type === 'box-and-whisker';
247
+ const legend = chart?.['cx:legend'];
248
+ if (legend) {
249
+ const mapped = LEGEND_POS_BACK[legend.pos ?? 'r'] ?? 'right';
250
+ if (boxWhisker && !(seriesCount > 1 && mapped === 'right')) out.legend = mapped;
251
+ else if (!boxWhisker) notes.push({option: 'legend', message: `The '${type}' chart's legend has no OPF option; it is not imported.`});
252
+ } else if (boxWhisker && seriesCount > 1) out.legend = 'none';
253
+
254
+ const series = asArray(chart?.['cx:plotArea']?.['cx:plotAreaRegion']?.['cx:series']).find(entry => entry?.ownerIdx === undefined);
255
+ const labels = series?.['cx:dataLabels'];
256
+ const defaultContent = type === 'treemap' ? ['category'] : type === 'funnel' ? ['value'] : undefined;
257
+ if (labels) {
258
+ const visibility = labels['cx:visibility'] ?? {};
259
+ const content = [];
260
+ if (cxFlag(visibility.categoryName)) content.push('category');
261
+ if (cxFlag(visibility.value)) content.push('value');
262
+ if (cxFlag(visibility.seriesName)) notes.push({option: 'dataLabels', message: 'Chartex data labels that show the series name cannot be expressed; only category and value import.'});
263
+ const position = CX_POSITION_BACK[labels.pos];
264
+ const support = typeof opfCore.chartOptionSupport === 'function' && typeof opfCore.chartOptionTarget === 'function' ? opfCore.chartOptionSupport(opfCore.chartOptionTarget(type) ?? {kind: type}) : undefined;
265
+ const isDefault = defaultContent && content.length === defaultContent.length && content.every((part, index) => part === defaultContent[index]) && (!position || position === 'center');
266
+ const separator = text(labels['cx:separator']);
267
+ const result = {};
268
+ if (!(content.length === 1 && content[0] === 'value')) result.content = content.length ? content : undefined;
269
+ if (position && position !== support?.dataLabels.defaultPosition && (support?.dataLabels.positions ?? [position]).includes(position)) result.position = position;
270
+ if (content.length > 1 && separator && separator !== ', ') result.separator = separator;
271
+ for (const key of Object.keys(result)) if (result[key] === undefined) delete result[key];
272
+ if (content.length && !(isDefault && !result.separator)) out.dataLabels = Object.keys(result).length ? result : true;
273
+ else if (!content.length && defaultContent) out.dataLabels = false;
274
+ } else if (defaultContent) out.dataLabels = false;
275
+ return {options: out, notes};
276
+ }
package/dist/chartex.js CHANGED
@@ -16,6 +16,7 @@
16
16
  // chart type (an owned `paretoLine` marks the Office Pareto chart), and the
17
17
  // cached dimensions restore the category-major data.
18
18
  import {CHARTEX_NAMESPACES, chartTypeFromChartex} from './chart-types.js';
19
+ import {chartOptionsFromChartex} from './chart-options.js';
19
20
 
20
21
  const NS = Object.freeze({
21
22
  a: 'http://schemas.openxmlformats.org/drawingml/2006/main',
@@ -126,7 +127,18 @@ function textProperties(labelColor, font, size) {
126
127
  return `<cx:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="${size}">${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>`;
127
128
  }
128
129
 
129
- function dataLabels(spec, text) {
130
+ const CX_POSITION = Object.freeze({center: 'ctr', 'inside-end': 'inEnd', 'inside-base': 'inBase', 'outside-end': 'outEnd'});
131
+
132
+ // RR-35: an explicit dataLabels option writes the selected content, position and separator for every construct that takes it;
133
+ // `dataLabels: false` removes the labels a treemap or funnel carries by default. Without the option the defaults are unchanged.
134
+ function dataLabels(spec, text, options) {
135
+ if (options?.dataLabelsOff) return '';
136
+ const labels = options?.dataLabels;
137
+ if (labels) {
138
+ const has = part => labels.content.includes(part) ? 1 : 0;
139
+ const position = labels.position === null ? (spec.layoutId === 'treemap' || spec.layoutId === 'funnel' ? 'ctr' : undefined) : CX_POSITION[labels.position];
140
+ return `<cx:dataLabels${position ? ` pos="${position}"` : ''}>${text}<cx:visibility seriesName="0" categoryName="${has('category')}" value="${has('value')}"/><cx:separator>${escapeXml(labels.separator)}</cx:separator></cx:dataLabels>`;
141
+ }
130
142
  if (spec.layoutId === 'treemap') return `<cx:dataLabels pos="ctr">${text}<cx:visibility seriesName="0" categoryName="1" value="0"/></cx:dataLabels>`;
131
143
  if (spec.layoutId === 'funnel') return `<cx:dataLabels pos="ctr">${text}<cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels>`;
132
144
  return '';
@@ -140,14 +152,26 @@ export function chartexPointColors(layoutId, values, palette) {
140
152
  return values.map(() => null);
141
153
  }
142
154
 
143
- function axes(spec, gridColor, text) {
155
+ // RR-35: an axis title is the plain text of the axis (CT_AxisTitle: tx, spPr, txPr), after the axis scaling.
156
+ // Native check 2026-10-01: PowerPoint empties a title written as cx:txData/cx:v (that form holds a cell formula's cached text, and no
157
+ // cx:f is written), so the title is rich text, the way PowerPoint writes a typed chartex title; the importer reads both forms.
158
+ const axisTitle = (value, text, run) => {
159
+ if (!value) return '';
160
+ const face = escapeXml(run.font), size = run.textSize;
161
+ const props = `sz="${size}" b="0"`;
162
+ const fill = `<a:solidFill><a:srgbClr val="${run.labelColor}"/></a:solidFill><a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/>`;
163
+ return `<cx:title><cx:tx><cx:rich><a:bodyPr spcFirstLastPara="1" vertOverflow="ellipsis" horzOverflow="overflow" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="ctr" anchorCtr="1"/><a:lstStyle/>` +
164
+ `<a:p><a:pPr algn="ctr"><a:defRPr ${props}>${fill}</a:defRPr></a:pPr><a:r><a:rPr lang="en-US" ${props}>${fill}</a:rPr><a:t>${escapeXml(value)}</a:t></a:r></a:p></cx:rich></cx:tx></cx:title>`;
165
+ };
166
+
167
+ function axes(spec, gridColor, text, titles = {}, run = {}) {
144
168
  if (!spec.axes) return '';
145
169
  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>`;
146
170
  const gapWidth = {clusteredColumn: '0.06', paretoLine: '0.06', boxWhisker: '1', waterfall: '0.5', funnel: '0.06'}[spec.layoutId] ?? '1';
147
171
  // CT_Axis order: scaling, units, majorGridlines, tickLabels, spPr, txPr.
148
- const category = `<cx:axis id="0"><cx:catScaling gapWidth="${gapWidth}"/><cx:tickLabels/>${text}</cx:axis>`;
172
+ const category = `<cx:axis id="0"><cx:catScaling gapWidth="${gapWidth}"/>${axisTitle(titles.category, text, run)}<cx:tickLabels/>${text}</cx:axis>`;
149
173
  if (spec.layoutId === 'funnel') return `${category}<cx:axis id="1" hidden="1"><cx:valScaling/><cx:tickLabels/>${text}</cx:axis>`;
150
- const value = `<cx:axis id="1"><cx:valScaling/>${gridlines}<cx:tickLabels/>${text}</cx:axis>`;
174
+ const value = `<cx:axis id="1"><cx:valScaling/>${axisTitle(titles.value, text, run)}${gridlines}<cx:tickLabels/>${text}</cx:axis>`;
151
175
  const percentage = spec.layoutId === 'paretoLine' ? `<cx:axis id="2"><cx:valScaling max="1" min="0"/><cx:units unit="percentage"/><cx:tickLabels/>${text}</cx:axis>` : '';
152
176
  return `${category}${value}${percentage}`;
153
177
  }
@@ -159,7 +183,7 @@ function axes(spec, gridColor, text) {
159
183
  * layout is PptxGenJS's: Sheet1, headings in row 1, categories in column A and
160
184
  * one series per following column.
161
185
  */
162
- export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette, textSize}) {
186
+ export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette, textSize, options}) {
163
187
  const rows = series[0].labels.length;
164
188
  const range = (letter) => `Sheet1!$${letter}$2:$${letter}$${rows + 1}`;
165
189
  const categories = hasCategories
@@ -179,19 +203,23 @@ export function chartexPartXml({spec, series, hasCategories, number, workbookRel
179
203
  const line = spec.layoutId === 'boxWhisker' ? `<a:ln w="9525">${solidFill(labelColor)}</a:ln>` : '';
180
204
  return `<cx:series layoutId="${spec.owner ?? spec.layoutId}" uniqueId="${seriesUniqueId(number, index)}">` +
181
205
  `<cx:tx><cx:txData><cx:f>Sheet1!$${letter}$1</cx:f><cx:v>${escapeXml(entry.name)}</cx:v></cx:txData></cx:tx>` +
182
- `<cx:spPr>${solidFill(color)}${line}</cx:spPr>${pointFills}${dataLabels(spec, text)}<cx:dataId val="${index}"/>${layoutProperties(spec, entry, hasCategories)}` +
206
+ `<cx:spPr>${solidFill(color)}${line}</cx:spPr>${pointFills}${dataLabels(spec, text, options)}<cx:dataId val="${index}"/>${layoutProperties(spec, entry, hasCategories)}` +
183
207
  (spec.axes ? '<cx:axisId val="0"/><cx:axisId val="1"/>' : '') + '</cx:series>';
184
208
  });
185
209
  if (spec.owner) {
186
210
  // The Office Pareto chart: the cumulative-percentage line owned by the binned columns, on the percentage axis.
187
211
  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>`);
188
212
  }
189
- const legend = spec.layoutId === 'boxWhisker' && series.length > 1 ? `<cx:legend pos="r" align="ctr" overlay="0">${text}</cx:legend>` : '';
213
+ // RR-35: a box-and-whisker legend follows the option (`none` removes it, a position moves it); the other constructs take none.
214
+ const legendPosition = {top: 't', bottom: 'b', left: 'l', right: 'r'}[options?.legend];
215
+ const legend = spec.layoutId === 'boxWhisker' && options?.legend !== undefined
216
+ ? (legendPosition ? `<cx:legend pos="${legendPosition}" align="ctr" overlay="0">${text}</cx:legend>` : '')
217
+ : spec.layoutId === 'boxWhisker' && series.length > 1 ? `<cx:legend pos="r" align="ctr" overlay="0">${text}</cx:legend>` : '';
190
218
  const alpha = fill.transparency > 0 ? Math.round((100 - fill.transparency) * 1000) : undefined;
191
219
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
192
220
  `<cx:chartSpace xmlns:a="${NS.a}" xmlns:r="${NS.r}" xmlns:cx="${NS.cx}">` +
193
221
  `<cx:chartData><cx:externalData r:id="${workbookRelId}" cx:autoUpdate="0"/>${data}</cx:chartData>` +
194
- `<cx:chart><cx:plotArea><cx:plotAreaRegion>${plotted.join('')}</cx:plotAreaRegion>${axes(spec, gridColor, text)}</cx:plotArea>${legend}</cx:chart>` +
222
+ `<cx:chart><cx:plotArea><cx:plotAreaRegion>${plotted.join('')}</cx:plotAreaRegion>${axes(spec, gridColor, text, options?.axisTitles, {labelColor, font, textSize})}</cx:plotArea>${legend}</cx:chart>` +
195
223
  `<cx:spPr>${solidFill(fill.color, alpha)}<a:ln><a:noFill/></a:ln></cx:spPr>${text}` +
196
224
  '</cx:chartSpace>';
197
225
  }
@@ -324,7 +352,7 @@ const scalar = value => typeof value === 'string' ? value : value && typeof valu
324
352
  * heading recovered from the workbook, `limits` the cache bounds
325
353
  * ({points, cells}) and `invalid(message, path)` throws the import error.
326
354
  */
327
- export function chartFromChartex(doc, {heading, limits, invalid, path}) {
355
+ export function chartFromChartex(doc, {heading, limits, invalid, path, report}) {
328
356
  const space = doc?.['cx:chartSpace'];
329
357
  const region = space?.['cx:chart']?.['cx:plotArea']?.['cx:plotAreaRegion'];
330
358
  const allSeries = asArray(region?.['cx:series']);
@@ -384,8 +412,11 @@ export function chartFromChartex(doc, {heading, limits, invalid, path}) {
384
412
  if (rowCount === 0) return null;
385
413
  if (rowCount * (values.length + (labels ? 1 : 0)) > limits.cells) invalid('chart cache exceeds the 1,000,000-cell import limit', path);
386
414
  // A histogram or Pareto chart binned from one value column has no category dimension: the column comes back as authored.
387
- if (!labels) return {type, data: {columns: [names[0]], rows: values[0].slice(0, rowCount).map((value, index) => [values[0][index] ?? null])}};
415
+ // RR-35: axis titles, legend position and data labels read back into the chart's option fields.
416
+ const {options, notes} = chartOptionsFromChartex(space, {type, seriesCount: plotted.length});
417
+ for (const note of notes) report?.(note);
418
+ if (!labels) return {type, data: {columns: [names[0]], rows: values[0].slice(0, rowCount).map((value, index) => [values[0][index] ?? null])}, ...options};
388
419
  const rows = [];
389
420
  for (let index = 0; index < rowCount; index += 1) rows.push([labels[index] ?? null, ...values.map(row => row[index] ?? null)]);
390
- return {type, data: {columns: [heading ?? 'Category', ...names], rows}};
421
+ return {type, data: {columns: [heading ?? 'Category', ...names], rows}, ...options};
391
422
  }
@@ -1,4 +1,5 @@
1
1
  import { XMLParser } from 'fast-xml-parser';
2
+ import { withDisplayedNumbers } from './numbered-list.js';
2
3
 
3
4
  // Native shape tags are standard PresentationML customer data. Uppercase hex
4
5
  // protects case-sensitive source from PowerPoint's case-insensitive Tags API.
@@ -29,7 +30,7 @@ export function attachCodeTags(entries, records) {
29
30
 
30
31
  export {unhex as decodeTextTag, hex as encodeTextTag};
31
32
 
32
- export function attachTextTags(entries, records, tagName, prefix, kind, {pictures = false} = {}) {
33
+ export function attachTextTags(entries, records, tagName, prefix, kind, {pictures = false, descriptions} = {}) {
33
34
  if (!records.size) return;
34
35
  let count = 0;
35
36
  const seen = new Set(), types = [];
@@ -50,6 +51,9 @@ export function attachTextTags(entries, records, tagName, prefix, kind, {picture
50
51
  entries[part] = enc.encode(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:tagLst xmlns:p="${NS}"><p:tag name="${tagName}" val="${hex(records.get(name))}"/></p:tagLst>`);
51
52
  types.push(`<Override PartName="/${part}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
52
53
  rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/${prefix}${count}.xml"/></Relationships>`);
54
+ // Optional alternative text (a:cNvPr descr) for generated decoration such as the metric trend arrow.
55
+ const descr = descriptions?.get(name);
56
+ if (descr !== undefined) shape = shape.replace(/<p:cNvPr\b([^>]*?)>/, (_match, attributes) => `<p:cNvPr${attributes} descr="${descr.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;')}">`);
53
57
  shape = shape.replace(/<p:nvPr\s*\/>/,'<p:nvPr></p:nvPr>');
54
58
  if (!shape.includes('</p:nvPr>')) throw new Error(`Generated ${kind} shape has no native application properties.`);
55
59
  return shape.replace('</p:nvPr>',`<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
@@ -72,26 +76,46 @@ export function nativeShapeParagraphs(xml, rootElement = 'p:sld') {
72
76
  const root = children(reader.parse(xml),rootElement)[0];
73
77
  const tree = children(children(root,'p:cSld')[0],'p:spTree')[0];
74
78
  return orderedShapes(tree).map(shape=>children(children(shape,'p:txBody')[0],'a:p').map(paragraph=>{
75
- let text = '', maxFontSize = 0, bullet = false, level = 0;
79
+ let text = '', maxFontSize = 0, bullet = false, level = 0, math = null, autoNum;
76
80
  const fields = [];
77
- for (const child of paragraph) {
78
- if (child['a:br'] !== undefined) text += '\n';
79
- if (child['a:fld'] !== undefined) fields.push({type: String(child[':@']?.type ?? ''), text: children(child['a:fld'],'a:t').map(plainText).join('')});
80
- for (const key of ['a:r','a:fld']) if (child[key] !== undefined) {
81
- text += children(child[key],'a:t').map(plainText).join('');
82
- for (const run of child[key]) {
81
+ const readRuns = runs => {
82
+ let value = '';
83
+ for (const key of ['a:r','a:fld']) if (runs[key] !== undefined) {
84
+ value += children(runs[key],'a:t').map(plainText).join('');
85
+ for (const run of runs[key]) {
83
86
  const size = Number(run[':@']?.sz);
84
87
  if (run['a:rPr'] !== undefined && Number.isFinite(size)) maxFontSize = Math.max(maxFontSize,size/100);
85
88
  }
86
89
  }
90
+ return value;
91
+ };
92
+ for (const child of paragraph) {
93
+ if (child['a:br'] !== undefined) text += '\n';
94
+ if (child['a:fld'] !== undefined) fields.push({type: String(child[':@']?.type ?? ''), text: children(child['a:fld'],'a:t').map(plainText).join('')});
95
+ text += readRuns(child);
96
+ if (child['mc:AlternateContent'] !== undefined) {
97
+ // FF-45: an a14:m math zone (an OMML equation, m:oMathPara) has no OPF model. Its mc:Fallback runs (the text PowerPoint
98
+ // writes for older readers) are kept as plain text; without them the equation's m:t text is kept in order. The paragraph
99
+ // records the zones so the importer diagnoses the lost equation layout instead of dropping the paragraph silently.
100
+ const zones = children(child['mc:AlternateContent'],'mc:Choice').flatMap(choice=>children(choice,'a14:m'));
101
+ const fallback = children(child['mc:AlternateContent'],'mc:Fallback').flatMap(nodes=>nodes.map(readRuns)).join('');
102
+ const linear = zones.map(mathText).join('');
103
+ if (zones.length) math = {zones: (math?.zones ?? 0) + zones.length, text: (math?.text ?? '') + linear};
104
+ text += fallback || linear;
105
+ }
87
106
  if (child['a:pPr'] !== undefined) {
88
107
  level = Number(child[':@']?.lvl ?? 0);
89
108
  bullet = child['a:pPr'].some(node=>node['a:buChar'] !== undefined || node['a:buBlip'] !== undefined || node['a:buAutoNum'] !== undefined);
109
+ // RR-33: a native auto-number; the last bullet element of the paragraph properties wins.
110
+ const numbering = child['a:pPr'].findLast(node=>node['a:buAutoNum'] !== undefined || node['a:buNone'] !== undefined || node['a:buChar'] !== undefined || node['a:buBlip'] !== undefined);
111
+ autoNum = numbering?.['a:buAutoNum'] !== undefined ? {type: numbering[':@']?.type, startAt: numbering[':@']?.startAt} : undefined;
90
112
  }
91
113
  }
92
- return {text,maxFontSize,bullet,level,fields};
93
- }));
114
+ return {text,maxFontSize,bullet,level,fields,...(math ? {math} : {}),...(autoNum ? {autoNum} : {})};
115
+ })).map(withDisplayedNumbers);
94
116
  }
117
+ /** The m:t text of an OMML tree in document order (its linear reading, without the equation's layout). */
118
+ export const mathText = nodes => array(nodes).flatMap(node=>Object.entries(node).flatMap(([key,value])=>key === ':@' || key === '#text' ? [] : key === 'm:t' ? [plainText(value)] : mathText(value))).join('');
95
119
 
96
120
  function sourceFor(value, role, generated) {
97
121
  if (generated) return 'code';