@openpresentation/opf-pptx 0.11.2 → 0.11.4

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.4 depends on published [`@openpresentation/opf@^0.11.3`](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.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). 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
 
@@ -24,7 +24,7 @@ Version 0.8.0 also measures `design.contentBox` cards through core's padded inte
24
24
 
25
25
  ## Scope
26
26
 
27
- Version 0.8.0 requires core 0.10.0 and uses renderer 0.8.0 for coordinated preview/font measurement. Quotes and code export their accepted internal lines and styles without another fitting pass. [Controlled Windows PowerPoint quote evidence](docs/evidence/shared-quote-integration/comparison.json) records glyph containment, separation, save/reopen and text reimport against its exact source/font hashes. Native quote import returns editable text blocks and does not restore the original OPF quote structure, typography or readability policy. Native chart geometry and general scalar-text wrapping also remain different from preview; editability and valid reimport do not establish raster equivalence.
27
+ Version 0.8.0 requires core 0.10.0 and uses renderer 0.8.0 for coordinated preview/font measurement. Quotes and code export their accepted internal lines and styles without another fitting pass. [Controlled Windows PowerPoint quote evidence](docs/evidence/shared-quote-integration/comparison.json) records glyph containment, separation, save/reopen and text reimport against its exact source/font hashes. Native quote import restores the quote payload from an unchanged export (see [Quote provenance](#quote-provenance-ff-57)); it does not restore the original typography or readability policy, and an edited or damaged quote imports as editable text blocks. Native chart geometry and general scalar-text wrapping also remain different from preview; editability and valid reimport do not establish raster equivalence.
28
28
 
29
29
  - Package: `@openpresentation/opf-pptx`
30
30
  - Repository: `OpenPresentation/opf-pptx`
@@ -80,12 +80,12 @@ 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
- - 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.
88
- - `checkPptxTypefaces(bytes, {fonts, monospace})` inventories every `typeface`, workbook font name and "Fonts Used" entry in every XML part, including nested packages, and reports each font outside that policy. `inventoryPptxTypefaces()` returns the raw inventory.
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 unless a script font was selected for that slot (see [Languages, right-to-left text and script fonts](#languages-right-to-left-text-and-script-fonts)).
88
+ - `checkPptxTypefaces(bytes, {fonts, monospace, themeScripts})` (optional `themeScripts: {major: {ea, cs}, minor: {ea, cs}}` names the East Asian / complex-script families the author selected; a selected theme slot that is empty or different is reported as `theme-script-slot`, an unselected one may stay empty) inventories every `typeface`, workbook font name and "Fonts Used" entry in every XML part, including nested packages, and reports each font outside that policy. `inventoryPptxTypefaces()` returns the raw inventory.
89
89
 
90
90
  This pass did not require an OPF schema change. The deferred full OOXML placeholder mapping from `docs/plans/layout-placeholders.md` remains a later hand-written OOXML emitter concern.
91
91
 
@@ -101,6 +101,12 @@ This intentionally tightens the previous host-dependent `Date` parsing contract.
101
101
 
102
102
  ## v1 Import Mapping
103
103
 
104
+ ### Quote provenance (FF-57)
105
+
106
+ A `quote` payload exports as native text lines: the body (the quoted text wrapped in straight quotation marks) and, when there is an attribution or source, one footer (`attribution - source`). Each line shape carries an `OPF_QUOTE_V1` tag that stores only topology (whether the value was the string shorthand, which footer fields exist, where the separator falls, and how many source lines each part has). No quote word is stored: every value is read back from the current native text, so a cleared or edited quote cannot bring back the words it once held.
107
+
108
+ An unchanged export re-imports as `{ "type": "quote", "quote": ... }` exactly: the string shorthand stays a string, `text`/`attribution`/`source` come back with their whitespace, hard line breaks and inner quotation marks, and several quotes on one slide keep their order. Native edits to the body or footer text import as the edited quote. The importer reports `quote-import-reflow` (native formatting, position and font theme are not reconstructed) and `quote-footer-merged` when an edited footer no longer separates attribution from source. A missing, duplicated or reordered line, a changed manifest or a native bullet on a tagged line rejects the group: the shapes import as ordinary text blocks with an `invalid-quote-provenance` diagnostic and no old words are restored. A quote authored without tags (for example in PowerPoint) imports as text. The output stays ordinary editable PowerPoint text boxes; the tags are `p:custDataLst` entries that PowerPoint keeps.
109
+
104
110
  ### New in 0.10.0: current native body formatting
105
111
 
106
112
  Current source imports supported formatting from ordinary untagged native body text and list items. A value that previously imported as a string can now be a rich-run array containing the same current characters with explicit native properties. Unstyled values remain strings; title/subtitle selection and tagged recovery stay separate. Run, field and break order, blank paragraphs, significant whitespace, explicit normal overrides, paragraph/list defaults, point sizes, Latin font families, supported colors/alpha, hyperlinks and script direction come from the current PPTX. Edits, clears and deletion remain authoritative in every provenance mode. Native weight faces of the bundled Roboto family (for example `Roboto SemiBold`) import as `Roboto` plus `bold` for weights of 600 and above. Medium (500) and lighter weights import as regular, because OPF runs have no numeric weight, so a round trip re-exports Medium as Regular; `approximate-body-font-weight` reports this. Authored families such as `Arial Black` or `Aptos Light` keep their names.
@@ -125,12 +131,30 @@ The first importer is mechanical and schema-compatible:
125
131
 
126
132
  There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
127
133
 
134
+ ## Chartex charts (FF-22b)
135
+
136
+ **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.
137
+
138
+ 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.
139
+
140
+ | OPF id | `cx:series layoutId` | Data (category-major) | Notes |
141
+ |---|---|---|---|
142
+ | `treemap` | `treemap` | `[Category, Value]`, first series | one tile per category, category data labels, one palette colour per tile |
143
+ | `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`) |
144
+ | `pareto` | `clusteredColumn` owning a `paretoLine` | as histogram | the Office Pareto chart: sorted columns plus the cumulative-percentage line on a percentage axis |
145
+ | `box-and-whisker` | `boxWhisker` | `[Category, S1, S2, ...]`, every series | rows with the same category form one box per series; exclusive quartiles, mean markers, outliers |
146
+ | `waterfall` | `waterfall` | `[Category, Value]`, first series | connector lines; increases and decreases in the first two palette colours; no subtotals |
147
+ | `funnel` | `funnel` | `[Category, Value]`, first series | value data labels |
148
+ | `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 |
149
+
150
+ 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.
151
+
128
152
  ## Languages, right-to-left text and script fonts
129
153
 
130
154
  The exporter reads the presentation `language` through core `resolveScriptFonts()` (FF-07; the model is core `docs/programs/font-fidelity-everywhere/script-font-model.md`):
131
155
 
132
156
  - Every run, end-of-paragraph and default run property carries `lang` set to the resolved OOXML tag instead of a fixed `en-US`. That tag is the catalog's curated `ooxmlLang` (for example `ja-JP` or `ar-SA`) or an authored region tag such as `en-NZ`. `altLang` is not written: it names the editing-UI language, which OPF does not model.
133
- - For a language that uses an East Asian or complex-script slot (CJK, Arabic, Hebrew, Indic, Thai and others), theme major/minor `a:ea`/`a:cs` name the resolved heading/body fonts instead of the vendored empty values. For a language written in the latin slot (Latin, Cyrillic, Greek and others) they stay empty unless the design font scheme sets `eastAsian`/`complexScript` explicitly. Filling them with the latin family is gated on FF-05: native evidence shows it does not remove the nameless and Aptos entries PowerPoint lists at open, and empty slots keep PowerPoint's per-script theme fallback for CJK or Arabic text typed later. So en-US decks export byte-identically.
157
+ - Theme major/minor `a:ea`/`a:cs` are written only for a slot where a script font is actually selected (FF-49): an explicit `eastAsian`/`complexScript` on the design font scheme, the scheme's own script family, or the language's script font (a language that uses the East Asian slot, such as Japanese, writes `ea`; one that uses the complex-script slot, such as Arabic, Hebrew, Indic or Thai, writes `cs`). Every other slot keeps the vendored empty typeface, exactly as Office's own themes leave it, so PowerPoint picks its per-language default for script text typed later and the package names nothing the author did not select (owner font policy). Latin, Cyrillic, Greek, Armenian, Georgian and Ethiopic decks write neither; the other slot of a CJK or complex-script deck is empty too. The language never changes the theme's latin fonts (Model C): `design.fontScheme` alone sets them. The preview resolves the same slots (opf-render's script profile: the theme's family where it names one, the latin family where it is empty), and `test/theme-script-slots.mjs` checks preview against export for every catalog font scheme.
134
158
  - The theme's per-script entry for the language's own script (for example `Jpan`, `Hang`, `Arab` or `Deva`) names the resolver's supplement. The rest of the vendored Office per-script list is unchanged; that list is FF-08's call.
135
159
  - Run `a:ea`/`a:cs` name the resolved slot when the language or the font scheme supplies a script-specific font, for example Meiryo in `a:ea` for Japanese or Arabic Typesetting in `a:cs` for Arabic. Headings take the heading font and other text the body font. Otherwise the slots keep repeating the run's latin face, so Latin, Cyrillic and Greek decks keep their run bytes.
136
160
  - In a right-to-left deck, each slide and notes paragraph takes its direction from core `paragraphDirection(text, direction)`, the same rule the renderer uses: `rtl="1"` when its first strong character is right-to-left or it has none (digits, punctuation, empty), else an explicit `rtl="0"` (an English quote, a code line). The master, layout and presentation default paragraph levels start right-to-left only in a right-to-left deck. Left-to-right decks write no paragraph direction. Alignment is unchanged: `algn` stays the composed absolute alignment (left stays `l`), so native line placement keeps matching the renderer's geometry. Right-aligning RTL text by default is a composition decision in core; until then, set `contentAlignment` or `titleAlignment` to `right`.
@@ -148,7 +172,7 @@ When the package carries an FF-32 stored `language` ([document round trip](docs/
148
172
 
149
173
  Exports report `language-unresolved` when the document's language cannot be resolved locally (a URL, `pkg:` reference or unknown id) and `en-US` is used.
150
174
 
151
- **Core without the resolver.** Core `@openpresentation/opf` 0.11.0 and earlier have no `resolveScriptFonts` (this release requires ^0.11.2, so this only applies to a forced older core). Export with it is byte-identical to the output before FF-07 (`lang="en-US"`, empty theme `ea`/`cs`, no `rtl`), and a document that names a language gets a `language-export-unavailable` diagnostic. A core with the resolver but without `paragraphDirection` marks no paragraph direction and reports `paragraph-direction-unavailable` for a right-to-left deck. Import then matches run tags against the installed catalog's `bcp47` and primary language. `npm run test:packed` exercises this path against the registry release. CI links core at a pinned commit that has the resolver.
175
+ **Core without the resolver.** Core `@openpresentation/opf` 0.11.0 and earlier have no `resolveScriptFonts` (this release requires ^0.11.3, so this only applies to a forced older core). Export with it is byte-identical to the output before FF-07 (`lang="en-US"`, empty theme `ea`/`cs`, no `rtl`), and a document that names a language gets a `language-export-unavailable` diagnostic. A core with the resolver but without `paragraphDirection` marks no paragraph direction and reports `paragraph-direction-unavailable` for a right-to-left deck. Import then matches run tags against the installed catalog's `bcp47` and primary language. `npm run test:packed` exercises this path against the registry release. CI links core at a pinned commit that has the resolver.
152
176
 
153
177
  ## Runtime Policy
154
178
 
@@ -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
+ }