@openpresentation/opf-pptx 0.11.2 → 0.11.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPENDENCY-NOTES.md +1 -1
- package/README.md +21 -3
- package/dist/chart-types.js +38 -9
- package/dist/chart-workbook.js +4 -2
- package/dist/chartex.js +375 -0
- package/dist/index.d.ts +18 -3
- package/dist/index.js +80 -19
- package/dist/script-fonts.js +1 -1
- package/package.json +3 -3
package/DEPENDENCY-NOTES.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Coordinated `@openpresentation/opf` pin
|
|
4
4
|
|
|
5
|
-
PPTX 0.11.
|
|
5
|
+
PPTX 0.11.3 depends on published [`@openpresentation/opf@^0.11.2`](https://www.npmjs.com/package/@openpresentation/opf) (reference layer: ColorRef, document `variables`, exported `resolveColorRef()`). The optional renderer peer is [`@openpresentation/opf-render@^0.11.0`](https://www.npmjs.com/package/@openpresentation/opf-render) so editor 0.10.0 + render 0.11.0 + pptx 0.11.0 install together. CI checks out OPF [`b8a1faf`](https://github.com/OpenPresentation/opf/commit/b8a1faf24203635bd36b09ea19f44bcd53781f16) (`opf-v0.11.0`) for coordinated source linking. Named content colors resolve through core `resolveColorRef()`; eight-digit hex alpha stays a local export concern because core `normalizeHexColor` drops the alpha byte. The deck color scheme is written as the theme `clrScheme`, and named slot and role colors (text, table cells and borders, hyperlinks, backgrounds) are written as `schemeClr` where the deck theme holds exactly that color; card, timeline and media colors, literal hex, variables, translucent colors and slide-override colors stay literal `srgbClr`.
|
|
6
6
|
|
|
7
7
|
OPF PPTX 0.5.1 ships the exact, unmodified PptxGenJS 4.0.1 ESM distribution in `vendor/pptxgenjs`, with its MIT license, upstream archive integrity and per-file SHA-256 hashes. Its actual JSZip dependency is declared directly. Ordinary npm installations therefore omit the unused image-size parser without requiring consumer overrides. This removes the affected dependency; it does not patch the parser. Published OPF PPTX 0.5.0 retains the older graph.
|
|
8
8
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
-
Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
|
|
3
|
+
Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
|
|
4
4
|
|
|
5
5
|
Version 0.9.1 kept core `^0.11.0` and raised the optional `@openpresentation/opf-render` peer to `^0.9.0` so it coexists with editor 0.8.0. ColorRef / `variables` on content colors still hex-resolve through core `resolveColorRef()` before PptxGenJS `srgbClr` export. Unrecognized run colors such as `color:'invalid'` still validate and fall back to the theme text color. Native DrawingML `schemeClr` and theme `clrScheme` writes, and native `p:hf` headers/footers, are not in this release. Import still flattens theme colors to hex. Metric, quote and timeline layout placeholders and the corrected text-bullet contract from 0.8.1 are retained.
|
|
6
6
|
|
|
@@ -80,8 +80,8 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
|
|
|
80
80
|
|
|
81
81
|
- `Slide.title`, `Slide.subtitle`, and `Slide.tag` become editable text boxes, not PowerPoint master placeholders.
|
|
82
82
|
- Root payloads, `blocks[]`, and promoted region keys become editable slide objects in deterministic regions. Promoted keys use the OPF 3x3 region vocabulary (`top`, `middle`, `bottom`, `left`, `center`, `right`).
|
|
83
|
-
- Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping.
|
|
84
|
-
- A chart whose data is one column of values (a histogram or dot plot) has no category column. It exports as a native chart and reports `chart-data-adapted`: a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts, and any other chart type plots the values against their row numbers.
|
|
83
|
+
- Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping. The chartex types (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) export by default as clustered columns and report `chart-data-adapted` (`chartex-fallback`); with `chartex: "native"` they are written as native Office 2016 chartex parts (see [Chartex charts](#chartex-charts-ff-22b), opt-in pending native PowerPoint confirmation). A pie or doughnut chart (and, natively, a one-series chartex construct) plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more.
|
|
84
|
+
- A chart whose data is one column of values (a histogram or dot plot) has no category column. It exports as a native chart and reports `chart-data-adapted`: by default a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts (`histogram-binned`; the binned counts do not round-trip), and any other chart type plots the values against their row numbers (`row-numbers`). With `chartex: "native"` a histogram or Pareto chart bins the values itself (PowerPoint's histogram with an explicit automatic bin count, no diagnostic) and re-imports as authored. Cells parse as in every chart ("12%", "$5" and "1,234" count) and cells that hold no number are skipped, not plotted as 0. Chart data that cannot be plotted (no numbers, no rows, or an external data source), an empty table and unsupported content keep a placeholder frame with a plain-language description (never a dump of the source data or URLs) and report `chart-data-unplottable` or `content-placeholder` with a `reason`; content is never dropped without a diagnostic.
|
|
85
85
|
- Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path.
|
|
86
86
|
- ZIP entries, generated chart/workbook part names, core-property timestamps, and nested chart workbook timestamps are normalized for reproducible bytes. [Export determinism](docs/export-determinism.md) lists the tested time zone, locale, clock and host-font controls and each known variance (WebP conversion, font registries, ICU segmentation, timestamps).
|
|
87
87
|
- The package names only the document's chosen fonts. Chart text (data labels, axes, legend, titles) uses the chart slide's body font in `latin`/`ea`/`cs`; each embedded chart workbook uses the same fonts in its styles and theme; run `pitchFamily` follows the font scheme type (monospace is fixed pitch, serif is roman); and `docProps/app.xml` "Fonts Used" lists the fonts the package actually uses. The theme keeps PptxGenJS's per-script supplements (`THEME_SCRIPT_SUPPLEMENTS`) and empty `ea`/`cs` slots.
|
|
@@ -125,6 +125,24 @@ The first importer is mechanical and schema-compatible:
|
|
|
125
125
|
|
|
126
126
|
There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
|
|
127
127
|
|
|
128
|
+
## Chartex charts (FF-22b)
|
|
129
|
+
|
|
130
|
+
**Opt-in.** `toPptx(document, {chartex: "native"})` enables the native chartex export described here. The default, `chartex: "fallback"`, keeps the clustered column export of the seven chartex ids byte for byte (`test/fixtures/chartex-fallback-main.json` pins it) with the `chartex-fallback` and `histogram-binned` diagnostics, because a malformed chartex part would make PowerPoint offer to repair the whole deck, and the parts are not yet confirmed in native PowerPoint (no Open XML SDK validation either). The default flips once the program's native check of the FF-22b deck set passes. Import of chartex parts is always on.
|
|
131
|
+
|
|
132
|
+
With `chartex: "native"`, the seven kept chart types with no ECMA-376 construct are written as Office 2016 chartex parts (`ppt/charts/chartExN.xml`, `cx:chartSpace`, content type `application/vnd.ms-office.chartex+xml`), each with its chart style and colour style parts (`styleN.xml`, `colorsN.xml`) and the embedded workbook the data came from. PptxGenJS cannot write these parts, so the exporter first writes the classic clustered column chart of the same data and then post-processes the package (`src/chartex.js`): the slide's chart frame becomes an `mc:AlternateContent` whose `mc:Choice` (requiring the construct's chartex namespace) references the chartex part and whose `mc:Fallback` keeps the classic chart frame, so a reader without chartex support still shows the data as columns. Both parts share the workbook. `cx:series uniqueId` values are derived from the chart number, so the bytes stay deterministic.
|
|
133
|
+
|
|
134
|
+
| OPF id | `cx:series layoutId` | Data (category-major) | Notes |
|
|
135
|
+
|---|---|---|---|
|
|
136
|
+
| `treemap` | `treemap` | `[Category, Value]`, first series | one tile per category, category data labels, one palette colour per tile |
|
|
137
|
+
| `histogram` | `clusteredColumn` | `[Value]` or `[Category, Value]`, first series | a lone value column is binned by PowerPoint with an explicit automatic bin count (Scott's rule, `cx:binCount`); a category column bins by category (`cx:aggregation`) |
|
|
138
|
+
| `pareto` | `clusteredColumn` owning a `paretoLine` | as histogram | the Office Pareto chart: sorted columns plus the cumulative-percentage line on a percentage axis |
|
|
139
|
+
| `box-and-whisker` | `boxWhisker` | `[Category, S1, S2, ...]`, every series | rows with the same category form one box per series; exclusive quartiles, mean markers, outliers |
|
|
140
|
+
| `waterfall` | `waterfall` | `[Category, Value]`, first series | connector lines; increases and decreases in the first two palette colours; no subtotals |
|
|
141
|
+
| `funnel` | `funnel` | `[Category, Value]`, first series | value data labels |
|
|
142
|
+
| `world` | `regionMap` | `[Region, Value]`, first series | no `cx:geoCache`: PowerPoint matches the region names and fetches the shapes from Bing Maps when the deck is opened online, and reports `chart-map-geodata`; offline, or where Office map data is disabled, the map shows no regions |
|
|
143
|
+
|
|
144
|
+
The chart area, label colour, label font (`latin`/`ea`/`cs` in the chart's body font) and series colours follow the classic charts. Re-import (`fromPptx`) reads the chartex part first: the layoutIds name the OPF id (an owned `paretoLine` is `pareto`), `cx:strDim type="cat"` restores the categories and each `cx:numDim` a series, under the same 100,000-point and 1,000,000-cell bounds as classic chart caches; a lone value column comes back as authored. A chartex part with no OPF construct (sunburst) falls back to the `mc:Fallback` chart, or to a text placeholder when PowerPoint's own text fallback is all there is. Native PowerPoint rendering of each construct is confirmed through the program's bounded native sample, not by this package's tests.
|
|
145
|
+
|
|
128
146
|
## Languages, right-to-left text and script fonts
|
|
129
147
|
|
|
130
148
|
The exporter reads the presentation `language` through core `resolveScriptFonts()` (FF-07; the model is core `docs/programs/font-fidelity-everywhere/script-font-model.md`):
|
package/dist/chart-types.js
CHANGED
|
@@ -29,19 +29,48 @@ export const CHART_TYPES = Object.freeze({
|
|
|
29
29
|
radar: category('radar', { aspose: 'Radar', radarStyle: 'standard', markers: false }),
|
|
30
30
|
'radar-with-markers': category('radar', { aspose: 'RadarWithMarkers', radarStyle: 'marker', markers: true }),
|
|
31
31
|
'filled-radar': category('radar', { aspose: 'FilledRadar', radarStyle: 'filled', markers: false }),
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
// Office 2016 chartex constructs (cx:chartSpace, one cx:series per plot,
|
|
33
|
+
// named by layoutId). `requires` is the markup-compatibility namespace the
|
|
34
|
+
// slide's mc:Choice names, so older readers take the classic fallback.
|
|
35
|
+
// `series` is the number of value columns the construct plots.
|
|
36
|
+
treemap: chartex('treemap', 'Treemap', { requires: 'cx1', dimension: 'size', series: 1 }),
|
|
37
|
+
histogram: chartex('clusteredColumn', 'Histogram', { requires: 'cx1', binning: true, series: 1, axes: true }),
|
|
38
|
+
pareto: chartex('paretoLine', 'ParetoLine', { requires: 'cx1', binning: true, owner: 'clusteredColumn', series: 1, axes: true }),
|
|
39
|
+
'box-and-whisker': chartex('boxWhisker', 'BoxAndWhisker', { requires: 'cx1', series: Infinity, axes: true }),
|
|
40
|
+
waterfall: chartex('waterfall', 'Waterfall', { requires: 'cx1', series: 1, axes: true }),
|
|
41
|
+
funnel: chartex('funnel', 'Funnel', { requires: 'cx2', series: 1, axes: true }),
|
|
42
|
+
world: chartex('regionMap', 'Map', { requires: 'cx5', dimension: 'colorVal', series: 1 }),
|
|
39
43
|
});
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
45
|
+
function chartex(layoutId, aspose, extra) {
|
|
46
|
+
return { family: 'chartex', layoutId, aspose, dimension: 'val', ...extra };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The classic construct written as the mc:Fallback of every chartex chart, so a
|
|
50
|
+
// reader without chartex support shows a clustered column chart of the same
|
|
51
|
+
// workbook data.
|
|
43
52
|
export const CHARTEX_FALLBACK = CHART_TYPES.column;
|
|
44
53
|
|
|
54
|
+
// Markup-compatibility namespaces for `requires` (MS-ODRAWXML chartex
|
|
55
|
+
// versions). PowerPoint 2016 and later understand all of them.
|
|
56
|
+
export const CHARTEX_NAMESPACES = Object.freeze({
|
|
57
|
+
cx: 'http://schemas.microsoft.com/office/drawing/2014/chartex',
|
|
58
|
+
cx1: 'http://schemas.microsoft.com/office/drawing/2015/9/8/chartex',
|
|
59
|
+
cx2: 'http://schemas.microsoft.com/office/drawing/2015/10/21/chartex',
|
|
60
|
+
cx3: 'http://schemas.microsoft.com/office/drawing/2016/5/9/chartex',
|
|
61
|
+
cx4: 'http://schemas.microsoft.com/office/drawing/2016/5/10/chartex',
|
|
62
|
+
cx5: 'http://schemas.microsoft.com/office/drawing/2016/5/11/chartex',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// cx:series layoutIds of a chartex part (an owned paretoLine marks the Office
|
|
66
|
+
// Pareto chart) -> kept OPF id, for import; null for sunburst and unknown layouts.
|
|
67
|
+
export function chartTypeFromChartex(layoutIds) {
|
|
68
|
+
const ids = new Set(layoutIds);
|
|
69
|
+
if (ids.has('paretoLine')) return 'pareto';
|
|
70
|
+
for (const [id, spec] of Object.entries(CHART_TYPES)) if (spec.family === 'chartex' && ids.has(spec.layoutId)) return id;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
const variants = (base, target) => Object.fromEntries([base, `${base}-2x`, `${base}-3x`].map((id) => [id, target]));
|
|
46
75
|
|
|
47
76
|
// Deprecated core ids (opf 0.12.0 removes them) -> replacement id.
|
package/dist/chart-workbook.js
CHANGED
|
@@ -21,7 +21,8 @@ function relationship(entries,source,id){
|
|
|
21
21
|
}
|
|
22
22
|
function workbookContext(entries,chartPart){
|
|
23
23
|
const chart=xml(entries,chartPart)?.chartSpace;
|
|
24
|
-
|
|
24
|
+
// A classic part keeps c:externalData under c:chartSpace; a chartex part keeps cx:externalData under cx:chartData.
|
|
25
|
+
const ref=relationship(entries,chartPart,(chart?.externalData??chart?.chartData?.externalData)?.id);
|
|
25
26
|
if(!ref?.type?.endsWith('/package')||!entries[ref.path])return undefined;
|
|
26
27
|
// Read only bounded spreadsheet metadata. Never follow external workbook links.
|
|
27
28
|
let size=0;
|
|
@@ -29,7 +30,8 @@ function workbookContext(entries,chartPart){
|
|
|
29
30
|
if(!/^xl\/(workbook\.xml|_rels\/workbook\.xml\.rels|sharedStrings\.xml|worksheets\/[^/]+\.xml)$/.test(file.name))return false;
|
|
30
31
|
size+=file.originalSize;if(size>16*1024*1024)throw Error('Chart workbook metadata exceeds the limit.');return true;
|
|
31
32
|
}});
|
|
32
|
-
|
|
33
|
+
// The category range: c:cat/c:xVal references, or the chartex cx:strDim type="cat" formula.
|
|
34
|
+
const category=find(chart,'cat')[0]??find(chart,'xVal')[0]??find(chart,'strDim').find(dim=>dim?.type==='cat'),formula=category?.strRef?.f??category?.multiLvlStrRef?.f??category?.numRef?.f??(category?.type==='cat'?category?.f:undefined);
|
|
33
35
|
// Infer a heading only for one contiguous vertical category range with a row
|
|
34
36
|
// above it. Other chart/workbook arrangements remain explicitly unrecovered.
|
|
35
37
|
const range=/^(?:'((?:[^']|'')+)'|([^'!]+))!\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)$/i.exec(text(formula));
|
package/dist/chartex.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Native Office 2016 chartex export and import (FF-22b).
|
|
2
|
+
//
|
|
3
|
+
// PptxGenJS cannot write a `cx:chartSpace` part, so a chartex chart (treemap,
|
|
4
|
+
// histogram, pareto, box & whisker, waterfall, funnel, map) is exported in two
|
|
5
|
+
// steps. PptxGenJS first writes the classic clustered column chart of the same
|
|
6
|
+
// data, with its embedded workbook. The package is then post-processed here:
|
|
7
|
+
// a `ppt/charts/chartExN.xml` part is added with `cx:chartData` (the workbook
|
|
8
|
+
// ranges and their cached values) and one `cx:series` per plotted column,
|
|
9
|
+
// together with its chart style and colour style parts, and the slide's chart
|
|
10
|
+
// frame is wrapped in `mc:AlternateContent`: the `mc:Choice` (which requires
|
|
11
|
+
// the chartex namespace of the construct) references the chartex part and the
|
|
12
|
+
// `mc:Fallback` keeps the classic chart, so a reader without chartex support
|
|
13
|
+
// still shows the data. Both parts share the workbook.
|
|
14
|
+
//
|
|
15
|
+
// Import reads the chartex part back: the `cx:series` layoutIds name the OPF
|
|
16
|
+
// chart type (an owned `paretoLine` marks the Office Pareto chart), and the
|
|
17
|
+
// cached dimensions restore the category-major data.
|
|
18
|
+
import {CHARTEX_NAMESPACES, chartTypeFromChartex} from './chart-types.js';
|
|
19
|
+
|
|
20
|
+
const NS = Object.freeze({
|
|
21
|
+
a: 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
|
22
|
+
r: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
|
23
|
+
mc: 'http://schemas.openxmlformats.org/markup-compatibility/2006',
|
|
24
|
+
cs: 'http://schemas.microsoft.com/office/drawing/2012/chartStyle',
|
|
25
|
+
cx: CHARTEX_NAMESPACES.cx,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const CHARTEX_CONTENT_TYPES = Object.freeze({
|
|
29
|
+
chart: 'application/vnd.ms-office.chartex+xml',
|
|
30
|
+
style: 'application/vnd.ms-office.chartstyle+xml',
|
|
31
|
+
colors: 'application/vnd.ms-office.chartcolorstyle+xml',
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const CHARTEX_RELATIONSHIP_TYPES = Object.freeze({
|
|
35
|
+
chart: 'http://schemas.microsoft.com/office/2014/relationships/chartEx',
|
|
36
|
+
style: 'http://schemas.microsoft.com/office/2011/relationships/chartStyle',
|
|
37
|
+
colors: 'http://schemas.microsoft.com/office/2011/relationships/chartColorStyle',
|
|
38
|
+
package: 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/package',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export const CHARTEX_GRAPHIC_DATA_URI = NS.cx;
|
|
42
|
+
|
|
43
|
+
const decoder = new TextDecoder(), encoder = new TextEncoder();
|
|
44
|
+
const text = bytes => decoder.decode(bytes);
|
|
45
|
+
const escapeXml = value => String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
|
46
|
+
|
|
47
|
+
/** Excel column letters for a 1-based column index (1 = A, 27 = AA). */
|
|
48
|
+
export function columnLetters(index) {
|
|
49
|
+
let name = '';
|
|
50
|
+
for (let n = index; n > 0; n = Math.floor((n - 1) / 26)) name = String.fromCharCode(65 + ((n - 1) % 26)) + name;
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Office's automatic histogram bins: Scott's normal reference rule gives the bin
|
|
56
|
+
* width 3.49 * sd * n^(-1/3) (sample standard deviation), and the bins cover
|
|
57
|
+
* [min, max] from the minimum. Returns the bin count PowerPoint is told to use
|
|
58
|
+
* (`cx:binCount`), so the preview and the native chart agree on the bins.
|
|
59
|
+
*/
|
|
60
|
+
export function scottBinCount(values) {
|
|
61
|
+
const n = values.length;
|
|
62
|
+
if (n === 0) return 1;
|
|
63
|
+
let min = Infinity, max = -Infinity, mean = 0;
|
|
64
|
+
for (const value of values) { if (value < min) min = value; if (value > max) max = value; mean += value / n; }
|
|
65
|
+
if (!(max > min) || n < 2) return 1;
|
|
66
|
+
let variance = 0;
|
|
67
|
+
for (const value of values) variance += (value - mean) * (value - mean) / (n - 1);
|
|
68
|
+
const width = 3.49 * Math.sqrt(variance) * Math.cbrt(n) ** -1;
|
|
69
|
+
if (!(width > 0) || !Number.isFinite(width)) return 1;
|
|
70
|
+
const count = Math.ceil((max - min) / width - 1e-9);
|
|
71
|
+
return Number.isFinite(count) && count >= 1 ? count : 1;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A deterministic GUID for cx:series uniqueId, from the chart number and
|
|
75
|
+
// series index (FNV-1a over the key, repeated for each hex group).
|
|
76
|
+
function seriesUniqueId(chart, index) {
|
|
77
|
+
const hex = (seed, length) => {
|
|
78
|
+
let hash = 0x811c9dc5 ^ seed;
|
|
79
|
+
const key = `chartEx${chart}:${index}:${seed}`;
|
|
80
|
+
for (let i = 0; i < key.length; i++) { hash ^= key.charCodeAt(i); hash = Math.imul(hash, 0x01000193) >>> 0; }
|
|
81
|
+
return hash.toString(16).toUpperCase().padStart(8, '0').repeat(2).slice(0, length);
|
|
82
|
+
};
|
|
83
|
+
return `{${hex(1, 8)}-${hex(2, 4)}-4${hex(3, 3)}-8${hex(4, 3)}-${hex(5, 12)}}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function solidFill(hex, alpha) {
|
|
87
|
+
return `<a:solidFill>${alpha === undefined ? `<a:srgbClr val="${hex}"/>` : `<a:srgbClr val="${hex}"><a:alpha val="${alpha}"/></a:srgbClr>`}</a:solidFill>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function numberText(value) {
|
|
91
|
+
return String(value);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The layout properties of the plotted series (CT_SeriesLayoutProperties order:
|
|
95
|
+
// parentLabelLayout, regionLabelLayout, visibility, aggregation, binning,
|
|
96
|
+
// geography, statistics, subtotals).
|
|
97
|
+
function layoutProperties(spec, series, hasCategories) {
|
|
98
|
+
switch (spec.layoutId) {
|
|
99
|
+
case 'treemap':
|
|
100
|
+
return '<cx:layoutPr><cx:parentLabelLayout val="none"/></cx:layoutPr>';
|
|
101
|
+
case 'clusteredColumn':
|
|
102
|
+
case 'paretoLine':
|
|
103
|
+
// A category column bins by category (Office "By category"); a lone value column is binned automatically (Scott's rule, made explicit).
|
|
104
|
+
if (hasCategories) return '<cx:layoutPr><cx:aggregation/></cx:layoutPr>';
|
|
105
|
+
return `<cx:layoutPr><cx:binning intervalClosed="r" underflow="auto" overflow="auto"><cx:binCount val="${scottBinCount(series.values.filter(value => value !== null && Number.isFinite(value)))}"/></cx:binning></cx:layoutPr>`;
|
|
106
|
+
case 'boxWhisker':
|
|
107
|
+
return '<cx:layoutPr><cx:visibility meanLine="0" meanMarker="1" nonoutliers="0" outliers="1"/><cx:statistics quartileMethod="exclusive"/></cx:layoutPr>';
|
|
108
|
+
case 'waterfall':
|
|
109
|
+
return '<cx:layoutPr><cx:visibility connectorLines="1"/></cx:layoutPr>';
|
|
110
|
+
case 'regionMap':
|
|
111
|
+
// No cx:geoCache: PowerPoint fetches the region shapes from Bing Maps when the deck is opened online (see chart-map-geodata).
|
|
112
|
+
return '<cx:layoutPr><cx:geography cultureLanguage="en-US" cultureRegion="US" attribution="Powered by Bing"/></cx:layoutPr>';
|
|
113
|
+
default:
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function dataLabels(spec) {
|
|
119
|
+
if (spec.layoutId === 'treemap') return '<cx:dataLabels pos="inEnd"><cx:visibility seriesName="0" categoryName="1" value="0"/></cx:dataLabels>';
|
|
120
|
+
if (spec.layoutId === 'funnel') return '<cx:dataLabels pos="ctr"><cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels>';
|
|
121
|
+
return '';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Per-point fills where the construct colours points, not series: a treemap
|
|
125
|
+
// tile per category, a waterfall bar by sign. The preview paints the same.
|
|
126
|
+
export function chartexPointColors(layoutId, values, palette) {
|
|
127
|
+
if (layoutId === 'treemap') return values.map((value, index) => value === null ? null : palette[index % palette.length]);
|
|
128
|
+
if (layoutId === 'waterfall') return values.map(value => value === null ? null : palette[value < 0 ? 1 : 0]);
|
|
129
|
+
return values.map(() => null);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function axes(spec, gridColor) {
|
|
133
|
+
if (!spec.axes) return '';
|
|
134
|
+
const gridlines = `<cx:majorGridlines><cx:spPr><a:ln w="9525"><a:solidFill><a:srgbClr val="${gridColor}"><a:alpha val="70000"/></a:srgbClr></a:solidFill></a:ln></cx:spPr></cx:majorGridlines>`;
|
|
135
|
+
const gapWidth = {clusteredColumn: '0.06', paretoLine: '0.06', boxWhisker: '1', waterfall: '0.5', funnel: '0.06'}[spec.layoutId] ?? '1';
|
|
136
|
+
const category = `<cx:axis id="0"><cx:catScaling gapWidth="${gapWidth}"/><cx:tickLabels/></cx:axis>`;
|
|
137
|
+
if (spec.layoutId === 'funnel') return `${category}<cx:axis id="1" hidden="1"><cx:valScaling/><cx:tickLabels/></cx:axis>`;
|
|
138
|
+
const value = `<cx:axis id="1"><cx:valScaling/>${gridlines}<cx:tickLabels/></cx:axis>`;
|
|
139
|
+
const percentage = spec.layoutId === 'paretoLine' ? '<cx:axis id="2"><cx:valScaling max="1" min="0"/><cx:units unit="percentage"/><cx:tickLabels/></cx:axis>' : '';
|
|
140
|
+
return `${category}${value}${percentage}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The cx:chartSpace part for one chart. `series` is the exporter's plotted
|
|
145
|
+
* series ({name, labels, values}); `hasCategories` says whether the first data
|
|
146
|
+
* column held category labels (a lone value column has none). The workbook
|
|
147
|
+
* layout is PptxGenJS's: Sheet1, headings in row 1, categories in column A and
|
|
148
|
+
* one series per following column.
|
|
149
|
+
*/
|
|
150
|
+
export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette}) {
|
|
151
|
+
const rows = series[0].labels.length;
|
|
152
|
+
const range = (letter) => `Sheet1!$${letter}$2:$${letter}$${rows + 1}`;
|
|
153
|
+
const categories = hasCategories
|
|
154
|
+
? `<cx:strDim type="cat"><cx:f>${range('A')}</cx:f><cx:lvl ptCount="${rows}">${series[0].labels.map((label, index) => `<cx:pt idx="${index}">${escapeXml(label)}</cx:pt>`).join('')}</cx:lvl></cx:strDim>`
|
|
155
|
+
: '';
|
|
156
|
+
const data = series.map((entry, index) => {
|
|
157
|
+
const points = entry.values.map((value, row) => value === null || !Number.isFinite(value) ? '' : `<cx:pt idx="${row}">${numberText(value)}</cx:pt>`).join('');
|
|
158
|
+
return `<cx:data id="${index}">${categories}<cx:numDim type="${spec.dimension}"><cx:f>${range(columnLetters(index + 2))}</cx:f><cx:lvl ptCount="${rows}" formatCode="General">${points}</cx:lvl></cx:numDim></cx:data>`;
|
|
159
|
+
}).join('');
|
|
160
|
+
const plotted = series.map((entry, index) => {
|
|
161
|
+
const color = palette[index % palette.length];
|
|
162
|
+
const letter = columnLetters(index + 2);
|
|
163
|
+
const pointFills = chartexPointColors(spec.layoutId, entry.values, palette)
|
|
164
|
+
.map((pointColor, row) => pointColor ? `<cx:dataPt idx="${row}"><cx:spPr>${solidFill(pointColor)}</cx:spPr></cx:dataPt>` : '').join('');
|
|
165
|
+
return `<cx:series layoutId="${spec.owner ?? spec.layoutId}" uniqueId="${seriesUniqueId(number, index)}">` +
|
|
166
|
+
`<cx:tx><cx:txData><cx:f>Sheet1!$${letter}$1</cx:f><cx:v>${escapeXml(entry.name)}</cx:v></cx:txData></cx:tx>` +
|
|
167
|
+
`<cx:spPr>${solidFill(color)}</cx:spPr>${pointFills}${dataLabels(spec)}<cx:dataId val="${index}"/>${layoutProperties(spec, entry, hasCategories)}` +
|
|
168
|
+
(spec.axes ? '<cx:axisId val="0"/><cx:axisId val="1"/>' : '') + '</cx:series>';
|
|
169
|
+
});
|
|
170
|
+
if (spec.owner) {
|
|
171
|
+
// The Office Pareto chart: the cumulative-percentage line owned by the binned columns, on the percentage axis.
|
|
172
|
+
plotted.push(`<cx:series layoutId="${spec.layoutId}" ownerIdx="0" uniqueId="${seriesUniqueId(number, series.length)}"><cx:spPr><a:ln w="19050"><a:solidFill><a:srgbClr val="${palette[1 % palette.length]}"/></a:solidFill></a:ln></cx:spPr><cx:axisId val="0"/><cx:axisId val="2"/></cx:series>`);
|
|
173
|
+
}
|
|
174
|
+
const legend = spec.layoutId === 'boxWhisker' && series.length > 1 ? '<cx:legend pos="r" align="ctr" overlay="0"/>' : '';
|
|
175
|
+
const alpha = fill.transparency > 0 ? Math.round((100 - fill.transparency) * 1000) : undefined;
|
|
176
|
+
const face = escapeXml(font);
|
|
177
|
+
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
178
|
+
`<cx:chartSpace xmlns:a="${NS.a}" xmlns:r="${NS.r}" xmlns:cx="${NS.cx}">` +
|
|
179
|
+
`<cx:chartData><cx:externalData r:id="${workbookRelId}" cx:autoUpdate="0"/>${data}</cx:chartData>` +
|
|
180
|
+
`<cx:chart><cx:plotArea><cx:plotAreaRegion>${plotted.join('')}</cx:plotAreaRegion>${axes(spec, gridColor)}</cx:plotArea>${legend}</cx:chart>` +
|
|
181
|
+
`<cx:spPr>${solidFill(fill.color, alpha)}<a:ln><a:noFill/></a:ln></cx:spPr>` +
|
|
182
|
+
`<cx:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="900">${solidFill(labelColor)}<a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/></a:defRPr></a:pPr><a:endParaRPr lang="en-US"/></a:p></cx:txPr>` +
|
|
183
|
+
'</cx:chartSpace>';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function chartexRelationshipsXml({workbookTarget, number}) {
|
|
187
|
+
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
188
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
189
|
+
`<Relationship Id="rId1" Type="${CHARTEX_RELATIONSHIP_TYPES.package}" Target="${escapeXml(workbookTarget)}"/>` +
|
|
190
|
+
`<Relationship Id="rId2" Type="${CHARTEX_RELATIONSHIP_TYPES.style}" Target="style${number}.xml"/>` +
|
|
191
|
+
`<Relationship Id="rId3" Type="${CHARTEX_RELATIONSHIP_TYPES.colors}" Target="colors${number}.xml"/>` +
|
|
192
|
+
'</Relationships>';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Chart style part (cs:chartStyle, style 201): every CT_StyleEntry the schema
|
|
196
|
+
// requires, in schema order, with the Office 2013+ default references. Series
|
|
197
|
+
// and point fills in the chartex part are explicit, so the style only governs
|
|
198
|
+
// text and chrome PowerPoint draws itself.
|
|
199
|
+
const STYLE_ENTRIES = [
|
|
200
|
+
'axisTitle', 'categoryAxis', 'chartArea', 'dataLabel', 'dataLabelCallout', 'dataPoint', 'dataPoint3D', 'dataPointLine', 'dataPointMarker',
|
|
201
|
+
'dataPointMarkerLayout', 'dataPointWireframe', 'dataTable', 'downBar', 'dropLine', 'errorBar', 'floor', 'gridlineMajor', 'gridlineMinor', 'hiLoLine',
|
|
202
|
+
'leaderLine', 'legend', 'plotArea', 'plotArea3D', 'seriesAxis', 'seriesLine', 'title', 'trendline', 'trendlineLabel', 'upBar', 'valueAxis', 'wall',
|
|
203
|
+
];
|
|
204
|
+
export function chartStyleXml() {
|
|
205
|
+
const textColor = '<a:schemeClr val="tx1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr>';
|
|
206
|
+
const faintLine = '<a:ln w="9525" cap="flat" cmpd="sng" algn="ctr"><a:solidFill><a:schemeClr val="tx1"><a:lumMod val="15000"/><a:lumOff val="85000"/></a:schemeClr></a:solidFill><a:round/></a:ln>';
|
|
207
|
+
const entry = (name, {fillIdx = '0', fillColor = '', fontColor = '<a:schemeClr val="tx1"/>', spPr = '', size = '', mods = ''} = {}) =>
|
|
208
|
+
`<cs:${name}${mods ? ` mods="${mods}"` : ''}><cs:lnRef idx="0"/><cs:fillRef idx="${fillIdx}">${fillColor}</cs:fillRef><cs:effectRef idx="0"/><cs:fontRef idx="minor">${fontColor}</cs:fontRef>${spPr ? `<cs:spPr>${spPr}</cs:spPr>` : ''}${size ? `<cs:defRPr sz="${size}" kern="1200"/>` : ''}</cs:${name}>`;
|
|
209
|
+
const entries = STYLE_ENTRIES.map(name => {
|
|
210
|
+
switch (name) {
|
|
211
|
+
case 'dataPointMarkerLayout': return '<cs:dataPointMarkerLayout symbol="circle" size="5"/>';
|
|
212
|
+
case 'axisTitle': return entry(name, {fontColor: textColor, size: '1000'});
|
|
213
|
+
case 'categoryAxis': case 'valueAxis': case 'seriesAxis': return entry(name, {fontColor: textColor, spPr: faintLine, size: '900'});
|
|
214
|
+
case 'chartArea': return entry(name, {mods: 'allowNoFillOverride allowNoLineOverride', spPr: `<a:solidFill><a:schemeClr val="bg1"/></a:solidFill>${faintLine}`, size: '1000'});
|
|
215
|
+
case 'dataLabel': case 'dataLabelCallout': case 'dataTable': case 'legend': case 'trendlineLabel': return entry(name, {fontColor: textColor, size: '900'});
|
|
216
|
+
case 'dataPoint': case 'dataPoint3D': case 'dataPointWireframe': case 'upBar': case 'floor': case 'wall':
|
|
217
|
+
return entry(name, {fillIdx: '1', fillColor: '<cs:styleClr val="auto"/>', spPr: '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'});
|
|
218
|
+
case 'dataPointLine': case 'dataPointMarker': case 'trendline': case 'seriesLine': case 'hiLoLine': case 'dropLine': case 'leaderLine': case 'errorBar':
|
|
219
|
+
return entry(name, {fillColor: '<cs:styleClr val="auto"/>', spPr: '<a:ln w="28575" cap="rnd"><a:solidFill><a:schemeClr val="phClr"/></a:solidFill><a:round/></a:ln>'});
|
|
220
|
+
case 'downBar': return entry(name, {fillIdx: '1', spPr: '<a:solidFill><a:schemeClr val="dk1"><a:lumMod val="65000"/><a:lumOff val="35000"/></a:schemeClr></a:solidFill>'});
|
|
221
|
+
case 'gridlineMajor': case 'gridlineMinor': return entry(name, {spPr: faintLine});
|
|
222
|
+
case 'title': return entry(name, {fontColor: textColor, size: '1400'});
|
|
223
|
+
default: return entry(name);
|
|
224
|
+
}
|
|
225
|
+
}).join('');
|
|
226
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cs:chartStyle xmlns:cs="${NS.cs}" xmlns:a="${NS.a}" id="201">${entries}</cs:chartStyle>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Colour style part (cs:colorStyle, colourful palette 10): the six theme
|
|
230
|
+
// accents with the standard Office variations.
|
|
231
|
+
export function chartColorStyleXml() {
|
|
232
|
+
const variations = ['', '<a:lumMod val="60000"/>', '<a:lumMod val="80000"/><a:lumOff val="20000"/>', '<a:lumMod val="80000"/>', '<a:lumMod val="60000"/><a:lumOff val="40000"/>',
|
|
233
|
+
'<a:lumMod val="50000"/>', '<a:lumMod val="70000"/><a:lumOff val="30000"/>', '<a:lumMod val="70000"/>', '<a:lumMod val="50000"/><a:lumOff val="50000"/>'];
|
|
234
|
+
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cs:colorStyle xmlns:cs="${NS.cs}" xmlns:a="${NS.a}" meth="cycle" id="10">` +
|
|
235
|
+
[1, 2, 3, 4, 5, 6].map(n => `<a:schemeClr val="accent${n}"/>`).join('') + variations.map(v => `<cs:variation>${v}</cs:variation>`).join('') + '</cs:colorStyle>';
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Wrap the PptxGenJS chart frame: the mc:Choice references the chartex part,
|
|
240
|
+
* the mc:Fallback keeps the classic chart frame. The choice frame copies the
|
|
241
|
+
* name, id, description and transform of the original.
|
|
242
|
+
*/
|
|
243
|
+
export function wrapChartexFrame(frame, relId, requires) {
|
|
244
|
+
const namespace = CHARTEX_NAMESPACES[requires];
|
|
245
|
+
if (!namespace) throw new Error(`Unknown chartex namespace ${requires}.`);
|
|
246
|
+
const head = frame.match(/^<p:graphicFrame>[\s\S]*?<\/p:xfrm>/)?.[0];
|
|
247
|
+
if (!head) throw new Error('Generated chart frame has no transform.');
|
|
248
|
+
const choice = `${head}<a:graphic xmlns:a="${NS.a}"><a:graphicData uri="${NS.cx}"><cx:chart xmlns:cx="${NS.cx}" xmlns:r="${NS.r}" r:id="${relId}"/></a:graphicData></a:graphic></p:graphicFrame>`;
|
|
249
|
+
return `<mc:AlternateContent xmlns:mc="${NS.mc}"><mc:Choice xmlns:${requires}="${namespace}" Requires="${requires}">${choice}</mc:Choice><mc:Fallback>${frame}</mc:Fallback></mc:AlternateContent>`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Add the chartex parts for every chart the exporter registered in
|
|
254
|
+
* `context.chartex` (by frame name), in slide order. Runs on the unzipped
|
|
255
|
+
* package after the classic chart parts have their fonts and headings.
|
|
256
|
+
*/
|
|
257
|
+
export function attachChartexParts(entries, chartex, parseRelationships) {
|
|
258
|
+
if (!chartex.size) return;
|
|
259
|
+
const slides = Object.keys(entries).filter(part => /^ppt\/slides\/slide\d+\.xml$/.test(part)).sort((a, b) => Number(a.match(/(\d+)\.xml$/)[1]) - Number(b.match(/(\d+)\.xml$/)[1]));
|
|
260
|
+
const overrides = [];
|
|
261
|
+
let number = 0;
|
|
262
|
+
for (const part of slides) {
|
|
263
|
+
const relationships = parseRelationships(entries, part);
|
|
264
|
+
const relsPart = part.replace(/([^/]+)$/, '_rels/$1.rels');
|
|
265
|
+
let rels = text(entries[relsPart]);
|
|
266
|
+
let nextId = Math.max(0, ...[...rels.matchAll(/\bId="rId(\d+)"/g)].map(match => Number(match[1]))) + 1;
|
|
267
|
+
let xml = text(entries[part]);
|
|
268
|
+
let changed = false;
|
|
269
|
+
xml = xml.replace(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g, frame => {
|
|
270
|
+
const name = frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
271
|
+
const chart = chartex.get(name);
|
|
272
|
+
if (!chart) return frame;
|
|
273
|
+
const chartPart = relationships.get(frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1])?.path;
|
|
274
|
+
if (!chartPart) throw new Error('Generated chart relationship is missing.');
|
|
275
|
+
const workbook = [...parseRelationships(entries, chartPart).values()].find(relationship => relationship.type === CHARTEX_RELATIONSHIP_TYPES.package);
|
|
276
|
+
if (!workbook) throw new Error('Generated chart has no embedded workbook.');
|
|
277
|
+
number += 1;
|
|
278
|
+
const base = `ppt/charts/chartEx${number}.xml`;
|
|
279
|
+
entries[base] = encoder.encode(chartexPartXml({...chart, number, workbookRelId: 'rId1'}));
|
|
280
|
+
entries[`ppt/charts/_rels/chartEx${number}.xml.rels`] = encoder.encode(chartexRelationshipsXml({workbookTarget: workbook.target, number}));
|
|
281
|
+
entries[`ppt/charts/style${number}.xml`] = encoder.encode(chartStyleXml());
|
|
282
|
+
entries[`ppt/charts/colors${number}.xml`] = encoder.encode(chartColorStyleXml());
|
|
283
|
+
overrides.push(`<Override PartName="/${base}" ContentType="${CHARTEX_CONTENT_TYPES.chart}"/>`,
|
|
284
|
+
`<Override PartName="/ppt/charts/style${number}.xml" ContentType="${CHARTEX_CONTENT_TYPES.style}"/>`,
|
|
285
|
+
`<Override PartName="/ppt/charts/colors${number}.xml" ContentType="${CHARTEX_CONTENT_TYPES.colors}"/>`);
|
|
286
|
+
const relId = `rId${nextId++}`;
|
|
287
|
+
rels = rels.replace('</Relationships>', `<Relationship Id="${relId}" Type="${CHARTEX_RELATIONSHIP_TYPES.chart}" Target="../charts/chartEx${number}.xml"/></Relationships>`);
|
|
288
|
+
changed = true;
|
|
289
|
+
return wrapChartexFrame(frame, relId, chart.spec.requires);
|
|
290
|
+
});
|
|
291
|
+
if (!changed) continue;
|
|
292
|
+
entries[part] = encoder.encode(xml);
|
|
293
|
+
entries[relsPart] = encoder.encode(rels);
|
|
294
|
+
}
|
|
295
|
+
if (overrides.length) entries['[Content_Types].xml'] = encoder.encode(text(entries['[Content_Types].xml']).replace('</Types>', `${overrides.join('')}</Types>`));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Import
|
|
300
|
+
|
|
301
|
+
const asArray = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
302
|
+
const scalar = value => typeof value === 'string' ? value : value && typeof value === 'object' ? String(value['#text'] ?? '') : value === undefined ? '' : String(value);
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Read a parsed cx:chartSpace (fast-xml-parser, prefixes kept, attributes as
|
|
306
|
+
* plain keys) back to an OPF chart. Returns null when no cx:series names a
|
|
307
|
+
* kept OPF chart type (sunburst has no OPF record). `heading` is the category
|
|
308
|
+
* heading recovered from the workbook, `limits` the cache bounds
|
|
309
|
+
* ({points, cells}) and `invalid(message, path)` throws the import error.
|
|
310
|
+
*/
|
|
311
|
+
export function chartFromChartex(doc, {heading, limits, invalid, path}) {
|
|
312
|
+
const space = doc?.['cx:chartSpace'];
|
|
313
|
+
const region = space?.['cx:chart']?.['cx:plotArea']?.['cx:plotAreaRegion'];
|
|
314
|
+
const allSeries = asArray(region?.['cx:series']);
|
|
315
|
+
const type = chartTypeFromChartex(allSeries.map(series => series?.layoutId));
|
|
316
|
+
if (!type) return null;
|
|
317
|
+
const dataById = new Map(asArray(space?.['cx:chartData']?.['cx:data']).map(data => [String(data?.id ?? ''), data]));
|
|
318
|
+
const plotted = allSeries.filter(series => series?.ownerIdx === undefined && series?.['cx:dataId']?.val !== undefined);
|
|
319
|
+
if (!plotted.length) return null;
|
|
320
|
+
const integer = (raw, limit, location) => {
|
|
321
|
+
if (typeof raw !== 'string' || !/^[+-]?\d+$/.test(raw.trim())) invalid('expected an unsigned integer', location);
|
|
322
|
+
const value = Number(raw.trim());
|
|
323
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > limit) invalid(`integer exceeds the supported range 0–${limit}`, location);
|
|
324
|
+
return value;
|
|
325
|
+
};
|
|
326
|
+
let cells = 0;
|
|
327
|
+
const level = (dimension, location, numeric) => {
|
|
328
|
+
const lvl = asArray(dimension?.['cx:lvl'])[0];
|
|
329
|
+
if (!lvl) return [];
|
|
330
|
+
const count = lvl.ptCount === undefined ? undefined : integer(lvl.ptCount, limits.points, `${location}/cx:lvl@ptCount`);
|
|
331
|
+
const points = asArray(lvl['cx:pt']);
|
|
332
|
+
if (points.length > limits.points) invalid('too many points', location);
|
|
333
|
+
let extent = count ?? 0;
|
|
334
|
+
const indexed = new Map();
|
|
335
|
+
for (const [position, point] of points.entries()) {
|
|
336
|
+
const at = `${location}/cx:lvl/cx:pt[${position}]@idx`;
|
|
337
|
+
const index = integer(point?.idx, limits.points - 1, at);
|
|
338
|
+
if (count !== undefined && index >= count) invalid('point index is outside its declared count', at);
|
|
339
|
+
if (indexed.has(index)) invalid('duplicate point index', at);
|
|
340
|
+
const value = scalar(point);
|
|
341
|
+
if (numeric) {
|
|
342
|
+
const parsed = value.trim() === '' ? null : Number(value);
|
|
343
|
+
if (parsed !== null && !Number.isFinite(parsed)) invalid('numeric point is not a finite number', at);
|
|
344
|
+
indexed.set(index, parsed);
|
|
345
|
+
} else indexed.set(index, value);
|
|
346
|
+
extent = Math.max(extent, index + 1);
|
|
347
|
+
}
|
|
348
|
+
cells += extent;
|
|
349
|
+
if (cells > limits.cells) invalid('combined caches exceed the 1,000,000-cell import limit', location);
|
|
350
|
+
const result = new Array(extent).fill(null);
|
|
351
|
+
for (const [index, value] of indexed) result[index] = value;
|
|
352
|
+
return result;
|
|
353
|
+
};
|
|
354
|
+
let labels = null;
|
|
355
|
+
const names = [], values = [];
|
|
356
|
+
plotted.forEach((series, index) => {
|
|
357
|
+
const data = dataById.get(String(series['cx:dataId'].val));
|
|
358
|
+
if (!data) invalid('series data is missing', `${path}#cx:series[${index}]/cx:dataId`);
|
|
359
|
+
const location = `${path}#cx:data[@id="${series['cx:dataId'].val}"]`;
|
|
360
|
+
const categories = asArray(data['cx:strDim']).find(dimension => dimension?.type === 'cat');
|
|
361
|
+
if (categories && !labels) labels = level(categories, `${location}/cx:strDim`, false);
|
|
362
|
+
const numeric = asArray(data['cx:numDim'])[0];
|
|
363
|
+
values.push(numeric ? level(numeric, `${location}/cx:numDim`, true) : []);
|
|
364
|
+
const name = series['cx:tx']?.['cx:txData']?.['cx:v'];
|
|
365
|
+
names.push(name === undefined ? `Series ${index + 1}` : scalar(name));
|
|
366
|
+
});
|
|
367
|
+
const rowCount = values.reduce((count, row) => Math.max(count, row.length), labels?.length ?? 0);
|
|
368
|
+
if (rowCount === 0) return null;
|
|
369
|
+
if (rowCount * (values.length + (labels ? 1 : 0)) > limits.cells) invalid('chart cache exceeds the 1,000,000-cell import limit', path);
|
|
370
|
+
// A histogram or Pareto chart binned from one value column has no category dimension: the column comes back as authored.
|
|
371
|
+
if (!labels) return {type, data: {columns: [names[0]], rows: values[0].slice(0, rowCount).map((value, index) => [values[0][index] ?? null])}};
|
|
372
|
+
const rows = [];
|
|
373
|
+
for (let index = 0; index < rowCount; index += 1) rows.push([labels[index] ?? null, ...values.map(row => row[index] ?? null)]);
|
|
374
|
+
return {type, data: {columns: [heading ?? 'Category', ...names], rows}};
|
|
375
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -39,13 +39,28 @@ export interface MediaProvenanceDiagnostic { code: "media-provenance-omitted"; p
|
|
|
39
39
|
export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"; path: string; message: string; reason: "data-not-inline" | "no-rows" | "no-columns" | "single-column-not-numeric" }
|
|
40
40
|
/** Content with no PowerPoint export (an empty table, an unsupported payload) is replaced by a plain-language placeholder frame. */
|
|
41
41
|
export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; path: string; message: string; reason: "table-has-no-rows" | "unsupported-payload" }
|
|
42
|
-
/** The chart data was reshaped to export a native chart: a histogram's values were binned, or a single value column was plotted against row numbers. */
|
|
43
42
|
export interface WatermarkNotExportedDiagnostic { code: "watermark-not-exported"; path: string; message: string }
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The chart data was reshaped to export a native chart: a single value column was plotted against row numbers; a one-series construct
|
|
45
|
+
* (pie, doughnut, and with `chartex: "native"` treemap, histogram, pareto, waterfall, funnel, map) kept only its first series; by default
|
|
46
|
+
* a chartex chart type was written as a clustered column chart (`chartex-fallback`) and a one-column histogram was binned into counts
|
|
47
|
+
* (`histogram-binned`).
|
|
48
|
+
*/
|
|
49
|
+
export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" | "series-dropped" | "chartex-fallback" }
|
|
50
|
+
/** With `chartex: "native"`, a map chart (`world`) is exported as a chartex regionMap without cached geography: PowerPoint fetches the region shapes from Bing Maps online, so the map may show no regions offline. */
|
|
51
|
+
export interface ChartMapGeodataDiagnostic { code: "chart-map-geodata"; path: string; message: string }
|
|
45
52
|
|
|
46
53
|
export interface ToPptxOptions {
|
|
47
54
|
/** Default compatible converts WebP to a static PNG. Preserve embeds original WebP bytes. */
|
|
48
55
|
imageFormat?: "compatible" | "preserve";
|
|
56
|
+
/**
|
|
57
|
+
* How the chartex chart types (treemap, histogram, pareto, box-and-whisker, waterfall, funnel, world) are exported.
|
|
58
|
+
* "fallback" (default) writes the clustered column chart of the data and reports `chart-data-adapted` (`chartex-fallback`,
|
|
59
|
+
* `histogram-binned`). "native" writes Office 2016 chartex parts (`cx:chartSpace`, style parts, an `mc:AlternateContent`
|
|
60
|
+
* frame with the clustered column chart as fallback). Native chartex is opt-in pending native PowerPoint confirmation (FF-22b);
|
|
61
|
+
* the default flips once that check passes. Import of chartex parts is always on.
|
|
62
|
+
*/
|
|
63
|
+
chartex?: "native" | "fallback";
|
|
49
64
|
/**
|
|
50
65
|
* OPF_DOCUMENT_V1 / OPF_SLIDE_V1 customer-data tags that let fromPptx restore
|
|
51
66
|
* catalog references, layout ids and authoring metadata (docs/document-roundtrip.md).
|
|
@@ -58,7 +73,7 @@ export interface ToPptxOptions {
|
|
|
58
73
|
/** Match preview/pagination clearance around supplied vector text outlines; default 1. */
|
|
59
74
|
textRasterPadding?: number;
|
|
60
75
|
/** Layout diagnostics, `media-provenance-omitted` when video data cannot be stored, plus `unresolved-font-scheme` (once per reference path) when a font-scheme id matches no record and the default `aptos` scheme is used as the base. */
|
|
61
|
-
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ContentPlaceholderDiagnostic | WatermarkNotExportedDiagnostic) => void;
|
|
76
|
+
onDiagnostic?: (diagnostic: LayoutDiagnostic | FontSchemeDiagnostic | MediaProvenanceDiagnostic | ChartDataUnplottableDiagnostic | ChartDataAdaptedDiagnostic | ChartMapGeodataDiagnostic | ContentPlaceholderDiagnostic | WatermarkNotExportedDiagnostic) => void;
|
|
62
77
|
baseDir?: string;
|
|
63
78
|
compressionLevel?: number;
|
|
64
79
|
imageResolver?: (src: string, context: ImageResolverContext) => ImageResolverResult | Promise<ImageResolverResult | null | undefined> | null | undefined;
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import {importTableFrames} from './table-import.js';
|
|
|
4
4
|
import {applyChartFonts, applyPitchFamilies, finalizeFontsUsed, fontPitchFamilies} from './package-fonts.js';
|
|
5
5
|
import {readChartCategoryHeading,writeChartCategoryHeading} from './chart-workbook.js';
|
|
6
6
|
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
7
|
+
import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
|
|
7
8
|
import {attachCodeTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
8
9
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
9
10
|
import {attachCardTags,importCardFrames} from './card-provenance.js';
|
|
@@ -224,7 +225,14 @@ export async function toPptx(input, options = {}) {
|
|
|
224
225
|
context.metricTags = new Map();
|
|
225
226
|
context.chartHeadings = new Map();
|
|
226
227
|
context.chartFonts = new Map();
|
|
228
|
+
context.chartex = new Map();
|
|
227
229
|
context.imageFormat = options.imageFormat ?? "compatible";
|
|
230
|
+
// Native chartex parts are opt-in until the native PowerPoint check confirms them (FF-22b); the default keeps the
|
|
231
|
+
// clustered column export of the chartex chart types.
|
|
232
|
+
if (options.chartex !== undefined && !['native', 'fallback'].includes(options.chartex)) {
|
|
233
|
+
throw new OPFPptxError('invalid-chartex-mode', 'chartex must be native or fallback.', {path: 'options.chartex'});
|
|
234
|
+
}
|
|
235
|
+
context.chartexMode = options.chartex ?? 'fallback';
|
|
228
236
|
Object.assign(context, exportTheme(presentation, context));
|
|
229
237
|
context.reportedFontSchemes = new Set();
|
|
230
238
|
// Document references and metadata tags (FF-32, docs/document-roundtrip.md).
|
|
@@ -613,6 +621,15 @@ function collectSlideItems(entries, slideRoot, slidePath, relationships, dimensi
|
|
|
613
621
|
const item = importGraphicFrame(entries, frame, slidePath, relationships, tables[index]);
|
|
614
622
|
if (item) items.push(item);
|
|
615
623
|
}
|
|
624
|
+
// A chartex chart is an mc:AlternateContent: the choice frame references the cx:chartSpace part; the fallback is a classic chart frame
|
|
625
|
+
// (this exporter) or a text shape (PowerPoint). The choice is read first and the fallback only when it names no OPF chart type.
|
|
626
|
+
for (const alternate of asArray(tree?.["mc:AlternateContent"])) {
|
|
627
|
+
const choice = asArray(alternate?.["mc:Choice"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
628
|
+
const fallback = asArray(alternate?.["mc:Fallback"]).map((node) => node?.["p:graphicFrame"]).find(Boolean);
|
|
629
|
+
const chosen = choice ? importGraphicFrame(entries, choice, slidePath, relationships) : null;
|
|
630
|
+
const item = chosen?.payload?.type === "chart" ? chosen : fallback ? importGraphicFrame(entries, fallback, slidePath, relationships) : chosen;
|
|
631
|
+
if (item && item.kind !== "unknown") items.push(item);
|
|
632
|
+
}
|
|
616
633
|
|
|
617
634
|
for (const [index, picture] of nativeContext.pictures.entries()) {
|
|
618
635
|
if (furniture.pictures.has(index) || nativeContext.slideImagePictures?.has(index) || nativeContext.watermarkPictures?.has(index)) continue;
|
|
@@ -668,9 +685,12 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
668
685
|
};
|
|
669
686
|
}
|
|
670
687
|
|
|
671
|
-
const
|
|
688
|
+
const chartExRelId = graphicData?.uri === CHARTEX_GRAPHIC_DATA_URI ? graphicData?.["cx:chart"]?.["r:id"] : undefined;
|
|
689
|
+
const chartRelId = graphicData?.["c:chart"]?.["r:id"] ?? chartExRelId;
|
|
672
690
|
if (chartRelId) {
|
|
673
|
-
const chart =
|
|
691
|
+
const chart = chartExRelId
|
|
692
|
+
? chartexFromRelationship(entries, relationships, chartExRelId)
|
|
693
|
+
: chartFromRelationship(entries, slidePath, relationships, chartRelId);
|
|
674
694
|
return {
|
|
675
695
|
kind: "chart",
|
|
676
696
|
bounds,
|
|
@@ -904,6 +924,23 @@ function scatterFromSeries(entries, chartPart, series, budget, cachePath) {
|
|
|
904
924
|
return { type: "scatter", data: { columns: ["Point", readChartCategoryHeading(entries, chartPart) ?? "X", ...names], rows } };
|
|
905
925
|
}
|
|
906
926
|
|
|
927
|
+
// A chartex part (cx:chartSpace): the series layoutIds name the kept OPF chart
|
|
928
|
+
// type and the cached dimensions restore the category-major data, under the
|
|
929
|
+
// same bounds as the classic chart cache.
|
|
930
|
+
function chartexFromRelationship(entries, relationships, relId) {
|
|
931
|
+
const relationship = relationships.get(relId);
|
|
932
|
+
if (!relationship?.path || !entries[relationship.path]) return null;
|
|
933
|
+
const doc = parseRequiredXml(entries, relationship.path);
|
|
934
|
+
return chartFromChartex(doc, {
|
|
935
|
+
heading: readChartCategoryHeading(entries, relationship.path),
|
|
936
|
+
limits: {points: MAX_CHART_CACHE_POINTS, cells: MAX_CHART_CACHE_CELLS},
|
|
937
|
+
path: relationship.path,
|
|
938
|
+
invalid: (message, location) => {
|
|
939
|
+
throw new OPFPptxError('invalid-chart-cache', `Invalid chart cache: ${message}. Repair the chart data before importing.`, {path: location ?? relationship.path});
|
|
940
|
+
}
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
|
|
907
944
|
function firstChartNode(plotArea) {
|
|
908
945
|
// Map the native construct back to the kept OPF chart type id (FF-22).
|
|
909
946
|
for (const element of NATIVE_CHART_ELEMENTS) {
|
|
@@ -1614,7 +1651,7 @@ async function addSlideImage(slide, presentation, image, slideIndex, context, op
|
|
|
1614
1651
|
}
|
|
1615
1652
|
|
|
1616
1653
|
function addChartPayload(slide, chart, region, context, options = {}, path = "chart") {
|
|
1617
|
-
const chartData = toPptxChartData(chart);
|
|
1654
|
+
const chartData = toPptxChartData(chart, context.chartexMode);
|
|
1618
1655
|
if (!chartData.series) {
|
|
1619
1656
|
// Never lose a chart silently: the placeholder frame stands in for it, and a diagnostic names the reason.
|
|
1620
1657
|
options.onDiagnostic?.({code: "chart-data-unplottable", path, message: chartData.message, reason: chartData.reason});
|
|
@@ -1633,6 +1670,16 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1633
1670
|
const circular = chartData.type === 'pie' || chartData.type === 'doughnut';
|
|
1634
1671
|
context.chartHeadings.set(objectName,{heading:chartData.type === 'scatter' ? undefined : chartData.heading ?? chart.data.columns[0],labelColor,spec:chartData.spec});
|
|
1635
1672
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
1673
|
+
const palette = CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`)));
|
|
1674
|
+
if (chartData.chartex) {
|
|
1675
|
+
// The native chartex part is added when the package is normalized (attachChartexParts); the classic chart below becomes its fallback.
|
|
1676
|
+
// PptxGenJS rewrites the series it is given (labels become nested levels), so the chartex part keeps its own copy.
|
|
1677
|
+
const series = chartData.series.map((entry) => ({name: entry.name, labels: [...entry.labels], values: [...entry.values]}));
|
|
1678
|
+
context.chartex.set(objectName, {spec: chartData.chartex, series, hasCategories: chartData.hasCategories, fill, labelColor, gridColor: context.colors.border, font: context.fonts.body, palette});
|
|
1679
|
+
if (chartData.chartex.layoutId === 'regionMap') {
|
|
1680
|
+
options.onDiagnostic?.({code: "chart-map-geodata", path, message: `The '${stringifyText(chart.type)}' chart is exported as a native PowerPoint map (chartex regionMap) without cached geography: PowerPoint matches the category names to regions and fetches their shapes from Bing Maps when the deck is opened online, so the map shows no regions offline or where Office map data is disabled. The clustered column fallback shows the same values in other readers.`});
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1636
1683
|
const percent = chartData.spec.grouping === 'percentStacked';
|
|
1637
1684
|
slide.addChart(chartData.type, chartData.series, {
|
|
1638
1685
|
objectName,
|
|
@@ -1642,7 +1689,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
1642
1689
|
h: region.h,
|
|
1643
1690
|
showLegend: circular || chartData.series.length > (chartData.type === 'scatter' ? 2 : 1),
|
|
1644
1691
|
showTitle: false,
|
|
1645
|
-
chartColors:
|
|
1692
|
+
chartColors: palette,
|
|
1646
1693
|
chartArea: {fill:{...fill},roundedCorners:false},
|
|
1647
1694
|
// Paint alpha once in the chart area, rather than stacking two alpha fills.
|
|
1648
1695
|
plotArea: {fill:{color:null}},
|
|
@@ -2164,7 +2211,7 @@ function textRuns(value, context, fallbackFontSize) {
|
|
|
2164
2211
|
* into equal-width bins and exported as a column chart of the counts, every other type plots the values against
|
|
2165
2212
|
* their row numbers. `adapted` names that transformation so it is reported, never silent.
|
|
2166
2213
|
*/
|
|
2167
|
-
function toPptxChartData(chart) {
|
|
2214
|
+
function toPptxChartData(chart, chartexMode = 'fallback') {
|
|
2168
2215
|
const data = chart?.data;
|
|
2169
2216
|
const unplottable = (reason, summary, message) => ({reason, summary, message: `${message} No native chart was exported; a placeholder frame stands in for it.`});
|
|
2170
2217
|
if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) {
|
|
@@ -2173,15 +2220,19 @@ function toPptxChartData(chart) {
|
|
|
2173
2220
|
if (data.rows.length === 0) return unplottable("no-rows", "The chart has no data rows.", "The chart data has no rows.");
|
|
2174
2221
|
if (data.columns.length === 0) return unplottable("no-columns", "The chart has no data columns.", "The chart data has no columns.");
|
|
2175
2222
|
const resolved = resolveChartType(chart.type).spec;
|
|
2176
|
-
//
|
|
2177
|
-
|
|
2178
|
-
|
|
2223
|
+
// A chartex type (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) has no classic construct. With
|
|
2224
|
+
// `chartex: 'native'` it is written as its cx:chartSpace part and the clustered column chart of the same data is its
|
|
2225
|
+
// mc:Fallback (src/chartex.js); by default (pending the native PowerPoint check, FF-22b) it exports as the clustered
|
|
2226
|
+
// column chart alone and reports chartex-fallback, never silently.
|
|
2227
|
+
const native = chartexMode === 'native';
|
|
2228
|
+
const chartex = resolved.family === 'chartex' && native ? resolved : null;
|
|
2229
|
+
const spec = resolved.family === 'chartex' ? CHARTEX_FALLBACK : resolved;
|
|
2179
2230
|
const typeName = stringifyText(chart.type);
|
|
2180
2231
|
const adaptations = [];
|
|
2181
|
-
if (chartex) {
|
|
2232
|
+
if (resolved.family === 'chartex' && !native) {
|
|
2182
2233
|
adaptations.push({adaptation: "chartex-fallback", message: `The '${typeName}' chart is a PowerPoint extension (chartex) chart that this exporter does not write; its data is exported as a native clustered column chart instead.`});
|
|
2183
2234
|
}
|
|
2184
|
-
const mapped = {type: spec.pptx, spec, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
|
|
2235
|
+
const mapped = {type: spec.pptx, spec, chartex, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined};
|
|
2185
2236
|
if (data.columns.length === 1) {
|
|
2186
2237
|
const heading = stringifyText(data.columns[0]);
|
|
2187
2238
|
const cell = (row) => Array.isArray(row) ? row[0] : row;
|
|
@@ -2192,7 +2243,7 @@ function toPptxChartData(chart) {
|
|
|
2192
2243
|
}
|
|
2193
2244
|
const skipped = data.rows.length - points.length;
|
|
2194
2245
|
const skippedNote = skipped ? ` (${skipped} non-numeric ${skipped === 1 ? "cell was" : "cells were"} skipped)` : "";
|
|
2195
|
-
if (String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2246
|
+
if (!native && String(chart.type ?? "").toLowerCase() === "histogram") {
|
|
2196
2247
|
const bins = histogramBins(points.map((point) => point.value));
|
|
2197
2248
|
return {
|
|
2198
2249
|
type: "bar", spec: CHARTEX_FALLBACK, barDir: "col", barGrouping: "clustered", heading: "Bin",
|
|
@@ -2209,8 +2260,10 @@ function toPptxChartData(chart) {
|
|
|
2209
2260
|
const series = mapped.type === "scatter"
|
|
2210
2261
|
? [{name: "Row", labels, values: points.map((point) => point.row)}, {name: heading, labels, values: numbers}]
|
|
2211
2262
|
: [{name: heading, labels, values: numbers}];
|
|
2263
|
+
// A native histogram or Pareto chart bins the values themselves (PowerPoint's automatic bins); only its classic fallback plots them against row numbers.
|
|
2264
|
+
if (chartex?.binning) return {...mapped, series, heading: "Row", hasCategories: false, adaptations};
|
|
2212
2265
|
adaptations.push({adaptation: "row-numbers", message: `The chart's single data column '${heading}' has no category column, so its ${points.length} values${skippedNote} are plotted against their row numbers.`});
|
|
2213
|
-
return {...mapped, series, heading: "Row", adaptations};
|
|
2266
|
+
return {...mapped, series, heading: "Row", hasCategories: true, adaptations};
|
|
2214
2267
|
}
|
|
2215
2268
|
|
|
2216
2269
|
const labels = data.rows.map((row) => stringifyText(row?.[0]));
|
|
@@ -2219,18 +2272,19 @@ function toPptxChartData(chart) {
|
|
|
2219
2272
|
labels,
|
|
2220
2273
|
values: data.rows.map((row) => numericValue(row?.[seriesIndex + 1]))
|
|
2221
2274
|
}));
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2275
|
+
const plotted = spec.family === 'circular' ? 1 : chartex ? chartex.series : Infinity;
|
|
2276
|
+
if (series.length > plotted) {
|
|
2277
|
+
// A pie, doughnut or single-series chartex construct plots one series; name the ones left out instead of dropping them silently.
|
|
2278
|
+
const dropped = series.slice(plotted).map((entry) => `'${entry.name}'`);
|
|
2225
2279
|
adaptations.push({adaptation: "series-dropped", message: `The ${typeName} chart plots one series, so its first series '${series[0].name}' is exported and the other ${dropped.length} (${dropped.join(", ")}) ${dropped.length === 1 ? "is" : "are"} not.`});
|
|
2226
|
-
series = series.slice(0,
|
|
2280
|
+
series = series.slice(0, plotted);
|
|
2227
2281
|
}
|
|
2228
2282
|
if (spec.family === 'xy' && series.length === 1) {
|
|
2229
2283
|
// [Point, X, Y...] carries its own X column; a lone value column is plotted against the row numbers.
|
|
2230
2284
|
series = [{name: 'X', labels, values: data.rows.map((_, index) => index + 1)}, ...series];
|
|
2231
2285
|
adaptations.push({adaptation: "row-numbers", message: `The scatter chart has no X column (a point label column and one value column), so its ${data.rows.length} values are plotted against their row numbers.`});
|
|
2232
2286
|
}
|
|
2233
|
-
return {
|
|
2287
|
+
return {...mapped, series, hasCategories: true, adaptations};
|
|
2234
2288
|
}
|
|
2235
2289
|
|
|
2236
2290
|
|
|
@@ -2238,6 +2292,7 @@ function toPptxChartData(chart) {
|
|
|
2238
2292
|
* Equal-width bins (Sturges' count, at most 50) from the minimum to the maximum; every bin but the last is [low, high).
|
|
2239
2293
|
* Arithmetic stays finite for any finite input (values of +-1e308 and denormals included): the bin edges are
|
|
2240
2294
|
* computed by interpolating the edges rather than from a width, and a value is placed by comparing it with the edges.
|
|
2295
|
+
* Used by the default (fallback) histogram export only; the native chartex histogram bins in PowerPoint.
|
|
2241
2296
|
*/
|
|
2242
2297
|
function histogramBins(values) {
|
|
2243
2298
|
const min = values.reduce((a, b) => Math.min(a, b)), max = values.reduce((a, b) => Math.max(a, b));
|
|
@@ -2253,7 +2308,7 @@ function histogramBins(values) {
|
|
|
2253
2308
|
// Labels carry six significant digits, and more when that would make two bins read alike.
|
|
2254
2309
|
for (let precision = 6; ; precision++) {
|
|
2255
2310
|
const format = (value) => String(Number(value.toPrecision(precision)));
|
|
2256
|
-
const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}
|
|
2311
|
+
const labels = bins.map((bin) => min === max ? format(min) : `${format(bin.low)}–${format(bin.high)}`);
|
|
2257
2312
|
if (new Set(labels).size === labels.length) return bins.map((bin, index) => ({...bin, label: labels[index]}));
|
|
2258
2313
|
if (precision === 17) return bins.map((bin, index) => ({...bin, label: `${labels[index]} (bin ${index + 1})`}));
|
|
2259
2314
|
}
|
|
@@ -2486,6 +2541,12 @@ async function normalizePptxZip(raw, context) {
|
|
|
2486
2541
|
}
|
|
2487
2542
|
}
|
|
2488
2543
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
2544
|
+
// Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
|
|
2545
|
+
try {
|
|
2546
|
+
attachChartexParts(entries, context.chartex, parseRelationships);
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
throw new OPFPptxError('packaging-failed', `Chartex chart parts could not be attached: ${errorMessage(error)}`);
|
|
2549
|
+
}
|
|
2489
2550
|
applyPitchFamilies(entries,context.fontPitch);
|
|
2490
2551
|
const imageSources = new Map();
|
|
2491
2552
|
for (const [part, bytes] of Object.entries(entries)) {
|
|
@@ -2535,7 +2596,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
2535
2596
|
if (!slide) continue;
|
|
2536
2597
|
context.partSlides.set(part, Number(slide[1]) - 1);
|
|
2537
2598
|
for (const relationship of parseRelationships(entries, part).values()) {
|
|
2538
|
-
if (/\/(?:chart|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
|
|
2599
|
+
if (/\/(?:chart|chartEx|notesSlide)$/.test(relationship.type)) context.partSlides.set(relationship.path, Number(slide[1]) - 1);
|
|
2539
2600
|
}
|
|
2540
2601
|
}
|
|
2541
2602
|
const output = {};
|
package/dist/script-fonts.js
CHANGED
|
@@ -158,7 +158,7 @@ export function partScriptFonts(path, xml, plan, slideIndex) {
|
|
|
158
158
|
xml = xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape =>
|
|
159
159
|
runScriptFonts(shape, resolved, /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape)));
|
|
160
160
|
if (plan.rtl) xml = paragraphRtl(xml, plan.deck.direction);
|
|
161
|
-
} else if (/^ppt\/(?:charts\/chart
|
|
161
|
+
} else if (/^ppt\/(?:charts\/chart(?:Ex)?|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
|
|
162
162
|
xml = runScriptFonts(xml, resolved, false);
|
|
163
163
|
if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction);
|
|
164
164
|
} else if (plan.rtl && /^ppt\/(?:slideMasters\/slideMaster\d+|slideLayouts\/slideLayout\d+|notesMasters\/notesMaster\d+|presentation)\.xml$/.test(path)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.3",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"sideEffects": false,
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node scripts/build.mjs",
|
|
40
|
-
"typecheck": "node --check src/index.js && node --check src/chart-types.js && node --check test/chart-types.mjs && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check test/table-theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check src/watermark-provenance.js && node --check src/media-dedupe.js && node --check test/media-dedupe.mjs && node --check test/watermark.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check test/document-provenance.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs",
|
|
41
|
-
"test": "npm run build && node test/chart-types.mjs && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/watermark.mjs && node test/media-dedupe.mjs && node test/document-provenance.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/table-theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && npm run test:timeline && npm run test:determinism",
|
|
40
|
+
"typecheck": "node --check src/index.js && node --check src/chart-types.js && node --check src/chartex.js && node --check test/chartex.mjs && node --check test/chart-types.mjs && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check test/table-theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check src/watermark-provenance.js && node --check src/media-dedupe.js && node --check test/media-dedupe.mjs && node --check test/watermark.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check test/document-provenance.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs",
|
|
41
|
+
"test": "npm run build && node test/chart-types.mjs && node test/chartex.mjs && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/watermark.mjs && node test/media-dedupe.mjs && node test/document-provenance.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/table-theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && npm run test:timeline && npm run test:determinism",
|
|
42
42
|
"validate": "node scripts/validate-package.mjs",
|
|
43
43
|
"test:packed": "node test/packed-install.mjs",
|
|
44
44
|
"test:compare-published": "node test/compare-published.mjs",
|