@openpresentation/opf-pptx 0.13.2 → 0.15.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.
@@ -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. Every id maps to the exact Office construct (OPF 0.15: chart.type is a
6
+ // schema enum, so there is no other id).
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,65 +76,12 @@ 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
- const ALIASES = Object.freeze({ donut: 'doughnut' });
117
-
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.
79
+ // Resolve an OPF chart type id to {id, spec}. `chart.type` is a schema enum in OPF 0.15 (core CHART_TYPES), so every
80
+ // validated document names one of these; an id outside the table (a caller that skipped validation) resolves to
81
+ // {id: null, spec: undefined}.
120
82
  export function resolveChartType(type) {
121
- const raw = String(type ?? '').trim().toLowerCase();
122
- const id = ALIASES[raw] ?? DEPRECATED_CHART_TYPES[raw] ?? raw;
123
- if (Object.hasOwn(CHART_TYPES, id)) return { id, spec: CHART_TYPES[id] };
124
- return { id: null, spec: legacyChartType(raw) };
125
- }
126
-
127
- // The pre-FF-22 heuristic for ids outside the core catalog.
128
- function legacyChartType(normalized) {
129
- if (normalized.includes('pie')) return CHART_TYPES.pie;
130
- if (normalized.includes('doughnut') || normalized.includes('donut')) return CHART_TYPES.doughnut;
131
- if (normalized.includes('area')) return { ...CHART_TYPES.area, legacy: true };
132
- if (normalized.includes('line')) return { ...CHART_TYPES['line-with-markers'], legacy: true };
133
- if (normalized.includes('scatter')) return CHART_TYPES.scatter;
134
- if (normalized.includes('radar')) return { ...CHART_TYPES.radar, legacy: true };
135
- const grouping = normalized.includes('stacked') ? 'stacked' : 'clustered';
136
- return category('bar', { barDir: normalized.includes('bar') ? 'bar' : 'col', grouping, legacy: true });
83
+ const id = String(type ?? '').trim();
84
+ return Object.hasOwn(CHART_TYPES, id) ? { id, spec: CHART_TYPES[id] } : { id: null, spec: undefined };
137
85
  }
138
86
 
139
87
  // Native construct -> kept OPF id, for import. `node` is the parsed chart-type
@@ -149,17 +97,17 @@ export function chartTypeFromNative(element, node) {
149
97
  case 'barChart':
150
98
  case 'bar3DChart': {
151
99
  const direction = attr('barDir') === 'bar' ? 'bar' : 'column';
152
- if (percent) return `100pct-stacked-${direction}-3x`;
153
- if (stacked) return `stacked-${direction}-3x`;
100
+ if (percent) return `100pct-stacked-${direction}`;
101
+ if (stacked) return `stacked-${direction}`;
154
102
  return direction;
155
103
  }
156
104
  case 'lineChart':
157
105
  case 'line3DChart':
158
- if (stacked) return markers ? 'stacked-line-with-markers-3x' : 'stacked-line-3x';
106
+ if (stacked) return markers ? 'stacked-line-with-markers' : 'stacked-line';
159
107
  return markers ? 'line-with-markers' : 'line';
160
108
  case 'areaChart':
161
109
  case 'area3DChart':
162
- return percent ? '100pct-stacked-area-3x' : stacked ? 'stacked-area-3x' : 'area';
110
+ return percent ? '100pct-stacked-area' : stacked ? 'stacked-area' : 'area';
163
111
  case 'pieChart':
164
112
  case 'pie3DChart':
165
113
  case 'ofPieChart':
@@ -179,6 +127,25 @@ export function chartTypeFromNative(element, node) {
179
127
  }
180
128
  }
181
129
 
130
+ /**
131
+ * A combo chart in a parsed c:plotArea: exactly one clustered (or standard) column c:barChart and one or two standard
132
+ * c:lineChart groups, and no other chart group. Returns {bar, lines: [{node, secondary}]} (a line group is secondary when it
133
+ * plots against an axis the column group does not use) or null for any other plot area.
134
+ */
135
+ export function comboFromNative(plotArea) {
136
+ if (!plotArea) return null;
137
+ const bars = asArray(plotArea['c:barChart']);
138
+ const lines = asArray(plotArea['c:lineChart']);
139
+ if (bars.length !== 1 || lines.length < 1 || lines.length > 2) return null;
140
+ if (NATIVE_CHART_ELEMENTS.some((element) => element !== 'barChart' && element !== 'lineChart' && plotArea[`c:${element}`] !== undefined)) return null;
141
+ const [bar] = bars;
142
+ if ((bar['c:barDir']?.val ?? 'col') !== 'col' || !['clustered', 'standard'].includes(bar['c:grouping']?.val ?? 'clustered')) return null;
143
+ if (lines.some((line) => !['standard', undefined].includes(line['c:grouping']?.val))) return null;
144
+ const axes = (node) => asArray(node['c:axId']).map((axis) => String(axis?.val));
145
+ const primary = axes(bar);
146
+ return { bar, lines: lines.map((node) => ({ node, secondary: axes(node).some((id) => !primary.includes(id)) })) };
147
+ }
148
+
182
149
  export const NATIVE_CHART_ELEMENTS = Object.freeze([
183
150
  'barChart', 'bar3DChart', 'lineChart', 'line3DChart', 'pieChart', 'pie3DChart', 'ofPieChart',
184
151
  'doughnutChart', 'areaChart', 'area3DChart', 'scatterChart', 'radarChart',
@@ -191,6 +158,7 @@ function asArray(value) {
191
158
  // PptxGenJS omits or cannot express parts of these constructs. Rewrite only
192
159
  // the generated chart-type element so the part states the exact grouping.
193
160
  export function applyChartConstruct(xml, spec) {
161
+ if (spec?.family === 'combo') return applyComboConstruct(xml);
194
162
  // ScatterWithMarkers: the core catalog records scatterStyle "marker" (markers, no connecting line). PptxGenJS writes lineMarker and relies on the series line being noFill.
195
163
  if (spec?.family === 'xy') return xml.replace(/<c:scatterStyle val="[^"]*"\/>/, '<c:scatterStyle val="marker"/>');
196
164
  if (!spec || spec.family !== 'category') return xml;
@@ -214,3 +182,21 @@ export function applyChartConstruct(xml, spec) {
214
182
  }
215
183
  return xml.slice(0, start) + open + body + xml.slice(end);
216
184
  }
185
+
186
+ // FA-15: PptxGenJS writes each c:lineChart of a multi-type chart without the c:grouping CT_LineChart requires, gives its series
187
+ // the bar-only c:invertIfNegative, and writes c:dLbls before c:marker (CT_LineSer orders marker, dPt, dLbls). Rewrite every line
188
+ // group of a combo chart into schema order; the c:barChart already states barDir and grouping.
189
+ export function applyComboConstruct(xml) {
190
+ return xml.replace(/<c:lineChart>([\s\S]*?)<\/c:lineChart>/g, (_, body) => {
191
+ let next = `<c:grouping val="standard"/>${body.replace(/<c:grouping val="[^"]*"\/>/g, '')}`;
192
+ next = next.replace(/<c:invertIfNegative val="[^"]*"\/>/g, '');
193
+ next = next.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, (ser) => {
194
+ const marker = /<c:marker>[\s\S]*?<\/c:marker>/.exec(ser)?.[0];
195
+ const labels = /<c:dLbls>[\s\S]*?<\/c:dLbls>/.exec(ser);
196
+ if (!marker || !labels || ser.indexOf(marker) < labels.index) return ser;
197
+ const without = ser.replace(marker, '');
198
+ return without.replace(/<c:dLbls>/, `${marker}<c:dLbls>`);
199
+ });
200
+ return `<c:lineChart>${next}</c:lineChart>`;
201
+ });
202
+ }
@@ -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
  };
@@ -1,7 +1,7 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
- import {validatePresentation} from '@openpresentation/opf';
2
+ import {checkFormat, isValidFormat} from './format-check.js';
3
3
  import {decodeTextTag, encodeTextTag} from './code-provenance.js';
4
- import {chartUsesDataFields, isDatasetRef, numberFormatFromExcel, resolveTableData, tableCellDisplayValue, tableUsesDataFields} from './chart-data.js';
4
+ import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableData, tableCellDisplayValue, tableUsesDataFields} from './chart-data.js';
5
5
 
6
6
  // RR-54: chart and table data survive a PPTX round trip (core docs/chart-table-data.md).
7
7
  //
@@ -13,11 +13,13 @@ import {chartUsesDataFields, isDatasetRef, numberFormatFromExcel, resolveTableDa
13
13
  //
14
14
  // OPF_DATASETS_V1 on p:presentation (beside OPF_DOCUMENT_V1): the whole `datasets` map, unused datasets included.
15
15
  // OPF_DATA_V1 on each chart or table graphic frame that uses one of the fields above:
16
- // chart {v: 1, kind: 'chart', data, mapping?, dataset?: hash, evidence: [hash]} `data` is the authored
16
+ // chart {v: 1, kind: 'chart', data, mapping?, highlight?, dataset?: hash, evidence: [hash]} `data` is the authored
17
17
  // chart.data (a dataset reference, or inline columns with DataColumn objects and `source`); `evidence`
18
18
  // hashes the native caches (series names, categories, values and format codes) of the chart part(s) the frame
19
19
  // shows (the chartex part and its classic fallback); `dataset` hashes the referenced dataset, so a slide
20
- // pasted into a deck whose dataset of the same id holds other data keeps its values inline.
20
+ // pasted into a deck whose dataset of the same id holds other data keeps its values inline. `highlight` (FA-14) is
21
+ // the authored chart.highlight: the native chart carries only the accent and muted colours it produced, which no
22
+ // exact inference turns back into series and category names, and a chart with only a highlight records its inline `data`.
21
23
  // table {v: 1, kind: 'table', table} a dataset-backed table, as authored
22
24
  // {v: 1, kind: 'table', headers: [[column, header, text]], cells: [[row, column, value, format, text]]}
23
25
  // an inline table: the DataColumn or formatted
@@ -79,13 +81,13 @@ const canonicalValue = text => NUMBER.test(text.trim()) && Number.isFinite(Numbe
79
81
  // A General format code is left out: on a plain save PowerPoint writes <c:formatCode>General</c:formatCode> into every
80
82
  // cache that has none (PptxGenJS writes none on pie and doughnut series), so General and no code are the same evidence.
81
83
  const generalCode = code => /^\s*general\s*$/i.test(code);
82
- // A format code is hashed in core's canonical form (numberFormatFromExcel), because a plain PowerPoint save re-spells codes
84
+ // A format code is hashed in core's canonical form (fromExcelNumberFormat), because a plain PowerPoint save re-spells codes
83
85
  // without changing what they show: `$#,##0.0` becomes `\$#,##0.0` and `#,##0 "units"` becomes `#,##0\ "units"` (native
84
86
  // check opf#385). Core reads quoted and backslash-escaped literals and literal-safe characters to the same NumberFormat, so
85
87
  // both spellings hash equal, while a real change (another number of decimals, another unit) still changes the hash. A code
86
88
  // core cannot map is hashed as written; the `opf:` prefix keeps a mapped form apart from a verbatim code of the same text.
87
89
  const formatEvidence = code => {
88
- const format = numberFormatFromExcel(code);
90
+ const format = fromExcelNumberFormat(code);
89
91
  return typeof format === 'string' ? `format:opf:${format}` : `format:${code}`;
90
92
  };
91
93
  function hash(text) {
@@ -119,9 +121,9 @@ const datasetHash = (datasets, id) => own(datasets, id) ? hash(canonical(dataset
119
121
 
120
122
  /** The record of a chart that uses an RR-54 data field, or undefined (the frame then carries no tag). */
121
123
  export function chartDataRecord(chart, datasets) {
122
- if (!chartUsesDataFields(chart)) return undefined;
124
+ if (!chartUsesDataFields(chart) && !object(chart?.highlight)) return undefined;
123
125
  const dataset = isDatasetRef(chart.data) ? datasetHash(datasets, chart.data.dataset) : undefined;
124
- return {v: 1, kind: 'chart', data: clone(chart.data), ...(chart.mapping !== undefined ? {mapping: clone(chart.mapping)} : {}), ...(dataset ? {dataset} : {})};
126
+ return {v: 1, kind: 'chart', data: clone(chart.data), ...(chart.mapping !== undefined ? {mapping: clone(chart.mapping)} : {}), ...(object(chart.highlight) ? {highlight: clone(chart.highlight)} : {}), ...(dataset ? {dataset} : {})};
125
127
  }
126
128
 
127
129
  /** The record of a table that is dataset-backed or formats numbers, or undefined. `layout` is the exported table layout. */
@@ -281,8 +283,9 @@ export function readDatasetsTag(entries, presentationRoot, relationships, report
281
283
  const datasets = readTags(entries, presentationRoot?.['p:custDataLst'], relationships, DATASETS_TAG);
282
284
  if (datasets === undefined) return undefined;
283
285
  if (!object(datasets) || !Object.keys(datasets).length) throw Error('Invalid datasets record.');
284
- const result = validatePresentation({slides: [{title: 'Datasets'}], datasets});
285
- if (!result.valid) throw Error(`The stored datasets do not validate (${result.errors[0]?.path ?? ''} ${result.errors[0]?.message ?? ''}).`);
286
+ const report = checkFormat({slides: [{title: 'Datasets'}], datasets});
287
+ const [first] = report.findings.filter(finding => finding.severity === 'error');
288
+ if (!report.valid) throw Error(`The stored datasets do not validate (${first?.path ?? ''} ${first?.message ?? ''}).`);
286
289
  return datasets;
287
290
  } catch (error) {
288
291
  report?.({code: 'invalid-data-provenance', path: 'datasets', message: `${error instanceof Error ? error.message : String(error)} The datasets recorded at export were not restored.`});
@@ -297,6 +300,7 @@ export function readDataTag(entries, frame, relationships) {
297
300
  if (!object(record) || record.v !== 1 || !['chart', 'table'].includes(record.kind)) throw Error('Unsupported data provenance version.');
298
301
  if (record.kind === 'chart' && (!object(record.data) || !Array.isArray(record.evidence) || !record.evidence.every(item => typeof item === 'string'))) throw Error('Invalid chart data record.');
299
302
  if (record.kind === 'chart' && record.dataset !== undefined && typeof record.dataset !== 'string') throw Error('Invalid chart data record.');
303
+ if (record.kind === 'chart' && record.highlight !== undefined && !object(record.highlight)) throw Error('Invalid chart data record.');
300
304
  if (record.kind === 'table' && !(object(record.table) || (Array.isArray(record.headers) && Array.isArray(record.cells)))) throw Error('Invalid table data record.');
301
305
  // Inline table entries are [column, header, text] and [row, column, value, format, text]; their indices are array
302
306
  // indices (non-negative integers), never a key such as "__proto__" or "length".
@@ -309,7 +313,7 @@ export function readDataTag(entries, frame, relationships) {
309
313
  return record;
310
314
  }
311
315
 
312
- const validWith = (payload, datasets) => validatePresentation({slides: [{title: 'Data', ...payload}], ...(datasets ? {datasets} : {})}).valid;
316
+ const validWith = (payload, datasets) => isValidFormat({slides: [{title: 'Data', ...payload}], ...(datasets ? {datasets} : {})});
313
317
 
314
318
  /**
315
319
  * The authored chart data while the native caches are unchanged: the imported chart (type and options) with the recorded
@@ -317,7 +321,7 @@ const validWith = (payload, datasets) => validatePresentation({slides: [{title:
317
321
  */
318
322
  export function restoreChartData(chart, record, evidence, {datasets, report}) {
319
323
  if (!record.evidence.includes(evidence)) {
320
- report({code: 'chart-data-provenance-changed', message: 'The chart\'s cached data or number formats changed after export (for example in Edit Data), so the dataset reference, column mapping, column formats and data source recorded at export were not restored; the chart imports the values and formats it now shows.'});
324
+ report({code: 'chart-data-provenance-changed', message: 'The chart\'s cached data or number formats changed after export (for example in Edit Data), so the dataset reference, column mapping, column formats and data source recorded at export were not restored; the chart imports the values and formats it now shows.' + (record.highlight ? ' The chart highlight (series and categories named at export) was not restored either: its names refer to the data as exported.' : '')});
321
325
  return chart;
322
326
  }
323
327
  if (isDatasetRef(record.data) && !own(datasets, record.data.dataset)) {
@@ -328,8 +332,8 @@ export function restoreChartData(chart, record, evidence, {datasets, report}) {
328
332
  report({code: 'chart-dataset-unavailable', message: `The chart referenced dataset '${record.data.dataset}', but this package's dataset of that id holds other data than at export (a slide pasted from another deck); the chart imports its values inline.`});
329
333
  return chart;
330
334
  }
331
- const {mapping: _mapping, ...rest} = chart;
332
- const restored = {...rest, data: clone(record.data), ...(record.mapping !== undefined ? {mapping: clone(record.mapping)} : {})};
335
+ const {mapping: _mapping, highlight: _highlight, ...rest} = chart;
336
+ const restored = {...rest, data: clone(record.data), ...(record.mapping !== undefined ? {mapping: clone(record.mapping)} : {}), ...(record.highlight !== undefined ? {highlight: clone(record.highlight)} : {})};
333
337
  if (!validWith({chart: restored}, isDatasetRef(record.data) ? datasets : undefined)) {
334
338
  report({code: 'invalid-data-provenance', message: 'The chart data recorded at export does not form a valid chart; the chart imports the values it shows.'});
335
339
  return chart;