@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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Coordinated `@openpresentation/opf` pin
4
4
 
5
- PPTX 0.11.2 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. Native `schemeClr` / theme `clrScheme` writes remain follow-up work.
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. Chartex types (treemap, histogram, pareto, box & whisker, waterfall, funnel, map) still export as clustered columns and report `chart-data-adapted` (`chartex-fallback`); a pie or doughnut chart 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`: 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. PowerPoint's own histogram chart is not exported (histogram is a column chart at this engine). Cells parse as in every chart ("12%", "$5" and "1,234" count) and cells that hold no number are skipped, not plotted as 0. Binned counts do not round-trip: re-importing the file gives a `Bin`/`Frequency` column chart, not the raw values. 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.
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`):
@@ -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
- treemap: { family: 'chartex', layoutId: 'treemap', aspose: 'Treemap' },
33
- histogram: { family: 'chartex', layoutId: 'clusteredColumn', aspose: 'Histogram' },
34
- pareto: { family: 'chartex', layoutId: 'clusteredColumn', aspose: 'ParetoLine' },
35
- 'box-and-whisker': { family: 'chartex', layoutId: 'boxWhisker', aspose: 'BoxAndWhisker' },
36
- waterfall: { family: 'chartex', layoutId: 'waterfall', aspose: 'Waterfall' },
37
- funnel: { family: 'chartex', layoutId: 'funnel', aspose: 'Funnel' },
38
- world: { family: 'chartex', layoutId: 'regionMap', aspose: 'Map' },
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
- // A chartex id (treemap, histogram, ...) has no classic construct; the exporter
42
- // writes it as a clustered column chart and reports chart-data-adapted.
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.
@@ -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
- const ref=relationship(entries,chartPart,chart?.externalData?.id);
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
- const category=find(chart,'cat')[0]??find(chart,'xVal')[0],formula=category?.strRef?.f??category?.multiLvlStrRef?.f??category?.numRef?.f;
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));
@@ -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 => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'}[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
- export interface ChartDataAdaptedDiagnostic { code: "chart-data-adapted"; path: string; message: string; adaptation: "histogram-binned" | "row-numbers" }
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 chartRelId = graphicData?.["c:chart"]?.["r:id"];
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 = chartFromRelationship(entries, slidePath, relationships, chartRelId);
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: CHART_COLORS.map(color=>normalizeHex(chartColorForFill(panelFill,`#${color}`))),
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
- // Chartex types (treemap, box & whisker, waterfall, ...) have no classic construct here: they export as a clustered column chart, never silently.
2177
- const chartex = resolved.family === 'chartex';
2178
- const spec = chartex ? CHARTEX_FALLBACK : resolved;
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
- if (spec.family === 'circular' && series.length > 1) {
2223
- // A pie or doughnut plots one series; name the ones left out instead of dropping them silently.
2224
- const dropped = series.slice(1).map((entry) => `'${entry.name}'`);
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, 1);
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 {type: spec.pptx, spec, series, barDir: spec.barDir, barGrouping: spec.pptx === 'bar' || spec.pptx === 'area' ? spec.grouping : undefined, adaptations};
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)}\u2013${format(bin.high)}`);
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 = {};
@@ -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|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
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.2",
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",