@openpresentation/opf-pptx 0.13.1 → 0.14.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.
@@ -0,0 +1,113 @@
1
+ // FA-14: chart emphasis (`chart.highlight`) in the PPTX export.
2
+ //
3
+ // Core owns what is highlighted (`resolveChartOptions(...).highlight`, `chartHighlightMarks`) and the two colours
4
+ // (`chartHighlightColors`); the preview (opf-render) calls the same functions, so both engines colour the same marks the same way.
5
+ // This module writes them natively into the generated classic chart part:
6
+ //
7
+ // column, bar, area, scatter, radar the series colour (c:ser/c:spPr, and c:marker/c:spPr) of every series, accent when the
8
+ // series is named, muted otherwise; clustered and stacked alike;
9
+ // column and bar, category highlight one c:dPt (idx, invertIfNegative, bubble3D, spPr) per point whose colour differs from its
10
+ // series' colour;
11
+ // line the series line and marker colour, and for a category highlight one c:dPt with a circular
12
+ // c:marker per highlighted point (a line without markers shows markers only there);
13
+ // pie, doughnut the fill of the c:dPt of every slice, accent for a highlighted category, muted otherwise.
14
+ //
15
+ // The highlight colour is the deck primary: `a:schemeClr val="accent1"` when the deck theme holds exactly that colour (so a theme
16
+ // edit in PowerPoint recolours it), the literal sRGB otherwise (a primary that needed a contrast adjustment against the chart panel).
17
+ // The muted colour is a mix of the panel and the text colour, which no single scheme colour with lumMod/lumOff reproduces, so it is
18
+ // an sRGB literal. A chart without a highlight never reaches this module, so its parts are unchanged.
19
+ import {chartHighlightColors, chartHighlightMarks} from '@openpresentation/opf/composition';
20
+
21
+ const srgb = hex => `<a:srgbClr val="${String(hex).replace(/^#/, '').toUpperCase()}"/>`;
22
+
23
+ /**
24
+ * The highlight of an exported chart: `{kind, marks, accent, muted, seriesFills, pointFills}` or undefined.
25
+ * `options` is core's `resolveChartOptions` result, `data` the resolved chart data (`columns`, `hasX`, `rows`), `kind` the chart's option
26
+ * kind, `panelFill` and `labelColor` the chart panel and text colours (as the preview uses them), `primary` the deck primary and
27
+ * `scheme` its scheme value when the theme holds it exactly (core's colour for the accent is compared with it here).
28
+ *
29
+ * Colours are `{hex, xml}`: `hex` (RRGGBB) feeds the data label contrast, `xml` is the DrawingML colour element.
30
+ */
31
+ export function chartHighlightPlan({options, data, kind, panelFill, labelColor, primary, schemeFor}) {
32
+ if (!options?.highlight || !data) return undefined;
33
+ const marks = chartHighlightMarks(options.highlight, data);
34
+ if (!marks) return undefined;
35
+ const colors = chartHighlightColors(panelFill, primary, `#${labelColor}`);
36
+ const accentHex = colors.accent.replace(/^#/, '').toUpperCase();
37
+ const scheme = schemeFor?.(colors.accent);
38
+ const accent = {hex: accentHex, xml: scheme ? `<a:schemeClr val="${scheme}"/>` : srgb(accentHex)};
39
+ const muted = {hex: colors.muted.replace(/^#/, '').toUpperCase(), xml: srgb(colors.muted)};
40
+ const seriesColor = index => marks.series[index] ? accent : muted;
41
+ const markColor = (series, row) => marks.series[series] || marks.categories[row] ? accent : muted;
42
+ const circular = kind === 'pie' || kind === 'doughnut';
43
+ return {
44
+ kind, marks, accent, muted, seriesColor, markColor, circular,
45
+ // The series colours (data label contrast of a series-level label) and, where marks differ within a series, the point colours.
46
+ seriesFills: Array.from({length: marks.series.length}, (_, index) => seriesColor(index).hex),
47
+ pointFills: series => circular ? marks.categories.map(row => (row ? accent : muted).hex)
48
+ : kind === 'bar' && marks.categories.some(Boolean) ? marks.categories.map((_, row) => markColor(series, row).hex) : undefined,
49
+ };
50
+ }
51
+
52
+ const fill = color => `<a:solidFill>${color.xml}</a:solidFill>`;
53
+ const recolor = (block, color) => block.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/g, fill(color));
54
+
55
+ // The series' own c:spPr: the first one, which precedes c:marker, c:dPt, c:dLbls and the data references in CT_*Ser.
56
+ function seriesProperties(ser) {
57
+ const at = ser.indexOf('<c:spPr>');
58
+ const limit = ['<c:marker>', '<c:dPt>', '<c:dLbls>', '<c:cat>', '<c:xVal>', '<c:val>'].map(tag => ser.indexOf(tag)).filter(index => index >= 0);
59
+ if (at < 0 || limit.some(index => index < at)) throw new Error('A generated chart series carries no series properties.');
60
+ return at;
61
+ }
62
+
63
+ function recolorSeries(ser, color) {
64
+ const at = seriesProperties(ser);
65
+ const end = ser.indexOf('</c:spPr>', at) + '</c:spPr>'.length;
66
+ let out = ser.slice(0, at) + recolor(ser.slice(at, end), color) + ser.slice(end);
67
+ out = out.replace(/<c:marker>[\s\S]*?<\/c:marker>/, marker => recolor(marker, color));
68
+ return out;
69
+ }
70
+
71
+ const barPoint = (row, color) => `<c:dPt><c:idx val="${row}"/><c:invertIfNegative val="0"/><c:bubble3D val="0"/><c:spPr>${fill(color)}<a:effectLst/></c:spPr></c:dPt>`;
72
+ const linePoint = (row, color) => `<c:dPt><c:idx val="${row}"/><c:marker><c:symbol val="circle"/><c:size val="6"/><c:spPr>${fill(color)}<a:ln w="9525" cap="flat">${fill(color)}<a:prstDash val="solid"/><a:round/></a:ln><a:effectLst/></c:spPr></c:marker><c:bubble3D val="0"/></c:dPt>`;
73
+
74
+ /**
75
+ * Rewrite the series of a generated classic chart for the highlight plan (see the module comment). `xml` is the chart part text.
76
+ * Series are matched to the plan by position, which is how core orders them (the columns after the category, and after the X
77
+ * column of a scatter chart): the generated part has one c:ser per plotted series, in that order.
78
+ */
79
+ export function applyChartHighlight(xml, plan) {
80
+ const {kind, marks, accent, muted} = plan;
81
+ let seriesIndex = 0;
82
+ return xml.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => {
83
+ const index = seriesIndex++;
84
+ if (plan.circular) {
85
+ return ser.replace(/<c:dPt>([\s\S]*?)<\/c:dPt>/g, (point, inner) => {
86
+ const row = Number(/<c:idx val="(\d+)"\/>/.exec(inner)?.[1]);
87
+ return marks.categories[row] === undefined ? point : recolor(point, marks.categories[row] ? accent : muted);
88
+ });
89
+ }
90
+ if (index >= marks.series.length) return ser;
91
+ let out = recolorSeries(ser, plan.seriesColor(index));
92
+ if (kind === 'bar' && marks.categories.some(Boolean)) {
93
+ const points = marks.categories.flatMap((_, row) => plan.markColor(index, row) === plan.seriesColor(index) ? [] : [barPoint(row, plan.markColor(index, row))]).join('');
94
+ if (points) out = out.replace(/<c:dLbls>|<c:cat>/, anchor => `${points}${anchor}`);
95
+ } else if (kind === 'line' && marks.categories.some(Boolean)) {
96
+ // A line shows a marker at every highlighted category: on a line with markers only where the series colour is not already the
97
+ // accent, on a line without markers (symbol none) at every highlighted point.
98
+ const hasMarkers = !/<c:marker>\s*<c:symbol val="none"\/>/.test(out);
99
+ const points = marks.categories.flatMap((highlighted, row) => highlighted && (!hasMarkers || plan.markColor(index, row) !== plan.seriesColor(index)) ? [linePoint(row, plan.markColor(index, row))] : []).join('');
100
+ if (points) {
101
+ // CT_LineSer: marker, dPt, dLbls, trendline, errBars, cat. The generator writes c:dLbls before c:marker; it moves behind the points.
102
+ const labels = /<c:dLbls>[\s\S]*?<\/c:dLbls>/.exec(out);
103
+ const markerEnd = out.indexOf('</c:marker>') + '</c:marker>'.length;
104
+ if (labels && labels.index < markerEnd) {
105
+ out = out.slice(0, labels.index) + out.slice(labels.index + labels[0].length);
106
+ const end = out.indexOf('</c:marker>') + '</c:marker>'.length;
107
+ out = `${out.slice(0, end)}${points}${labels[0]}${out.slice(end)}`;
108
+ } else out = `${out.slice(0, markerEnd)}${points}${out.slice(markerEnd)}`;
109
+ }
110
+ }
111
+ return out;
112
+ });
113
+ }
@@ -8,17 +8,14 @@
8
8
  // chartex charts cx:axis/cx:title, cx:legend pos and cx:dataLabels pos (see chartex.js, which calls the helpers here).
9
9
  //
10
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';
11
+ import {chartOptionSupport, chartOptionTarget, resolveChartOptions, textColorForFill} from '@openpresentation/opf/composition';
12
12
 
13
13
  const INACTIVE = Object.freeze({active: false, axisTitles: {}, diagnostics: []});
14
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). */
15
+ /** The resolved options of a chart. */
19
16
  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));
17
+ if (!chart || typeof chart !== 'object') return INACTIVE;
18
+ return resolveChartOptions(chart, chartOptionTarget(chart.type));
22
19
  }
23
20
 
24
21
  export function reportChartOptionDiagnostics(resolved, path, onDiagnostic) {
@@ -79,17 +76,33 @@ const dLbl = (index, color, labels, {size, font}) =>
79
76
  * - the label text colour: the chart text colour outside a mark, the contrasting colour (core's `textColorForFill`, as the
80
77
  * preview) inside one, per series, and per point on pie and doughnut charts.
81
78
  *
82
- * `info`: {resolved, kind, palette (series colours, hex without #), labelColor, font, textSize, pointCount}.
79
+ * FA-14: with a chart highlight (`info.highlight`, src/chart-highlight.js) `palette` holds the highlight-aware series colours, and a
80
+ * label inside a mark whose colour differs per point (pie and doughnut slices, columns and bars with a category highlight) is written
81
+ * per point with the colour that contrasts with that point's fill.
82
+ *
83
+ * `info`: {resolved, kind, palette (series colours, hex without #), labelColor, font, textSize, pointCount, highlight?}.
83
84
  */
84
85
  export function applyDataLabels(xml, info) {
85
- const {resolved, kind, palette, labelColor, font, textSize, pointCount} = info;
86
+ // FA-15: a combo chart labels its column group like clustered columns and its line groups like line points (core's
87
+ // `linePosition`); the series colours run on across the groups.
88
+ if (info.kind === 'combo') {
89
+ let offset = 0;
90
+ return xml.replace(/<c:(barChart|lineChart)>[\s\S]*?<\/c:\1>/g, (group, element) => {
91
+ const line = element === 'lineChart';
92
+ const dataLabels = line ? {...info.resolved.dataLabels, position: info.resolved.dataLabels.linePosition ?? 'above'} : info.resolved.dataLabels;
93
+ const palette = info.palette.slice(offset).concat(info.palette.slice(0, offset));
94
+ offset = (offset + (group.match(/<c:ser>/g)?.length ?? 0)) % Math.max(1, info.palette.length);
95
+ return applyDataLabels(group, {...info, kind: line ? 'line' : 'bar', palette, resolved: {...info.resolved, dataLabels}});
96
+ });
97
+ }
98
+ const {resolved, kind, palette, labelColor, font, textSize, pointCount, highlight} = info;
86
99
  const labels = resolved.dataLabels;
87
100
  // 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
101
  // take the colour that contrasts with the mark; every other label keeps the chart text colour (as the preview does).
89
102
  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('')}` +
103
+ const contrast = fill => String(textColorForFill(`#${fill}`, `#${labelColor}`)).replace(/^#/, '').toUpperCase();
104
+ const block = (color, {points = 0, leaderLines = false, fills} = {}) =>
105
+ `<c:dLbls>${Array.from({length: points}, (_, index) => dLbl(index, inside ? contrast(fills?.[index] ?? palette[index % palette.length]) : labelColor, labels, {size: textSize, font})).join('')}` +
93
106
  `<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
107
  const circular = kind === 'pie' || kind === 'doughnut';
95
108
  const replaceBlocks = (text, make) => text.replace(/<c:dLbls>[\s\S]*?<\/c:dLbls>/g, existing => make(existing));
@@ -98,7 +111,9 @@ export function applyDataLabels(xml, info) {
98
111
  const withSeries = xml.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => {
99
112
  const index = seriesIndex++;
100
113
  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)}));
114
+ const fills = highlight?.pointFills(index);
115
+ const points = circular ? pointCount : inside && fills ? fills.length : 0;
116
+ return replaceBlocks(ser, existing => block(color, {points, fills, leaderLines: /<c:showLeaderLines\b/.test(existing)}));
102
117
  });
103
118
  const outside = withSeries.split(/(<c:ser>[\s\S]*?<\/c:ser>)/);
104
119
  const groupLevel = outside.map((part, index) => index % 2 ? part : replaceBlocks(part, existing => block(labelColor, {leaderLines: /<c:showLeaderLines\b/.test(existing)}))).join('');
@@ -133,9 +148,7 @@ function titleString(title) {
133
148
  * plus `notes` naming what the three fields cannot express. `defaults` is what an export writes without the field:
134
149
  * `{legend: 'right' | undefined}` (a legend at the right of multi-series, pie and doughnut charts) and the chart kind.
135
150
  */
136
- export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCount, circular, scatter, seriesFormats = []}) {
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: []};
151
+ export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCount, circular, scatter, seriesFormats = [], combo}) {
139
152
  const chart = chartSpace?.['c:chart'];
140
153
  const plotArea = chart?.['c:plotArea'];
141
154
  const out = {};
@@ -151,6 +164,9 @@ export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCo
151
164
  const category = titleString(asArray(plotArea?.['c:catAx'])[0]?.['c:title']), value = titleString(asArray(plotArea?.['c:valAx'])[0]?.['c:title']);
152
165
  if (category) axisTitles.category = category;
153
166
  if (value) axisTitles.value = value;
167
+ // FA-15: a combo chart's secondary value axis title.
168
+ const secondary = titleString(combo?.secondaryValueAxis?.['c:title']);
169
+ if (secondary) axisTitles.secondary = secondary;
154
170
  }
155
171
  if (Object.keys(axisTitles).length) out.axisTitles = axisTitles;
156
172
 
@@ -164,12 +180,12 @@ export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCo
164
180
  if (!(defaultLegend && position === 'right')) out.legend = position;
165
181
  } else if (defaultLegend) out.legend = 'none';
166
182
 
167
- const dataLabels = classicDataLabels(chartNode, target, notes, seriesFormats);
183
+ const dataLabels = classicDataLabels(chartNode, target, notes, seriesFormats, combo);
168
184
  if (dataLabels !== undefined) out.dataLabels = dataLabels;
169
185
  return {options: out, notes};
170
186
  }
171
187
 
172
- function classicDataLabels(chartNode, target, notes, seriesFormats = []) {
188
+ function classicDataLabels(chartNode, target, notes, seriesFormats = [], combo) {
173
189
  const series = asArray(chartNode?.['c:ser']);
174
190
  // The labels the chart shows: the first series' block when it has one (PptxGenJS writes pie and doughnut visibility as per
175
191
  // point overrides), else the chart group's block.
@@ -186,12 +202,19 @@ function classicDataLabels(chartNode, target, notes, seriesFormats = []) {
186
202
  for (const hidden of ['c:showSerName', 'c:showLegendKey', 'c:showBubbleSize']) {
187
203
  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
204
  }
189
- const support = target && typeof opfCore.chartOptionSupport === 'function' ? opfCore.chartOptionSupport(target) : undefined;
205
+ const support = target ? chartOptionSupport(target) : undefined;
190
206
  const numberFormat = source['c:numFmt']?.formatCode ?? block['c:numFmt']?.formatCode;
191
207
  // RR-54: a label format that is a series' own number format imports with that column's format (or its own note).
192
208
  if (numberFormat && numberFormat !== 'General' && numberFormat !== '#,##0' && numberFormat !== '0%' && !seriesFormats.includes(numberFormat)) notes.push({option: 'dataLabels', message: `The label number format '${numberFormat}' cannot be expressed; labels import in the General format.`});
193
209
  const out = {};
194
- const position = DLBL_POS_BACK[source['c:dLblPos']?.val ?? block['c:dLblPos']?.val];
210
+ let position = DLBL_POS_BACK[source['c:dLblPos']?.val ?? block['c:dLblPos']?.val];
211
+ if (combo) {
212
+ // FA-15: one OPF position covers both parts. A line position the columns cannot take (below, left, right) was asked for the
213
+ // lines; otherwise the column position (center, inside-end, inside-base) is the one, and 'center' is written to both.
214
+ const lineSeries = asArray(combo.lineNode?.['c:ser']).map(entry => entry?.['c:dLbls']).find(entry => entry !== undefined) ?? combo.lineNode?.['c:dLbls'];
215
+ const linePosition = DLBL_POS_BACK[lineSeries?.['c:dLblPos']?.val];
216
+ if (['below', 'left', 'right'].includes(linePosition)) position = linePosition;
217
+ }
195
218
  const supported = support?.dataLabels.positions ?? [];
196
219
  if (position && position !== support?.dataLabels.defaultPosition) {
197
220
  if (!support || supported.includes(position)) out.position = position;
@@ -204,9 +227,9 @@ function classicDataLabels(chartNode, target, notes, seriesFormats = []) {
204
227
  return Object.keys(out).length ? out : true;
205
228
  }
206
229
 
207
- /** The core option target of a chart type id (undefined for an id outside the catalog or an older core). */
230
+ /** The core option target of a chart type id (undefined for an id outside the catalog). */
208
231
  export function chartTargetFor(typeId) {
209
- return typeof opfCore.chartOptionTarget === 'function' ? opfCore.chartOptionTarget(typeId) : undefined;
232
+ return chartOptionTarget(typeId);
210
233
  }
211
234
 
212
235
  // ---------------------------------------------------------------------------
@@ -233,7 +256,6 @@ function axisTitleText(axis) {
233
256
  * on a funnel), so only what differs from them is returned.
234
257
  */
235
258
  export function chartOptionsFromChartex(space, {type, seriesCount}) {
236
- if (!coreKnowsChartOptions()) return {options: {}, notes: []};
237
259
  const chart = space?.['cx:chart'];
238
260
  const out = {};
239
261
  const notes = [];
@@ -262,7 +284,7 @@ export function chartOptionsFromChartex(space, {type, seriesCount}) {
262
284
  if (cxFlag(visibility.value)) content.push('value');
263
285
  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.'});
264
286
  const position = CX_POSITION_BACK[labels.pos];
265
- const support = typeof opfCore.chartOptionSupport === 'function' && typeof opfCore.chartOptionTarget === 'function' ? opfCore.chartOptionSupport(opfCore.chartOptionTarget(type) ?? {kind: type}) : undefined;
287
+ const support = chartOptionSupport(chartOptionTarget(type) ?? {kind: type});
266
288
  const isDefault = defaultContent && content.length === defaultContent.length && content.every((part, index) => part === defaultContent[index]) && (!position || position === 'center');
267
289
  const separator = text(labels['cx:separator']);
268
290
  const result = {};
@@ -1,34 +1,35 @@
1
1
  // OPF chart type ids -> native PowerPoint chart constructs (FF-22).
2
2
  //
3
3
  // The core catalog keeps one chart type per Aspose.Slides ChartType
4
- // (spec/catalogs/chart-types, OpenPresentation/opf#121) and deprecates the
5
- // rest with a replacement id. The published core package this exporter
6
- // depends on predates that metadata, so the mapping is carried here. Kept ids
7
- // map to the exact Office construct; deprecated ids resolve to their
8
- // replacement; any other id keeps the legacy substring heuristic.
4
+ // (spec/catalogs/chart-types, OpenPresentation/opf#121). The mapping is carried
5
+ // here so the exporter stays self-contained. A catalog id maps to the exact
6
+ // Office construct; any other id keeps the legacy substring heuristic.
9
7
 
10
8
  const category = (pptx, extra = {}) => ({ family: 'category', pptx, ...extra });
11
9
 
12
10
  export const CHART_TYPES = Object.freeze({
13
11
  column: category('bar', { aspose: 'ClusteredColumn', barDir: 'col', grouping: 'clustered' }),
14
- 'stacked-column-3x': category('bar', { aspose: 'StackedColumn', barDir: 'col', grouping: 'stacked' }),
15
- '100pct-stacked-column-3x': category('bar', { aspose: 'PercentsStackedColumn', barDir: 'col', grouping: 'percentStacked' }),
12
+ 'stacked-column': category('bar', { aspose: 'StackedColumn', barDir: 'col', grouping: 'stacked' }),
13
+ '100pct-stacked-column': category('bar', { aspose: 'PercentsStackedColumn', barDir: 'col', grouping: 'percentStacked' }),
16
14
  bar: category('bar', { aspose: 'ClusteredBar', barDir: 'bar', grouping: 'clustered' }),
17
- 'stacked-bar-3x': category('bar', { aspose: 'StackedBar', barDir: 'bar', grouping: 'stacked' }),
18
- '100pct-stacked-bar-3x': category('bar', { aspose: 'PercentsStackedBar', barDir: 'bar', grouping: 'percentStacked' }),
15
+ 'stacked-bar': category('bar', { aspose: 'StackedBar', barDir: 'bar', grouping: 'stacked' }),
16
+ '100pct-stacked-bar': category('bar', { aspose: 'PercentsStackedBar', barDir: 'bar', grouping: 'percentStacked' }),
19
17
  line: category('line', { aspose: 'Line', grouping: 'standard', markers: false }),
20
18
  'line-with-markers': category('line', { aspose: 'LineWithMarkers', grouping: 'standard', markers: true }),
21
- 'stacked-line-3x': category('line', { aspose: 'StackedLine', grouping: 'stacked', markers: false }),
22
- 'stacked-line-with-markers-3x': category('line', { aspose: 'StackedLineWithMarkers', grouping: 'stacked', markers: true }),
19
+ 'stacked-line': category('line', { aspose: 'StackedLine', grouping: 'stacked', markers: false }),
20
+ 'stacked-line-with-markers': category('line', { aspose: 'StackedLineWithMarkers', grouping: 'stacked', markers: true }),
23
21
  area: category('area', { aspose: 'Area', grouping: 'standard' }),
24
- 'stacked-area-3x': category('area', { aspose: 'StackedArea', grouping: 'stacked' }),
25
- '100pct-stacked-area-3x': category('area', { aspose: 'PercentsStackedArea', grouping: 'percentStacked' }),
22
+ 'stacked-area': category('area', { aspose: 'StackedArea', grouping: 'stacked' }),
23
+ '100pct-stacked-area': category('area', { aspose: 'PercentsStackedArea', grouping: 'percentStacked' }),
26
24
  pie: { family: 'circular', pptx: 'pie', aspose: 'Pie' },
27
25
  doughnut: { family: 'circular', pptx: 'doughnut', aspose: 'Doughnut' },
28
26
  scatter: { family: 'xy', pptx: 'scatter', aspose: 'ScatterWithMarkers' },
29
27
  radar: category('radar', { aspose: 'Radar', radarStyle: 'standard', markers: false }),
30
28
  'radar-with-markers': category('radar', { aspose: 'RadarWithMarkers', radarStyle: 'marker', markers: true }),
31
29
  'filled-radar': category('radar', { aspose: 'FilledRadar', radarStyle: 'filled', markers: false }),
30
+ // FA-15: clustered columns (a c:barChart on the primary axes) with line series with markers (a c:lineChart per value axis;
31
+ // the secondary one crosses at the right on a deleted second c:catAx). Core's resolveChartData says which series is which.
32
+ combo: { family: 'combo', pptx: 'bar', aspose: 'ClusteredColumn', barDir: 'col', grouping: 'clustered', markers: true },
32
33
  // Office 2016 chartex constructs (cx:chartSpace, one cx:series per plot,
33
34
  // named by layoutId). `requires` is the markup-compatibility namespace the
34
35
  // slide's mc:Choice names, so older readers take the classic fallback.
@@ -75,51 +76,13 @@ export function chartTypeFromChartex(layoutIds) {
75
76
  return null;
76
77
  }
77
78
 
78
- const variants = (base, target) => Object.fromEntries([base, `${base}-2x`, `${base}-3x`].map((id) => [id, target]));
79
-
80
- // Deprecated core ids (opf 0.12.0 removes them) -> replacement id.
81
- export const DEPRECATED_CHART_TYPES = Object.freeze({
82
- ...variants('100pct-bullet-bar', '100pct-stacked-bar-3x'),
83
- '100pct-progress-bar': '100pct-stacked-bar-3x',
84
- '100pct-stacked-bar-2x': '100pct-stacked-bar-3x',
85
- ...variants('100pct-bullet-column', '100pct-stacked-column-3x'),
86
- '100pct-stacked-column-2x': '100pct-stacked-column-3x',
87
- '100pct-stacked-area-2x': '100pct-stacked-area-3x',
88
- australia: 'world',
89
- canada: 'world',
90
- 'united-kingdom': 'world',
91
- 'united-states': 'world',
92
- 'box-and-whisker-2x': 'box-and-whisker',
93
- 'box-and-whisker-3x': 'box-and-whisker',
94
- ...variants('bullet-bar', 'bar'),
95
- 'clustered-bar-2x': 'bar',
96
- ...variants('bullet-column', 'column'),
97
- 'clustered-column': 'column',
98
- ...Object.fromEntries(['', '-2x', '-3x', '-4x', '-5x', '-6x'].map((suffix) => [`dot-plot${suffix}`, 'scatter'])),
99
- dumbbell: 'scatter',
100
- 'line-2x': 'line',
101
- 'line-3x': 'line',
102
- 'line-with-high-low': 'line',
103
- ...Object.fromEntries(['', '-2x', '-3x', '-4x', '-5x', '-6x'].map((suffix) => [`sparkline${suffix}`, 'line'])),
104
- 'line-with-high-low-and-markers': 'line-with-markers',
105
- 'line-with-markers-2x': 'line-with-markers',
106
- 'line-with-markers-3x': 'line-with-markers',
107
- 'stacked-area-2x': 'stacked-area-3x',
108
- 'stacked-bar-2x': 'stacked-bar-3x',
109
- 'stacked-column-2x': 'stacked-column-3x',
110
- 'stacked-line-2x': 'stacked-line-3x',
111
- 'stacked-line-with-markers-2x': 'stacked-line-with-markers-3x',
112
- 'treemap-2x': 'treemap',
113
- 'treemap-3x': 'treemap',
114
- });
115
-
116
79
  const ALIASES = Object.freeze({ donut: 'doughnut' });
117
80
 
118
- // Resolve an OPF chart type id to {id, spec}. Kept and deprecated ids resolve
119
- // to a kept id; anything else returns the legacy heuristic with id: null.
81
+ // Resolve an OPF chart type id to {id, spec}. A catalog id resolves to itself;
82
+ // anything else returns the legacy heuristic with id: null.
120
83
  export function resolveChartType(type) {
121
84
  const raw = String(type ?? '').trim().toLowerCase();
122
- const id = ALIASES[raw] ?? DEPRECATED_CHART_TYPES[raw] ?? raw;
85
+ const id = ALIASES[raw] ?? raw;
123
86
  if (Object.hasOwn(CHART_TYPES, id)) return { id, spec: CHART_TYPES[id] };
124
87
  return { id: null, spec: legacyChartType(raw) };
125
88
  }
@@ -149,17 +112,17 @@ export function chartTypeFromNative(element, node) {
149
112
  case 'barChart':
150
113
  case 'bar3DChart': {
151
114
  const direction = attr('barDir') === 'bar' ? 'bar' : 'column';
152
- if (percent) return `100pct-stacked-${direction}-3x`;
153
- if (stacked) return `stacked-${direction}-3x`;
115
+ if (percent) return `100pct-stacked-${direction}`;
116
+ if (stacked) return `stacked-${direction}`;
154
117
  return direction;
155
118
  }
156
119
  case 'lineChart':
157
120
  case 'line3DChart':
158
- if (stacked) return markers ? 'stacked-line-with-markers-3x' : 'stacked-line-3x';
121
+ if (stacked) return markers ? 'stacked-line-with-markers' : 'stacked-line';
159
122
  return markers ? 'line-with-markers' : 'line';
160
123
  case 'areaChart':
161
124
  case 'area3DChart':
162
- return percent ? '100pct-stacked-area-3x' : stacked ? 'stacked-area-3x' : 'area';
125
+ return percent ? '100pct-stacked-area' : stacked ? 'stacked-area' : 'area';
163
126
  case 'pieChart':
164
127
  case 'pie3DChart':
165
128
  case 'ofPieChart':
@@ -179,6 +142,25 @@ export function chartTypeFromNative(element, node) {
179
142
  }
180
143
  }
181
144
 
145
+ /**
146
+ * A combo chart in a parsed c:plotArea: exactly one clustered (or standard) column c:barChart and one or two standard
147
+ * c:lineChart groups, and no other chart group. Returns {bar, lines: [{node, secondary}]} (a line group is secondary when it
148
+ * plots against an axis the column group does not use) or null for any other plot area.
149
+ */
150
+ export function comboFromNative(plotArea) {
151
+ if (!plotArea) return null;
152
+ const bars = asArray(plotArea['c:barChart']);
153
+ const lines = asArray(plotArea['c:lineChart']);
154
+ if (bars.length !== 1 || lines.length < 1 || lines.length > 2) return null;
155
+ if (NATIVE_CHART_ELEMENTS.some((element) => element !== 'barChart' && element !== 'lineChart' && plotArea[`c:${element}`] !== undefined)) return null;
156
+ const [bar] = bars;
157
+ if ((bar['c:barDir']?.val ?? 'col') !== 'col' || !['clustered', 'standard'].includes(bar['c:grouping']?.val ?? 'clustered')) return null;
158
+ if (lines.some((line) => !['standard', undefined].includes(line['c:grouping']?.val))) return null;
159
+ const axes = (node) => asArray(node['c:axId']).map((axis) => String(axis?.val));
160
+ const primary = axes(bar);
161
+ return { bar, lines: lines.map((node) => ({ node, secondary: axes(node).some((id) => !primary.includes(id)) })) };
162
+ }
163
+
182
164
  export const NATIVE_CHART_ELEMENTS = Object.freeze([
183
165
  'barChart', 'bar3DChart', 'lineChart', 'line3DChart', 'pieChart', 'pie3DChart', 'ofPieChart',
184
166
  'doughnutChart', 'areaChart', 'area3DChart', 'scatterChart', 'radarChart',
@@ -191,6 +173,7 @@ function asArray(value) {
191
173
  // PptxGenJS omits or cannot express parts of these constructs. Rewrite only
192
174
  // the generated chart-type element so the part states the exact grouping.
193
175
  export function applyChartConstruct(xml, spec) {
176
+ if (spec?.family === 'combo') return applyComboConstruct(xml);
194
177
  // ScatterWithMarkers: the core catalog records scatterStyle "marker" (markers, no connecting line). PptxGenJS writes lineMarker and relies on the series line being noFill.
195
178
  if (spec?.family === 'xy') return xml.replace(/<c:scatterStyle val="[^"]*"\/>/, '<c:scatterStyle val="marker"/>');
196
179
  if (!spec || spec.family !== 'category') return xml;
@@ -214,3 +197,21 @@ export function applyChartConstruct(xml, spec) {
214
197
  }
215
198
  return xml.slice(0, start) + open + body + xml.slice(end);
216
199
  }
200
+
201
+ // FA-15: PptxGenJS writes each c:lineChart of a multi-type chart without the c:grouping CT_LineChart requires, gives its series
202
+ // the bar-only c:invertIfNegative, and writes c:dLbls before c:marker (CT_LineSer orders marker, dPt, dLbls). Rewrite every line
203
+ // group of a combo chart into schema order; the c:barChart already states barDir and grouping.
204
+ export function applyComboConstruct(xml) {
205
+ return xml.replace(/<c:lineChart>([\s\S]*?)<\/c:lineChart>/g, (_, body) => {
206
+ let next = `<c:grouping val="standard"/>${body.replace(/<c:grouping val="[^"]*"\/>/g, '')}`;
207
+ next = next.replace(/<c:invertIfNegative val="[^"]*"\/>/g, '');
208
+ next = next.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, (ser) => {
209
+ const marker = /<c:marker>[\s\S]*?<\/c:marker>/.exec(ser)?.[0];
210
+ const labels = /<c:dLbls>[\s\S]*?<\/c:dLbls>/.exec(ser);
211
+ if (!marker || !labels || ser.indexOf(marker) < labels.index) return ser;
212
+ const without = ser.replace(marker, '');
213
+ return without.replace(/<c:dLbls>/, `${marker}<c:dLbls>`);
214
+ });
215
+ return `<c:lineChart>${next}</c:lineChart>`;
216
+ });
217
+ }
@@ -19,8 +19,8 @@ function unhex(value) {
19
19
  return JSON.parse(dec.decode(Uint8Array.from(value.match(/../g),byte=>parseInt(byte,16))));
20
20
  }
21
21
 
22
- export function codeManifest(value, layout, group) {
23
- return {v:1, group, role:'panel', value, parts:layout.parts.map(part=>({role:part.role, generated:part.generated === true,
22
+ export function codeManifest(value, layout, group, bands) {
23
+ return {v:1, group, role:'panel', value, ...(bands ? {bands} : {}), parts:layout.parts.map(part=>({role:part.role, generated:part.generated === true,
24
24
  lines:part.fit.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))}))};
25
25
  }
26
26
 
@@ -126,11 +126,16 @@ function sourceFor(value, role, generated) {
126
126
  function validateManifest(manifest) {
127
127
  const {value,parts} = manifest;
128
128
  const object = value && typeof value === 'object' && !Array.isArray(value);
129
- if (typeof value !== 'string' && (!object || typeof value.source !== 'string' || Object.keys(value).some(key=>!['source','filename','language'].includes(key)) || ['filename','language'].some(key=>value[key] !== undefined && typeof value[key] !== 'string'))) throw new Error('Invalid source value.');
129
+ if (typeof value !== 'string' && (!object || typeof value.source !== 'string' || Object.keys(value).some(key=>!['source','filename','language','highlight'].includes(key)) || ['filename','language'].some(key=>value[key] !== undefined && typeof value[key] !== 'string'))) throw new Error('Invalid source value.');
130
+ // FA-13: code.highlight is a list of line numbers and [start, end] pairs from 1.
131
+ const line = entry => Number.isSafeInteger(entry) && entry >= 1;
132
+ if (object && value.highlight !== undefined && (!Array.isArray(value.highlight) || !value.highlight.length || !value.highlight.every(entry => line(entry) || (Array.isArray(entry) && entry.length === 2 && entry.every(line))))) throw new Error('Invalid highlight value.');
130
133
  // Empty metadata is preserved in the manifest but does not produce a label.
131
134
  const roles = [...(object && value.filename ? ['filename'] : []), ...(object && value.language ? ['language'] : []), 'body'];
132
135
  if (roles.length === 1) roles.unshift('language');
133
136
  if (!Array.isArray(parts) || parts.length !== roles.length) throw new Error('Invalid code parts.');
137
+ // FA-13: the number of native highlight bands the export drew behind the lines.
138
+ if (manifest.bands !== undefined && (!Number.isSafeInteger(manifest.bands) || manifest.bands < 1 || manifest.bands > 100000 || !object || value.highlight === undefined)) throw new Error('Invalid highlight bands.');
134
139
  parts.forEach((part,i)=>{
135
140
  const generated = roles.length === 2 && roles[0] === 'language' && !(object && (value.filename || value.language)) && i === 0;
136
141
  if (part.role !== roles[i] || part.generated !== generated || !Array.isArray(part.lines) || !part.lines.length) throw new Error('Invalid source part.');
@@ -180,10 +185,21 @@ export function importCodeGroups(shapes, paragraphs, relationships, entries, rep
180
185
  if (panels.length !== 1 || panels[0].text !== '') throw new Error('Missing, duplicated or edited panel.');
181
186
  const panel = panels[0], manifest = panel.data;
182
187
  validateManifest(manifest);
188
+ // FA-13: highlight bands are decoration. The panel and every line are required; a band may be missing (someone removed it in
189
+ // PowerPoint) without losing the source, and code.highlight itself comes from the manifest value.
183
190
  const expectedCount = 1 + manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
184
- if (group.length !== expectedCount || new Set(group.map(item=>item.shape)).size !== group.length) throw new Error('Incomplete or duplicated code shapes.');
191
+ const bandCount = group.filter(item=>item.data.role === 'highlight').length;
192
+ if (group.length !== expectedCount + bandCount || new Set(group.map(item=>item.shape)).size !== group.length) throw new Error('Incomplete or duplicated code shapes.');
185
193
  const nativeLines = new Map();
194
+ const seenBands = new Set();
186
195
  for (const item of group) if (item !== panel) {
196
+ // FA-13: a highlight band holds no text; it only has to be one of the manifest's bands.
197
+ if (item.data.role === 'highlight') {
198
+ const {index} = item.data;
199
+ if (!Number.isSafeInteger(index) || index < 0 || index >= (manifest.bands ?? 0) || seenBands.has(index) || item.text !== '') throw new Error('Ambiguous highlight band.');
200
+ seenBands.add(index);
201
+ continue;
202
+ }
187
203
  const {role,part,line} = item.data, key = `${part}:${line}`;
188
204
  if (role !== 'line' || !Number.isSafeInteger(part) || !Number.isSafeInteger(line) || !manifest.parts[part]?.lines[line] || nativeLines.has(key)) throw new Error('Ambiguous source line.');
189
205
  nativeLines.set(key,item.text);
package/dist/color-ref.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // Export ColorRef through published core resolveColorRef(). Keep 8-digit hex
2
2
  // alpha locally: normalizeHexColor() strips the AA byte, which PptxGenJS needs.
3
3
 
4
- import { resolveColorRef as resolveCoreColorRef } from "@openpresentation/opf";
4
+ import { resolveColorRef as resolveCoreColorRef } from "@openpresentation/opf/composition";
5
5
 
6
6
  const UNRESOLVED = "";
7
7
 
@@ -38,14 +38,19 @@ export function resolveVariableColors(variables) {
38
38
  return isPlainObject(variables) ? variables : {};
39
39
  }
40
40
 
41
+ // The color roles a ColorRef names. An export context carries them resolved (core resolveColorRoles); a bare
42
+ // context with only `colors` maps those fields (the exporter's colors.accent is the primary color).
41
43
  function coreRoles(ctx) {
42
44
  const colors = ctx.colors ?? {};
45
+ const roles = ctx.roles;
43
46
  return {
44
- background: withHash(colors.background),
45
- surface: withHash(colors.surface),
46
- text: withHash(colors.text),
47
- textSecondary: withHash(colors.mutedText),
48
- accent: withHash(colors.accent),
47
+ primary: withHash(roles?.primary),
48
+ secondary: withHash(roles?.secondary),
49
+ accent: withHash(roles ? roles.accent : colors.accent),
50
+ background: withHash(roles?.background ?? colors.background),
51
+ surface: withHash(roles?.surface ?? colors.surface),
52
+ text: withHash(roles?.text ?? colors.text),
53
+ textSecondary: withHash(roles?.textSecondary ?? colors.mutedText),
49
54
  };
50
55
  }
51
56
 
@@ -76,6 +81,7 @@ export function colorContext(context, fallback) {
76
81
  return {
77
82
  colorScheme: context.colorScheme,
78
83
  colors: context.colors,
84
+ roles: context.roles,
79
85
  variables: context.variables ?? {},
80
86
  fallback: fallback ?? context.colors?.text ?? "000000",
81
87
  };