@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.
- package/DEPENDENCY-NOTES.md +1 -1
- package/README.md +34 -16
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background.js +2 -3
- package/dist/body-text-import.js +16 -4
- package/dist/chart-alt.js +54 -0
- package/dist/chart-data.js +29 -54
- package/dist/chart-highlight.js +113 -0
- package/dist/chart-options.js +46 -24
- package/dist/chart-types.js +59 -58
- package/dist/code-provenance.js +20 -4
- package/dist/color-ref.js +12 -6
- package/dist/data-provenance.js +18 -14
- package/dist/document-provenance.js +20 -8
- package/dist/format-check.js +10 -0
- package/dist/furniture-provenance.js +30 -3
- package/dist/heading-provenance.js +10 -3
- package/dist/image-fallback-node.js +4 -1
- package/dist/index.d.ts +32 -25
- package/dist/index.js +481 -235
- package/dist/media-provenance.js +3 -3
- package/dist/metric-provenance.js +3 -3
- package/dist/native-text-style.js +2 -0
- package/dist/package-fonts.js +2 -2
- package/dist/quote-provenance.js +70 -26
- package/dist/rich-heading.js +119 -0
- package/dist/run-colors.js +3 -0
- package/dist/script-fonts.js +81 -54
- package/dist/theme-colors.js +1 -1
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +7 -7
- package/dist/watermark-provenance.js +65 -3
- package/package.json +5 -5
|
@@ -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
|
+
}
|
package/dist/chart-options.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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 (
|
|
21
|
-
return
|
|
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
|
-
*
|
|
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
|
-
|
|
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 =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 = {};
|
package/dist/chart-types.js
CHANGED
|
@@ -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)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
15
|
-
'100pct-stacked-column
|
|
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
|
|
18
|
-
'100pct-stacked-bar
|
|
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
|
|
22
|
-
'stacked-line-with-markers
|
|
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
|
|
25
|
-
'100pct-stacked-area
|
|
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}.
|
|
119
|
-
//
|
|
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] ??
|
|
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}
|
|
153
|
-
if (stacked) return `stacked-${direction}
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/code-provenance.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
};
|