@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.
@@ -57,8 +57,6 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
57
57
  }
58
58
  if (background.type !== 'gradient') return null;
59
59
  const stops = background.gradient?.stops ?? [];
60
- if (!stops.length) return '<a:noFill/>';
61
- if (stops.length === 1) return `<a:solidFill>${paint(stops[0].color, fallback)}</a:solidFill>`;
62
60
  const radians = turn(background.gradient?.angle ?? 0) * Math.PI / 180;
63
61
  const c = Math.cos(radians), s = Math.sin(radians), span = Math.abs(c) + Math.abs(s);
64
62
  const angle = Math.round(turn(Math.atan2(s / height, c / width) * 180 / Math.PI) * 60000) % 21600000;
@@ -73,39 +71,42 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
73
71
  }
74
72
 
75
73
  const rectXml = (name, rect = {}) => `<a:${name}${['l', 't', 'r', 'b'].filter(key => rect[key]).map(key => ` ${key}="${rect[key]}"`).join('')}/>`;
76
- const percent = value => Math.round(value * 100000);
74
+ const percent = value => Math.round(value * 100000) || 0;
77
75
 
78
- // Picture fill for an OPF image background, following the SVG preview:
79
- // cover crops the centered source to the slide aspect, contain letterboxes it
80
- // with fill-rectangle insets, and tile repeats square cells of min(w,h)/4
81
- // from the top-left corner. Each cell holds the image by the deck imageFill
82
- // (crop = cover, otherwise contain; negative source insets are transparent
83
- // padding). With dpi="0", DrawingML sizes a tile from the raster's own
84
- // resolution (PNG pHYs, JPEG JFIF or EXIF; 96 dpi when absent), while the
85
- // preview draws CSS pixels, so the tile scale compensates on each axis.
76
+ // FA-23: the picture fill of an OPF image background (core SlideComposition.backgroundImage), drawn like the SVG
77
+ // preview. `fitImage` is core's shared fit math (composition `fitImage`), so every engine crops, letterboxes and
78
+ // focuses the same way:
79
+ // - stretch fills the slide (no crop);
80
+ // - cover crops the picture to the slide with the focus point kept in view (a:srcRect, positive insets);
81
+ // - contain centers the whole picture; the fill rectangle insets leave the rest of the slide unfilled;
82
+ // - tile repeats the picture at its intrinsic size (1 picture px = 1 reference px, 1/96 in) from the top-left corner.
83
+ // With dpi="0", DrawingML sizes a tile from the raster's own resolution (PNG pHYs, JPEG JFIF or EXIF; 96 dpi when
84
+ // absent), so the tile scale is the raster's dpi / 96 on each axis.
85
+ // A recolor (core backgroundImage.recolor, resolved: {type: 'grayscale'} or {type: 'duotone', dark: {hex}, light: {hex}})
86
+ // is a:grayscl or a:duotone on the blip, then opacity is a:alphaModFix (picture pixels only), as for image blocks.
86
87
  export const nativeTileAlignment = {tx: '0', ty: '0', flip: 'none', algn: 'tl'};
87
- export function nativeTileScale(image, {width, height, imageFill = 'fit'}) {
88
- const cell = Math.min(width, height) / 4;
89
- const scale = (imageFill === 'crop' ? Math.max : Math.min)(cell / image.width, cell / image.height);
90
- return {cell, scale, sx: percent(scale * (image.dpiX ?? 96) / 96), sy: percent(scale * (image.dpiY ?? 96) / 96)};
88
+ export function nativeTileScale(image) {
89
+ return {sx: percent((image.dpiX ?? 96) / 96), sy: percent((image.dpiY ?? 96) / 96)};
91
90
  }
92
- export function nativeImageBackgroundFill(relationshipId, image, {fit = 'cover', opacity = 1, width, height, imageFill = 'fit'}) {
91
+ export function nativeImageBackgroundFill(relationshipId, image, {fit = 'cover', focus, opacity = 1, recolor, width, height, fitImage}) {
93
92
  const alpha = clamp(opacity);
94
- const blip = `<a:blip r:embed="${relationshipId}">${alpha === 1 ? '' : `<a:alphaModFix amt="${percent(alpha)}"/>`}</a:blip>`;
93
+ const recolorXml = recolor?.type === 'grayscale' ? '<a:grayscl/>'
94
+ : recolor?.type === 'duotone' ? `<a:duotone><a:srgbClr val="${recolor.dark.hex}"></a:srgbClr><a:srgbClr val="${recolor.light.hex}"></a:srgbClr></a:duotone>` : '';
95
+ const blip = `<a:blip r:embed="${relationshipId}">${recolorXml}${alpha === 1 ? '' : `<a:alphaModFix amt="${percent(alpha)}"/>`}</a:blip>`;
95
96
  if (fit === 'tile') {
96
- const {cell, scale, sx, sy} = nativeTileScale(image, {width, height, imageFill});
97
- const x = percent((1 - cell / (image.width * scale)) / 2), y = percent((1 - cell / (image.height * scale)) / 2);
98
- const {tx, ty, flip, algn} = nativeTileAlignment;
99
- return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: x, t: y, r: x, b: y})}<a:tile tx="${tx}" ty="${ty}" sx="${sx}" sy="${sy}" flip="${flip}" algn="${algn}"/></a:blipFill>`;
97
+ const {sx, sy} = nativeTileScale(image), {tx, ty, flip, algn} = nativeTileAlignment;
98
+ return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:tile tx="${tx}" ty="${ty}" sx="${sx}" sy="${sy}" flip="${flip}" algn="${algn}"/></a:blipFill>`;
100
99
  }
100
+ if (fit === 'stretch') return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`;
101
+ const frame = {x: 0, y: 0, width, height};
102
+ const placed = fitImage(frame, fit, image.width / image.height, focus);
101
103
  if (fit === 'contain') {
102
- const scale = Math.min(width / image.width, height / image.height);
103
- const x = percent((1 - image.width * scale / width) / 2), y = percent((1 - image.height * scale / height) / 2);
104
- return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:srcRect/><a:stretch>${rectXml('fillRect', {l: x, t: y, r: x, b: y})}</a:stretch></a:blipFill>`;
104
+ const box = placed.image;
105
+ const inset = {l: percent(box.x / width), t: percent(box.y / height), r: percent((width - box.x - box.width) / width), b: percent((height - box.y - box.height) / height)};
106
+ return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:srcRect/><a:stretch>${rectXml('fillRect', inset)}</a:stretch></a:blipFill>`;
105
107
  }
106
- const scale = Math.max(width / image.width, height / image.height);
107
- const x = percent((1 - width / (image.width * scale)) / 2), y = percent((1 - height / (image.height * scale)) / 2);
108
- return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: x, t: y, r: x, b: y})}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`;
108
+ const {left, top, right, bottom} = placed.crop;
109
+ return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: percent(left), t: percent(top), r: percent(right), b: percent(bottom)})}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`;
109
110
  }
110
111
 
111
112
  // Internal metadata supplied by the ordered XML reader. A Symbol cannot collide
@@ -215,7 +216,8 @@ export function readNativeBackground(properties, {width, height}, report = () =>
215
216
  if (!c || !Number.isFinite(position) || position < -.00002 || position > 1.00002) return null;
216
217
  return {...c, position: clamp(position)};
217
218
  });
218
- if (!stops.length || stops.some(stop => !stop)) return unsupported();
219
+ // The schema needs at least two stops (DrawingML does too).
220
+ if (stops.length < 2 || stops.some(stop => !stop)) return unsupported();
219
221
  const alpha = stops[0].alpha, uniform = stops.every(stop => stop.alpha === alpha);
220
222
  return {type: 'gradient', gradient: {angle, stops: stops.map(stop => ({position: stop.position,
221
223
  color: stop.hex + (!uniform && stop.alpha !== 1 ? Math.round(stop.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '')}))},
@@ -17,7 +17,10 @@ export const joinNativeParagraphs = paragraphs => value(paragraphs.flatMap((para
17
17
  ...(index ? ['\n'] : []), ...(Array.isArray(paragraph.richText) ? paragraph.richText : [paragraph.richText ?? paragraph.text])
18
18
  ]));
19
19
 
20
- function bodyRunStyle(properties, context, relationships, report) {
20
+ // FA-13: a run language that differs from the deck language comes back as TextRun.lang; a run in the design's code family
21
+ // comes back as an inline code run. `extras` is {deckLang, codeFamily} (either may be absent).
22
+ const RUN_LANG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
23
+ function bodyRunStyle(properties, context, relationships, report, extras = {}) {
21
24
  const supported = {...properties};
22
25
  for (const key of ['b', 'i']) if (properties[key] !== undefined && !['1','0','true','false','on','off'].includes(properties[key])) {
23
26
  report('unsupported-body-text-style', `The native ${key} value is not a supported boolean; current text was retained.`);
@@ -52,10 +55,19 @@ function bodyRunStyle(properties, context, relationships, report) {
52
55
  (properties.cap !== undefined && properties.cap !== 'none') || (properties.spc !== undefined && Number(properties.spc) !== 0)) {
53
56
  report('unsupported-body-text-style', 'Native text effects, capitalization, spacing or underline decoration are not represented by OPF run properties; current text was retained.');
54
57
  }
55
- return nativeRunStyle(supported, context, relationships, report, 'body');
58
+ const style = nativeRunStyle(supported, context, relationships, report, 'body');
59
+ const lang = properties.lang;
60
+ if (extras.deckLang && typeof lang === 'string' && RUN_LANG.test(lang) && !/^(?:und|zxx)(?:-|$)/i.test(lang) && lang.toLowerCase() !== extras.deckLang.toLowerCase()) style.lang = lang;
61
+ const family = typeof style.fontFamily === 'string' ? style.fontFamily.trim().toLowerCase() : undefined;
62
+ if (family && extras.codeFamily && family === extras.codeFamily.trim().toLowerCase()
63
+ && !['a:majorFont', 'a:minorFont'].some(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase() === family)) {
64
+ style.code = true;
65
+ delete style.fontFamily;
66
+ }
67
+ return style;
56
68
  }
57
69
 
58
- export function nativeBodyReader(slidePath, archive, relationships, report) {
70
+ export function nativeBodyReader(slidePath, archive, relationships, report, extras = {}) {
59
71
  let bodies, context;
60
72
  return index => {
61
73
  if (!bodies) {
@@ -80,7 +92,7 @@ export function nativeBodyReader(slidePath, archive, relationships, report) {
80
92
  const properties = mergeNativeRunProperties(defaults, drawingObject(node[tag])['a:rPr']);
81
93
  // RR-05: a Latin phrase of a right-to-left paragraph is its own en-US run; its East Asian/complex-script slots can render nothing in it, so the face loss is not reported.
82
94
  const phrase = nodes(content, 'a:pPr')[0]?.[':@']?.rtl === '1' && properties.lang === 'en-US' && LATIN_ONLY.test(current);
83
- const style = bodyRunStyle(properties, context, relationships, (code, message) => { if (!(phrase && code === 'unsupported-body-font')) report({code,message,path}); });
95
+ const style = bodyRunStyle(properties, context, relationships, (code, message) => { if (!(phrase && code === 'unsupported-body-font')) report({code,message,path}); }, phrase ? {...extras, deckLang: undefined} : extras);
84
96
  if (current) runs.push(Object.keys(style).length ? {text:current,...style} : current);
85
97
  }
86
98
  return {text:runs.map(run => typeof run === 'string' ? run : run.text).join(''), richText:value(runs)};
@@ -0,0 +1,54 @@
1
+ // FA-09: the text alternative of a chart (`Chart.alt`) is the chart frame's `p:nvGraphicFramePr/p:cNvPr/@descr`, the attribute a
2
+ // picture's alt text uses. An empty `alt` marks the chart decorative, which PowerPoint stores as the `adec:decorative` extension of
3
+ // the same `p:cNvPr` ("Mark as decorative"), not as an empty `descr`: PowerPoint writes `descr=""` for any shape without alt text.
4
+ const DECORATIVE_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}';
5
+ const DECORATIVE_NS = 'http://schemas.microsoft.com/office/drawing/2017/decorative';
6
+ const ESCAPES = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;', '\r': '&#13;', '\n': '&#10;', '\t': '&#9;'};
7
+ const escape = value => value.replace(/[&<>"'\r\n\t]/g, char => ESCAPES[char]);
8
+ const decorativeExt = `<a:ext uri="${DECORATIVE_URI}"><adec:decorative xmlns:adec="${DECORATIVE_NS}" val="1"/></a:ext>`;
9
+ const decorativeExtPattern = new RegExp(`<a:ext\\b[^>]*\\buri="${DECORATIVE_URI.replace(/[{}]/g, '\\$&')}"[^>]*>[\\s\\S]*?</a:ext>`, 'g');
10
+
11
+ // The one a:extLst of a p:cNvPr is edited in place: PowerPoint's own extensions (a16:creationId and others) stay untouched and in
12
+ // order, an earlier decorative ext is replaced and never duplicated, and an extLst left with no ext is removed.
13
+ function withDecorative(body, decorative) {
14
+ const list = body.match(/<a:extLst(?:\s[^>]*)?(?:\/>|>[\s\S]*?<\/a:extLst>)/);
15
+ const inner = list ? list[0].replace(/^<a:extLst(?:\s[^>]*)?>|<\/a:extLst>$|^<a:extLst(?:\s[^>]*)?\/>$/g, '').replace(decorativeExtPattern, '') : '';
16
+ const exts = decorative ? inner + decorativeExt : inner;
17
+ const next = exts.trim() === '' ? '' : `<a:extLst>${exts}</a:extLst>`;
18
+ return list ? body.slice(0, list.index) + next + body.slice(list.index + list[0].length) : body + next;
19
+ }
20
+
21
+ /** Write `alt` on the first `p:cNvPr` of a graphic frame: `descr` for text, the decorative extension for the empty string. */
22
+ export function writeFrameAlt(frame, alt) {
23
+ const start = frame.match(/<p:cNvPr\b[^>]*?(\/?)>/);
24
+ if (!start) return frame;
25
+ const selfClosing = start[1] === '/';
26
+ const end = selfClosing ? start.index + start[0].length : frame.indexOf('</p:cNvPr>', start.index);
27
+ if (end < 0) return frame;
28
+ let tag = start[0].replace(/\sdescr="[^"]*"/, '');
29
+ const body = withDecorative(selfClosing ? '' : frame.slice(start.index + start[0].length, end), alt === '');
30
+ if (alt !== '') tag = tag.replace(/\s*\/?>$/, match => ` descr="${escape(alt)}"${match.trim()}`);
31
+ const tail = selfClosing ? start.index + start[0].length : end + '</p:cNvPr>'.length;
32
+ const open = selfClosing && body ? tag.replace(/\s*\/>$/, '>') : tag;
33
+ const close = !selfClosing || body ? '</p:cNvPr>' : '';
34
+ return `${frame.slice(0, start.index)}${open}${body}${close}${frame.slice(tail)}`;
35
+ }
36
+
37
+ /** Apply every recorded chart alt to the frames (classic and chartex fallback) of a slide part's XML, matched by frame name. */
38
+ export function applyChartAlt(xml, alts) {
39
+ return xml.replace(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g, frame => {
40
+ const name = frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
41
+ return alts.has(name) ? writeFrameAlt(frame, alts.get(name)) : frame;
42
+ });
43
+ }
44
+
45
+ const list = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
46
+
47
+ /** The alt of a parsed `p:cNvPr`: its `descr`, `""` when marked decorative, or undefined. */
48
+ export function readFrameAlt(cNvPr) {
49
+ if (!cNvPr || typeof cNvPr !== 'object') return undefined;
50
+ const descr = cNvPr.descr;
51
+ if (typeof descr === 'string' && descr.trim() !== '') return descr;
52
+ const decorative = list(list(cNvPr['a:extLst'])[0]?.['a:ext']).some(ext => list(ext?.['adec:decorative']).some(node => node?.val === '1' || node?.val === 'true'));
53
+ return decorative ? '' : undefined;
54
+ }
@@ -6,59 +6,25 @@
6
6
  // c:numFmt and the embedded workbook cell formats (src/chart-workbook.js). A chart without number formats is written
7
7
  // exactly as before.
8
8
  //
9
- // Optional core exports are read from the namespace, so a published core before RR-54 still loads: its schema rejects
10
- // the new fields, so the fallbacks only need the inline positional form, with the same strict number rule.
11
- import * as opfCore from '@openpresentation/opf';
9
+ // The core functions are re-exported from one place so the exporter and importer read the same ones: core 0.14 always has
10
+ // them, so there is no fallback to fall into.
11
+ import {
12
+ chartNumber, fromExcelNumberFormat, inlineChartData, inlineTableData, isDatasetRef, resolveChartData, resolveTableData, tableCellDisplayValue, toExcelNumberFormat
13
+ } from '@openpresentation/opf';
12
14
 
13
- const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
14
- const STRICT_DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
15
-
16
- /** Core `chartNumber` (strict decimal syntax; everything else is a gap). */
17
- export const chartNumber = typeof opfCore.chartNumber === 'function' ? opfCore.chartNumber : value => {
18
- if (typeof value === 'number') return Number.isFinite(value) ? value : null;
19
- if (typeof value !== 'string') return null;
20
- const text = value.trim();
21
- if (!STRICT_DECIMAL.test(text)) return null;
22
- const number = Number(text);
23
- return Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) ? number : null;
24
- };
25
- /** NumberFormat -> Excel format code ("General" when absent). */
26
- export const excelNumberFormat = typeof opfCore.excelNumberFormat === 'function' ? opfCore.excelNumberFormat : () => 'General';
27
- /** Excel format code -> NumberFormat, or undefined when it has no exact equivalent. */
28
- export const numberFormatFromExcel = typeof opfCore.numberFormatFromExcel === 'function' ? opfCore.numberFormatFromExcel : () => undefined;
29
- export const isDatasetRef = typeof opfCore.isDatasetRef === 'function' ? opfCore.isDatasetRef : value => object(value) && typeof value.dataset === 'string';
30
- export const inlineChartData = typeof opfCore.inlineChartData === 'function' ? opfCore.inlineChartData : chart => chart;
31
- export const inlineTableData = typeof opfCore.inlineTableData === 'function' ? opfCore.inlineTableData : table => table;
32
- export const tableCellDisplayValue = typeof opfCore.tableCellDisplayValue === 'function' ? opfCore.tableCellDisplayValue : cell => cell;
33
- export const resolveTableData = typeof opfCore.resolveTableData === 'function' ? opfCore.resolveTableData
34
- : table => ({...(Array.isArray(table?.columns) ? {columns: table.columns} : {}), rows: Array.isArray(table?.rows) ? table.rows : [], formats: [], diagnostics: []});
35
-
36
- const columnName = column => typeof column === 'string' ? column : object(column) && typeof column.name === 'string' ? column.name : '';
37
-
38
- // Before RR-54 core: inline positional data only (the schema of that core has no datasets or mapping).
39
- function positionalChartData(chart) {
40
- const data = chart?.data;
41
- if (!object(data) || !Array.isArray(data.columns)) return {ok: false, reason: 'no-columns', message: 'The chart data has no columns.', diagnostics: []};
42
- if (!data.columns.length) return {ok: false, reason: 'no-columns', message: 'The chart data has no columns.', diagnostics: []};
43
- if (!Array.isArray(data.rows) || !data.rows.length) return {ok: false, reason: 'no-rows', message: 'The chart data has no rows.', diagnostics: []};
44
- const diagnostics = [];
45
- const rows = data.rows.map((row, rowIndex) => data.columns.map((_, index) => {
46
- const cell = Array.isArray(row) && index < row.length ? row[index] : null;
47
- if (index === 0) return cell === undefined ? null : cell;
48
- const number = chartNumber(cell);
49
- if (number === null && cell !== null && cell !== undefined && cell !== '') diagnostics.push({code: 'chart-value-not-numeric', severity: 'warning', path: `/data/rows/${rowIndex}/${index}`, message: `chart value ${JSON.stringify(cell)} is not a number; it is plotted as a gap.`});
50
- return number;
51
- }));
52
- return {ok: true, columns: data.columns.map(columnName), formats: data.columns.map(() => undefined), rows, diagnostics};
53
- }
15
+ export {chartNumber, fromExcelNumberFormat, inlineChartData, inlineTableData, isDatasetRef, resolveChartData, resolveTableData, tableCellDisplayValue, toExcelNumberFormat};
54
16
 
55
- /** Core `resolveChartData`: the canonical positional table [category, (x,) ...series], or {ok: false, reason}. */
56
- export const resolveChartData = typeof opfCore.resolveChartData === 'function' ? opfCore.resolveChartData : positionalChartData;
17
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
57
18
 
58
- /** True when a chart uses an RR-54 data field that the native cache alone cannot carry back. */
19
+ /**
20
+ * True when a chart uses an RR-54 data field that the native cache alone cannot carry back, or is a combo chart whose `line` or
21
+ * `secondaryAxis` exports its series in another order than the data's (FA-15: column series first, then lines), so the record
22
+ * restores the authored column order.
23
+ */
59
24
  export function chartUsesDataFields(chart) {
60
25
  const data = chart?.data;
61
- return object(chart) && (chart.mapping !== undefined || isDatasetRef(data) || (object(data) && (data.source !== undefined || (Array.isArray(data.columns) && data.columns.some(object)))));
26
+ return object(chart) && (chart.mapping !== undefined || isDatasetRef(data) || (object(data) && (data.source !== undefined || (Array.isArray(data.columns) && data.columns.some(object))))
27
+ || (chart.type === 'combo' && (chart.line !== undefined || chart.secondaryAxis !== undefined)));
62
28
  }
63
29
 
64
30
  /** True when a table is dataset-backed or carries a DataColumn header or a number format. */
@@ -76,7 +42,7 @@ const escapeXml = value => String(value).replace(/&/g, '&amp;').replace(/</g, '&
76
42
  /** The Excel code of a NumberFormat, or undefined when it is absent or General (the exporter's default). */
77
43
  export function excelCode(format) {
78
44
  if (format === undefined) return undefined;
79
- const code = excelNumberFormat(format);
45
+ const code = toExcelNumberFormat(format);
80
46
  return code && code !== 'General' ? code : undefined;
81
47
  }
82
48
 
@@ -85,12 +51,13 @@ export function excelCode(format) {
85
51
  * series the Excel code of each c:ser, in document order (undefined keeps General);
86
52
  * x the X column's code (scatter c:xVal and the horizontal value axis);
87
53
  * axis the value axis code (the first plotted series' format, as Excel does), or undefined to keep the axis;
54
+ * secondaryAxis a combo chart's secondary value axis code (its first series' format, FA-15), or undefined to keep it;
88
55
  * labels false when the labels show percentages (the percent label keeps PowerPoint's own percent form);
89
56
  * scatter true for an XY chart (two value axes: axPos b/t is X).
90
57
  * Every c:numCache of a series takes its code, every c:numFmt of a series' data labels too; the chart group's labels
91
58
  * take the first series' code.
92
59
  */
93
- export function applyChartNumberFormats(xml, {series = [], x, axis, labels = true, scatter = false}) {
60
+ export function applyChartNumberFormats(xml, {series = [], x, axis, secondaryAxis, labels = true, scatter = false}) {
94
61
  // CT_NumData: formatCode is the cache's first child (PptxGenJS writes none on pie and doughnut series).
95
62
  const formatCode = (text, code) => code === undefined ? text : /<c:formatCode>/.test(text)
96
63
  ? text.replace(/<c:formatCode>[^<]*<\/c:formatCode>/g, `<c:formatCode>${escapeXml(code)}</c:formatCode>`)
@@ -107,10 +74,18 @@ export function applyChartNumberFormats(xml, {series = [], x, axis, labels = tru
107
74
  const result = ser.replace(/<c:(val|yVal|xVal)>[\s\S]*?<\/c:\1>/g, (role, name) => formatCode(role, name === 'xVal' ? x : code));
108
75
  return labelFormats(result, code);
109
76
  });
110
- // The chart group's own label block (outside every c:ser) follows the first series.
111
- const grouped = out.split(/(<c:ser>[\s\S]*?<\/c:ser>)/).map((part, position) => position % 2 ? part : labelFormats(part, series[0])).join('');
77
+ // The chart group's own label block (outside every c:ser) follows the group's first series. Only a combo chart has more than
78
+ // one group (FA-15: its line groups follow their own first series).
79
+ let first = 0;
80
+ const grouped = out.replace(/<c:(barChart|lineChart|areaChart|pieChart|doughnutChart|scatterChart|radarChart)>[\s\S]*?<\/c:\1>/g, group => {
81
+ const code = series[first];
82
+ first += group.match(/<c:ser>/g)?.length ?? 0;
83
+ return group.split(/(<c:ser>[\s\S]*?<\/c:ser>)/).map((part, position) => position % 2 ? part : labelFormats(part, code)).join('');
84
+ });
85
+ // The first c:valAx is the value axis; a combo chart's second one is its secondary axis (a scatter chart's two are X and Y).
86
+ let valueAxis = 0;
112
87
  return grouped.replace(/<c:valAx>[\s\S]*?<\/c:valAx>/g, valAx => {
113
- const code = scatter && /<c:axPos val="[bt]"\/>/.test(valAx) ? x : axis;
88
+ const code = scatter ? (/<c:axPos val="[bt]"\/>/.test(valAx) ? x : axis) : valueAxis++ === 0 ? axis : secondaryAxis;
114
89
  return code === undefined ? valAx : valAx.replace(/<c:numFmt\b[^>]*\/>/, `<c:numFmt formatCode="${escapeXml(code)}" sourceLinked="0"/>`);
115
90
  });
116
91
  }
@@ -139,7 +114,7 @@ export function numericRanges(xml, {series = [], x}) {
139
114
  */
140
115
  export function formatFromCode(code) {
141
116
  if (typeof code !== 'string' || code.trim() === '' || /^general$/i.test(code.trim())) return {};
142
- const format = numberFormatFromExcel(code);
117
+ const format = fromExcelNumberFormat(code);
143
118
  return format === undefined ? {unmapped: code} : {format};
144
119
  }
145
120
 
@@ -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 = {};