@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.
- package/README.md +57 -37
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background-import.js +33 -17
- package/dist/background.js +30 -28
- 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 +61 -75
- 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 +208 -129
- 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/image-provenance.js +273 -0
- package/dist/index.d.ts +57 -35
- package/dist/index.js +724 -399
- 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 +102 -101
- package/dist/theme-colors.js +16 -11
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +8 -8
- package/dist/watermark-provenance.js +65 -3
- package/package.json +4 -4
- package/dist/slide-image-provenance.js +0 -177
package/dist/background.js
CHANGED
|
@@ -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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
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
|
|
88
|
-
|
|
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,
|
|
91
|
+
export function nativeImageBackgroundFill(relationshipId, image, {fit = 'cover', focus, opacity = 1, recolor, width, height, fitImage}) {
|
|
93
92
|
const alpha = clamp(opacity);
|
|
94
|
-
const
|
|
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 {
|
|
97
|
-
|
|
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
|
|
103
|
-
const
|
|
104
|
-
return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:srcRect/><a:stretch>${rectXml('fillRect',
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
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() : '')}))},
|
package/dist/body-text-import.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '\r': ' ', '\n': ' ', '\t': '	'};
|
|
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
|
+
}
|
package/dist/chart-data.js
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
import
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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, '&').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 =
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
+
}
|
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 = {};
|