@openpresentation/opf-pptx 0.12.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,15 +4,17 @@
4
4
 
5
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
- 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.
7
+ Since RR-17 (opf-pptx#162) the vendored engine is [pptxgenjs-plus](https://github.com/lofcz/pptxgenjs-plus) 4.3.4, the maintained MIT fork of PptxGenJS (same public API and default export): `vendor/pptxgenjs/pptxgen.es.js` is the exact, unmodified `package/dist/pptxgen.es.js` of the npm archive, with its MIT license, the archive's SHA-512 integrity, per-file SHA-256 hashes and the source commit of its npm SLSA provenance (`lofcz/pptxgenjs-plus@ecfa5086`) in `UPSTREAM.json`. Its imports are declared directly and pinned: `@node-projects/jszip` 4.3.0 (replacing `jszip` 3.10.2), `@rgrove/parse-xml` 4.2.3 and `pako` 3.0.2. `@node-projects/jszip` declares `@types/node` as a runtime dependency (type declarations, no code), so it is installed too. opf-pptx's own package reading and writing stays on `fflate`. Every output change and how it was measured is in [docs/pptxgenjs-plus-migration.md](docs/pptxgenjs-plus-migration.md); `src/vendor-compat.js` keeps the PptxGenJS 4.0.1 output where opf-pptx's contracts need it.
8
8
 
9
- The inspected PptxGenJS 4.0.1 ESM/CommonJS distributions do not import `image-size`. The only sizing-module reference is in a commented, unused helper. OPF supplies image geometry itself. The image-fit follow-up reads bounded PNG/JPEG/GIF/WebP dimension headers and inline JPEG EXIF orientation; it does not load image-size or decode pixels.
9
+ Before RR-17: OPF PPTX 0.5.1 to 0.12.x ship 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.
10
10
 
11
- `npm test` requires image-size to be absent, then blocks both ESM and CommonJS loading before running the full model suite and checking image-byte preservation for data URIs, local paths and host-resolved bytes. Browser bundling retains upstream's Node built-in exclusions through the vendor directory's package metadata. This is not a guarantee about every upstream PptxGenJS API or future version.
11
+ The inspected PptxGenJS 4.0.1 ESM/CommonJS distributions do not import `image-size`, and neither does pptxgenjs-plus 4.3.4 (no `image-size` dependency). The only sizing-module reference is in a commented, unused helper. OPF supplies image geometry itself. The image-fit follow-up reads bounded PNG/JPEG/GIF/WebP dimension headers and inline JPEG EXIF orientation; it does not load image-size or decode pixels.
12
+
13
+ `npm test` requires image-size to be absent, then blocks both ESM and CommonJS loading before running the full model suite and checking image-byte preservation for data URIs, local paths and host-resolved bytes. Browser bundling retains upstream's Node built-in exclusions through the vendor directory's package metadata (pptxgenjs-plus has no static `node:` import; it loads `fs`, `http` and `https` with a runtime `import()` only in Node, for media given by `path`). This is not a guarantee about every upstream PptxGenJS API or future version.
12
14
 
13
15
  Every build checks the vendored code and license against `UPSTREAM.json`. `node scripts/vendor-pptxgenjs.mjs` is an explicit maintainer command that fetches the pinned official npm archive, verifies its SHA-512 integrity and reads only the named distribution/license entries. It is never run by builds or installations. Review the upstream source, actual imports, license and audit before changing versions; retain tests and reassess [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) and [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq). Do not accept npm's incompatible PptxGenJS downgrade suggestion.
14
16
 
15
- Package normalization removes PptxGenJS 4.0.1 content-type declarations for generated slide-master parts that do not exist, without changing the vendor bytes or existing package parts. The smoke regression requires every override to target an actual part. This ports the independently audited correction from [commit 1363a65](https://github.com/OpenPresentation/opf-pptx/commit/1363a653c8b0e80c50d970bc30db3587bcf6f14e). Its notes-master SDK/native-order disagreement remains separate; the native ordering is unchanged.
17
+ Package normalization removes PptxGenJS 4.0.1 content-type declarations for generated slide-master parts that do not exist (pptxgenjs-plus no longer writes them; the filter stays as a guard), without changing the vendor bytes or existing package parts. The smoke regression requires every override to target an actual part. This ports the independently audited correction from [commit 1363a65](https://github.com/OpenPresentation/opf-pptx/commit/1363a653c8b0e80c50d970bc30db3587bcf6f14e). Its notes-master SDK/native-order disagreement remains separate; the native ordering is unchanged.
16
18
 
17
19
  ## Compatible raster decoding
18
20
 
package/README.md CHANGED
@@ -4,11 +4,13 @@ Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on
4
4
 
5
5
  Chart options (RR-35, unreleased): `axisTitles`, `legend` and `dataLabels` on a chart export as native `c:title`, `c:legendPos` and `c:dLbls` (and `cx:title`, `cx:legend`, `cx:dataLabels` on the Office 2016 constructs) and import back; the preview draws the same options. See core `docs/chart-options.md` for the per-type support table and the `chart-option-adapted` diagnostic.
6
6
 
7
+ Chart and table data (RR-54, unreleased; core `docs/chart-table-data.md`): chart values pass core `chartNumber` (a string that is not a plain decimal, such as `"12%"`, `"$5"` or `"(5)"`, is a gap and one `chart-value-not-numeric` diagnostic per chart, where the exporter used to strip it to a number). A column number format (`{"name": "Revenue", "format": "$#,##0.0"}`) is written as the series format code, the data-label and value-axis number formats (the axis takes the first plotted series' format, as the preview does) and the embedded workbook cell formats (Edit Data shows it), on classic and chartex charts. Top-level `datasets` referenced by `{"dataset": "<id>", "fields"?}` and `chart.mapping` export exactly like their inline equivalent; dataset tables and formatted table cells export their display text. With `full` provenance, `OPF_DATASETS_V1` (beside the document tag) and `OPF_DATA_V1` (on each chart or table frame that uses these fields) restore the datasets, references, mapping, formats and `data.source` on import while the native caches (a plain PowerPoint save keeps them), the referenced datasets and the cell texts are unchanged (tags are limited to 16 MiB and shape-checked; a bad one is reported as `invalid-data-provenance`); a deck without them maps every series format code that core `numberFormatFromExcel` maps back to the column format and reports any other code (`chart-number-format-adapted`). A deck that uses none of these fields exports byte for byte as before.
8
+
7
9
  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.
8
10
 
9
11
  Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
10
12
 
11
- Version 0.8.0 and this checkout require Node 24 (`24.x`). Use `.nvmrc` for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
13
+ This checkout supports Node 22 or later (`engines.node` `>=22`; CI tests Node 22, 24 and 26). Versions 0.8.0 to 0.12.3 declared `24.x`, so npm on Node 22 or 26 silently installed an older release instead (RR-20); upgrade past 0.12.3. Use `.nvmrc` (Node 24) for local development. Earlier published versions retain their original engine declarations. Browser entrypoints remain browser-safe; native application compatibility is verified separately.
12
14
 
13
15
  Version 0.8.0 consumes shared heading/scalar/rich line placement, including `textRasterPadding`, through editable native line boxes without autofit. Complete heading tags retain title/subtitle/tag roles, line ordering and current edited text during import. Source boundary metadata also records hard-line separators, so complete scalar/heading groups recover current native text with spaces, tabs and exact authored line endings. Incomplete, duplicate, ambiguous or bulleted groups fall back to ordinary import with diagnostics. Legacy heading tags retain their prior native-line behavior; arbitrary formatting, nesting and geometry are not reconstructed. See the [source contract and limits](https://github.com/OpenPresentation/opf/blob/f94125a1ff95bf0974a055fe1c081d348dad54f2/docs/plans/text-placement.md). Native raster certification remains separate.
14
16
 
@@ -93,12 +95,12 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
93
95
  - `Slide.title`, `Slide.subtitle`, and `Slide.tag` become editable text boxes, not PowerPoint master placeholders.
94
96
  - 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`).
95
97
  - 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 and funnel are written as native Office 2016 chartex parts (confirmed in desktop PowerPoint on 2026-09-30; see [Chartex charts](#chartex-charts-ff-22b)); the map (`world`) stays a clustered column chart by default and reports `chart-data-adapted` (`chartex-fallback`) until PowerPoint accepts its regionMap part (`chartex: "native"` writes it anyway, `chartex: "fallback"` writes clustered columns for every chartex id). A pie or doughnut chart, or a one-series chartex construct, plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more.
96
- - A chart whose data is one column of values (a histogram or dot plot) has no category column. 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; any other chart type plots the values against their row numbers and reports `chart-data-adapted` (`row-numbers`). With `chartex: "fallback"` 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). 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.
98
+ - A chart whose data is one column of values (a histogram or dot plot) has no category column. 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; any other chart type plots the values against their row numbers and reports `chart-data-adapted` (`row-numbers`). With `chartex: "fallback"` 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). Cells parse as in every chart (core `chartNumber`: numbers and plain decimal strings; "12%", "$5" and "1,234" are not numbers) 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.
97
99
  - 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. A picture's native description is the authored `alt` (never a stand-in such as `preencoded.png` or the file path), and a distinct asset `title` is the picture's native `title`; `fromPptx` reads both back.
98
100
  - A chart cell that holds no number (`null`, an empty or non-numeric string, a boolean) is a gap in the native chart: its cache has no point at that row (`c:ptCount` keeps the row count), its workbook cell is blank, and it re-imports as `null`. An actual zero stays zero.
99
101
  - 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).
100
102
  - 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)).
101
- - `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.
103
+ - `checkPptxTypefaces(bytes, {fonts, monospace, themeScripts, themeScriptsByPart})` (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; `themeScriptsByPart` overrides it per theme part, for the theme of a per-slide script master; theme references resolve against the theme of the part's own slide or notes master) 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.
102
104
 
103
105
  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.
104
106
 
@@ -203,7 +205,8 @@ The exporter reads the presentation `language` through core `resolveScriptFonts(
203
205
  - 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.
204
206
  - 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.
205
207
  - 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.
206
- - 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.
208
+ - Slide and notes runs name no `a:ea`/`a:cs` (FF-05: PowerPoint lists an explicit run East Asian or complex-script typeface as an empty-name font in `Presentation.Fonts`); their script text takes the theme slots through `+mn-ea`/`+mn-cs`. The theme `ea` always names a font (the selected script font, an East Asian font for East Asian text in the deck, else the latin family), so no paragraph end mark reads an empty font. Chart text keeps its own latin/ea/cs in the chart's body font.
209
+ - Per-slide script fonts (opf-pptx#168): the presentation theme is built from the first slide. A slide whose own font scheme, slide theme or inline `eastAsian`/`complexScript` selects script fonts that theme does not carry gets its own slide master: a copy of slide master 1 and its layout, with a theme that is the presentation theme plus that slide's East Asian / complex-script slots and per-script entry (named `1_<theme>`, `2_<theme>`, ... as PowerPoint names a second master's theme). Slides with the same script fonts share a master, a slide that selects none stays on master 1, and masters are numbered in slide order. A deck with one script profile keeps one master and is byte-identical to the export before this change. A presentation has one notes master, whose theme is a copy of the presentation theme, so the notes of a slide on another master draw in the presentation theme's script fonts: `script-font-notes-not-exported` (path `slides.N.notes`) reports each such slide with notes.
207
210
  - 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 logical (RR-05, needs the core release that composes right-to-left decks): `left` is the start edge, so a right-to-left paragraph writes `rtl="1"` with `algn="r"` (PowerPoint's `algn` is physical, and `marL`/`indent` are the start-side margin and hanging indent, so a bullet hangs at the right), and every wrapped line shares its paragraph's direction and alignment (core computes the direction once per paragraph, not per line). The composition is mirrored through core: the `left` region and the first column are drawn at the right, a right-to-left table writes `a:tblPr rtl="1"` with its columns in logical order, column, line and area charts write `c:catAx` orientation `maxMin` (first category and value axis at the right), cover logos and header/footer zones swap sides, the master, layout and notes default levels start right-aligned, and notes paragraphs start at the right. Each Latin phrase inside an Arabic or Hebrew run (letters, joining spaces and word punctuation, digits) is written as its own `en-US` run, so PowerPoint orders `v2.0` and `PowerPoint 365` left to right instead of splitting them into items; digits that touch Arabic words stay in the Arabic run. Re-import reads right alignment of a right-to-left table cell as the logical start. Left-to-right decks are written exactly as before.
208
211
  - The PPTX names the chosen fonts only and never embeds font programs. Licensed fonts are never embedded; only open fonts could be, through the explicit FF-13 embed path. Catalog names such as Meiryo must be installed where the deck is opened.
209
212
 
@@ -215,7 +218,7 @@ When the package carries an FF-32 stored `language` ([document round trip](docs/
215
218
  - `language-ambiguous`: records share one curated tag and none has it as its own tag (`bn-BD` is Bengali and Chittagonian, `fil-PH` Filipino and Tagalog); the record of the same primary language is imported.
216
219
  - `language-uncatalogued`: no catalog record matches; the tag is imported.
217
220
  - `rtl-language-mismatch`: right-to-left paragraphs under a left-to-right language.
218
- - `script-font-not-imported`: theme `ea`/`cs` name a font that neither repeats latin nor matches the language default. Imported OPF does not yet carry explicit font-scheme script slots.
221
+ - `script-font-not-imported`: theme `ea`/`cs` name a font that neither repeats latin nor matches what the imported document resolves for the first slide (path `design.fontScheme`), or a slide on another slide master has a master theme whose `ea`/`cs` the imported slide does not resolve to (path `slides.N.design.fontScheme`; with FF-32 provenance the slide's font scheme is restored and nothing is reported). Without provenance, imported OPF does not carry the script slots.
219
222
 
220
223
  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.
221
224
 
@@ -280,7 +283,7 @@ Pass the same `textMeasurement` provider used by preview and pagination to `toPp
280
283
 
281
284
  The PPTX always names the font family the document chose (for example `typeface="Aptos"`), so PowerPoint opens the file and shows the actual font, installed or as a Microsoft 365 cloud font. This is the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31): the user's selection is the source of truth, licensed fonts are never bundled or embedded, and previews draw an open look-alike instead. A provider may preview that family with another face: a metric-compatible substitute (Carlito for Calibri, the goal), a visual-only one (Roboto or Carlito for Aptos, a documented fallback and known layout-fidelity gap until a metric-compatible replacement exists), a caller alias or a generic fallback. That face changes measurement and drawing only. It never reaches the theme, runs, bullets or chart parts. Faces of the chosen family itself, such as Roboto Medium for Roboto at weight 500, keep their native style-link names. `test/export-chosen-fonts.mjs` checks this for every renderer Office-pack substitute. The exporter does not embed font binaries. Viewers resolve the named family themselves. When the preview used a non-metric substitute, PowerPoint can break lines differently from the preview.
282
285
 
283
- Since 0.5.1, the exact PptxGenJS 4.0.1 ESM distribution is shipped with its MIT license and verified upstream hashes. Its unused `image-size` dependency is not installed; JSZip is declared directly. See [dependency provenance and regression coverage](DEPENDENCY-NOTES.md). The published 0.5.0 package retains the older dependency graph.
286
+ The exact [pptxgenjs-plus](https://github.com/lofcz/pptxgenjs-plus) 4.3.4 ESM distribution (the maintained PptxGenJS fork; PptxGenJS 4.0.1 from 0.5.1 to 0.12.x) is shipped with its MIT license and verified upstream hashes. Its imports (`@node-projects/jszip`, `@rgrove/parse-xml`, `pako`) are declared directly; `image-size` is not installed. Output changes of the move: [docs/pptxgenjs-plus-migration.md](docs/pptxgenjs-plus-migration.md). See [dependency provenance and regression coverage](DEPENDENCY-NOTES.md). The published 0.5.0 package retains the older dependency graph.
284
287
 
285
288
  ### Native table fitting
286
289
 
@@ -334,7 +337,7 @@ Native viewer check: Keynote 14.4 displays the PNG/JPEG/GIF media-type specimens
334
337
 
335
338
  Set `imageFormat: "preserve"` to embed WebP unchanged when the receiving application supports it. Other image formats keep their existing export behavior. Conversion errors include the OPF image path; images above 40 megapixels are rejected before compatible conversion. Decoder differences can affect color/alpha rounding, so byte identity across platforms is not promised.
336
339
 
337
- Node conversion lazily loads the pinned open-source Sharp dependency and requires Node 24. Normal package installation must include platform optional dependencies for its native binaries. Browser bundles select a separate browser decoder using local Blob/image/canvas APIs; Sharp and Node code are excluded. Neither path uploads images or fetches asset URLs. Source-preserving export and ordinary PNG/JPEG/GIF operations do not load Sharp.
340
+ Node conversion lazily loads the pinned open-source Sharp dependency and requires Node 22 or later. Normal package installation must include platform optional dependencies for its native binaries. Browser bundles select a separate browser decoder using local Blob/image/canvas APIs; Sharp and Node code are excluded. Neither path uploads images or fetches asset URLs. Source-preserving export and ordinary PNG/JPEG/GIF operations do not load Sharp.
338
341
 
339
342
  `npm test` includes the Node pixel-reference cases and verifies browser bundling. To run the browser pixel checks, run `npm run build:browser-check`, serve this repository locally, and open `/artifacts/webp-fallback/browser/index.html`. The page reports 13 checks covering embedded PNG pixels, alpha, EXIF, the first animation frame, fit/crop, resolver calls and DOM canvas fallback. These are browser export checks, separate from the recorded Keynote viewing evidence.
340
343
 
@@ -0,0 +1,156 @@
1
+ // RR-54: chart and table data in the PPTX export and import (core docs/chart-table-data.md).
2
+ //
3
+ // Core resolves chart and table data for every engine: one strict number rule (`chartNumber`), the NumberFormat to
4
+ // Excel code conversion, top-level datasets and series mapping by column name. This module reads those functions from
5
+ // core and writes their result into the native chart: the series c:numCache/c:formatCode, the data-label and value-axis
6
+ // c:numFmt and the embedded workbook cell formats (src/chart-workbook.js). A chart without number formats is written
7
+ // exactly as before.
8
+ //
9
+ // Optional core exports are read from the namespace, so a published core before RR-54 still loads: its schema rejects
10
+ // the new fields, so the fallbacks only need the inline positional form, with the same strict number rule.
11
+ import * as opfCore from '@openpresentation/opf';
12
+
13
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
14
+ const STRICT_DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
15
+
16
+ /** Core `chartNumber` (strict decimal syntax; everything else is a gap). */
17
+ export const chartNumber = typeof opfCore.chartNumber === 'function' ? opfCore.chartNumber : value => {
18
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
19
+ if (typeof value !== 'string') return null;
20
+ const text = value.trim();
21
+ if (!STRICT_DECIMAL.test(text)) return null;
22
+ const number = Number(text);
23
+ return Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) ? number : null;
24
+ };
25
+ /** NumberFormat -> Excel format code ("General" when absent). */
26
+ export const excelNumberFormat = typeof opfCore.excelNumberFormat === 'function' ? opfCore.excelNumberFormat : () => 'General';
27
+ /** Excel format code -> NumberFormat, or undefined when it has no exact equivalent. */
28
+ export const numberFormatFromExcel = typeof opfCore.numberFormatFromExcel === 'function' ? opfCore.numberFormatFromExcel : () => undefined;
29
+ export const isDatasetRef = typeof opfCore.isDatasetRef === 'function' ? opfCore.isDatasetRef : value => object(value) && typeof value.dataset === 'string';
30
+ export const inlineChartData = typeof opfCore.inlineChartData === 'function' ? opfCore.inlineChartData : chart => chart;
31
+ export const inlineTableData = typeof opfCore.inlineTableData === 'function' ? opfCore.inlineTableData : table => table;
32
+ export const tableCellDisplayValue = typeof opfCore.tableCellDisplayValue === 'function' ? opfCore.tableCellDisplayValue : cell => cell;
33
+ export const resolveTableData = typeof opfCore.resolveTableData === 'function' ? opfCore.resolveTableData
34
+ : table => ({...(Array.isArray(table?.columns) ? {columns: table.columns} : {}), rows: Array.isArray(table?.rows) ? table.rows : [], formats: [], diagnostics: []});
35
+
36
+ const columnName = column => typeof column === 'string' ? column : object(column) && typeof column.name === 'string' ? column.name : '';
37
+
38
+ // Before RR-54 core: inline positional data only (the schema of that core has no datasets or mapping).
39
+ function positionalChartData(chart) {
40
+ const data = chart?.data;
41
+ if (!object(data) || !Array.isArray(data.columns)) return {ok: false, reason: 'no-columns', message: 'The chart data has no columns.', diagnostics: []};
42
+ if (!data.columns.length) return {ok: false, reason: 'no-columns', message: 'The chart data has no columns.', diagnostics: []};
43
+ if (!Array.isArray(data.rows) || !data.rows.length) return {ok: false, reason: 'no-rows', message: 'The chart data has no rows.', diagnostics: []};
44
+ const diagnostics = [];
45
+ const rows = data.rows.map((row, rowIndex) => data.columns.map((_, index) => {
46
+ const cell = Array.isArray(row) && index < row.length ? row[index] : null;
47
+ if (index === 0) return cell === undefined ? null : cell;
48
+ const number = chartNumber(cell);
49
+ if (number === null && cell !== null && cell !== undefined && cell !== '') diagnostics.push({code: 'chart-value-not-numeric', severity: 'warning', path: `/data/rows/${rowIndex}/${index}`, message: `chart value ${JSON.stringify(cell)} is not a number; it is plotted as a gap.`});
50
+ return number;
51
+ }));
52
+ return {ok: true, columns: data.columns.map(columnName), formats: data.columns.map(() => undefined), rows, diagnostics};
53
+ }
54
+
55
+ /** Core `resolveChartData`: the canonical positional table [category, (x,) ...series], or {ok: false, reason}. */
56
+ export const resolveChartData = typeof opfCore.resolveChartData === 'function' ? opfCore.resolveChartData : positionalChartData;
57
+
58
+ /** True when a chart uses an RR-54 data field that the native cache alone cannot carry back. */
59
+ export function chartUsesDataFields(chart) {
60
+ const data = chart?.data;
61
+ return object(chart) && (chart.mapping !== undefined || isDatasetRef(data) || (object(data) && (data.source !== undefined || (Array.isArray(data.columns) && data.columns.some(object)))));
62
+ }
63
+
64
+ /** True when a table is dataset-backed or carries a DataColumn header or a number format. */
65
+ export function tableUsesDataFields(table) {
66
+ if (!object(table)) return false;
67
+ if (isDatasetRef(table)) return true;
68
+ const formatted = cell => object(cell) && (typeof cell.format === 'string' || (typeof cell.name === 'string' && !Object.hasOwn(cell, 'value')));
69
+ return (Array.isArray(table.columns) && table.columns.some(formatted)) || (Array.isArray(table.rows) && table.rows.some(row => Array.isArray(row) && row.some(formatted)));
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Export: number formats in the classic chart part
74
+
75
+ const escapeXml = value => String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
76
+ /** The Excel code of a NumberFormat, or undefined when it is absent or General (the exporter's default). */
77
+ export function excelCode(format) {
78
+ if (format === undefined) return undefined;
79
+ const code = excelNumberFormat(format);
80
+ return code && code !== 'General' ? code : undefined;
81
+ }
82
+
83
+ /**
84
+ * Write the number formats of a generated classic chart part (after its data labels are written). `formats`:
85
+ * series the Excel code of each c:ser, in document order (undefined keeps General);
86
+ * x the X column's code (scatter c:xVal and the horizontal value axis);
87
+ * axis the value axis code (the first plotted series' format, as Excel does), or undefined to keep the axis;
88
+ * labels false when the labels show percentages (the percent label keeps PowerPoint's own percent form);
89
+ * scatter true for an XY chart (two value axes: axPos b/t is X).
90
+ * Every c:numCache of a series takes its code, every c:numFmt of a series' data labels too; the chart group's labels
91
+ * take the first series' code.
92
+ */
93
+ export function applyChartNumberFormats(xml, {series = [], x, axis, labels = true, scatter = false}) {
94
+ // CT_NumData: formatCode is the cache's first child (PptxGenJS writes none on pie and doughnut series).
95
+ const formatCode = (text, code) => code === undefined ? text : /<c:formatCode>/.test(text)
96
+ ? text.replace(/<c:formatCode>[^<]*<\/c:formatCode>/g, `<c:formatCode>${escapeXml(code)}</c:formatCode>`)
97
+ : text.replace(/<c:numCache>/g, `<c:numCache><c:formatCode>${escapeXml(code)}</c:formatCode>`);
98
+ const labelFormats = (text, code) => code === undefined || !labels ? text : text.replace(/<c:dLbls>[\s\S]*?<\/c:dLbls>/g, block => block.replace(/<c:numFmt\b[^>]*\/>/g, `<c:numFmt formatCode="${escapeXml(code)}" sourceLinked="0"/>`));
99
+ // Radar and scatter series carry no labels of their own (PptxGenJS writes the chart group's only), so every series would
100
+ // show the first series' format. When shown labels differ by series, each series gets a copy of the group's labels,
101
+ // in the CT_RadarSer / CT_ScatterSer position (before trendline, errBars, cat, val, xVal and yVal).
102
+ const group = xml.split(/<c:ser>[\s\S]*?<\/c:ser>/).join('').match(/<c:dLbls>[\s\S]*?<\/c:dLbls>/)?.[0];
103
+ const ownLabels = labels && group && /<c:showVal val="1"\/>/.test(group) && series.some(code => code !== series[0]);
104
+ let index = 0;
105
+ const out = (ownLabels ? xml.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => /<c:dLbls>/.test(ser) ? ser : ser.replace(/<c:(?:trendline|errBars|cat|val|xVal|yVal)>/, at => `${group}${at}`)) : xml).replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => {
106
+ const code = series[index++];
107
+ const result = ser.replace(/<c:(val|yVal|xVal)>[\s\S]*?<\/c:\1>/g, (role, name) => formatCode(role, name === 'xVal' ? x : code));
108
+ return labelFormats(result, code);
109
+ });
110
+ // The chart group's own label block (outside every c:ser) follows the first series.
111
+ const grouped = out.split(/(<c:ser>[\s\S]*?<\/c:ser>)/).map((part, position) => position % 2 ? part : labelFormats(part, series[0])).join('');
112
+ return grouped.replace(/<c:valAx>[\s\S]*?<\/c:valAx>/g, valAx => {
113
+ const code = scatter && /<c:axPos val="[bt]"\/>/.test(valAx) ? x : axis;
114
+ return code === undefined ? valAx : valAx.replace(/<c:numFmt\b[^>]*\/>/, `<c:numFmt formatCode="${escapeXml(code)}" sourceLinked="0"/>`);
115
+ });
116
+ }
117
+
118
+ /** The workbook ranges and codes of a classic chart part's numeric series: [{formula, code}]. */
119
+ export function numericRanges(xml, {series = [], x}) {
120
+ const ranges = [];
121
+ let index = 0;
122
+ for (const [ser] of xml.matchAll(/<c:ser>[\s\S]*?<\/c:ser>/g)) {
123
+ const code = series[index++];
124
+ for (const [, role, body] of ser.matchAll(/<c:(val|yVal|xVal)>([\s\S]*?)<\/c:\1>/g)) {
125
+ const formula = /<c:numRef>\s*<c:f>([^<]*)<\/c:f>/.exec(body)?.[1];
126
+ const value = role === 'xVal' ? x : code;
127
+ if (formula && value !== undefined && !ranges.some(range => range.formula === formula)) ranges.push({formula, code: value});
128
+ }
129
+ }
130
+ return ranges;
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Import: series number formats
135
+
136
+ /**
137
+ * The NumberFormat of a native cache's format code: {format} when core maps it back exactly, {unmapped: code} when it
138
+ * is a code with no NumberFormat equivalent, {} for General or no code.
139
+ */
140
+ export function formatFromCode(code) {
141
+ if (typeof code !== 'string' || code.trim() === '' || /^general$/i.test(code.trim())) return {};
142
+ const format = numberFormatFromExcel(code);
143
+ return format === undefined ? {unmapped: code} : {format};
144
+ }
145
+
146
+ /** Columns with their number formats: a column whose code maps back becomes {name, format}; the others stay names. */
147
+ export function formattedColumns(names, codes, report) {
148
+ const unmapped = [];
149
+ const columns = names.map((name, index) => {
150
+ const {format, unmapped: code} = formatFromCode(codes[index]);
151
+ if (code !== undefined) unmapped.push(`'${name}' (${code})`);
152
+ return format === undefined ? name : {name, format};
153
+ });
154
+ if (unmapped.length) report?.({code: 'chart-number-format-adapted', option: 'data', message: `The number ${unmapped.length === 1 ? 'format' : 'formats'} of ${unmapped.join(', ')} ${unmapped.length === 1 ? 'has' : 'have'} no OPF number format equivalent; ${unmapped.length === 1 ? 'that column imports' : 'those columns import'} without a format (the General form), so the values keep their numbers but not their native display.`});
155
+ return columns;
156
+ }
@@ -133,7 +133,7 @@ function titleString(title) {
133
133
  * plus `notes` naming what the three fields cannot express. `defaults` is what an export writes without the field:
134
134
  * `{legend: 'right' | undefined}` (a legend at the right of multi-series, pie and doughnut charts) and the chart kind.
135
135
  */
136
- export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCount, circular, scatter}) {
136
+ export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCount, circular, scatter, seriesFormats = []}) {
137
137
  // An older core's schema has no such fields (additionalProperties is false), so nothing is added to the imported chart.
138
138
  if (!coreKnowsChartOptions()) return {options: {}, notes: []};
139
139
  const chart = chartSpace?.['c:chart'];
@@ -164,12 +164,12 @@ export function chartOptionsFromClassic(chartSpace, {chartNode, target, seriesCo
164
164
  if (!(defaultLegend && position === 'right')) out.legend = position;
165
165
  } else if (defaultLegend) out.legend = 'none';
166
166
 
167
- const dataLabels = classicDataLabels(chartNode, target, notes);
167
+ const dataLabels = classicDataLabels(chartNode, target, notes, seriesFormats);
168
168
  if (dataLabels !== undefined) out.dataLabels = dataLabels;
169
169
  return {options: out, notes};
170
170
  }
171
171
 
172
- function classicDataLabels(chartNode, target, notes) {
172
+ function classicDataLabels(chartNode, target, notes, seriesFormats = []) {
173
173
  const series = asArray(chartNode?.['c:ser']);
174
174
  // The labels the chart shows: the first series' block when it has one (PptxGenJS writes pie and doughnut visibility as per
175
175
  // point overrides), else the chart group's block.
@@ -188,7 +188,8 @@ function classicDataLabels(chartNode, target, notes) {
188
188
  }
189
189
  const support = target && typeof opfCore.chartOptionSupport === 'function' ? opfCore.chartOptionSupport(target) : undefined;
190
190
  const numberFormat = source['c:numFmt']?.formatCode ?? block['c:numFmt']?.formatCode;
191
- if (numberFormat && numberFormat !== 'General' && numberFormat !== '#,##0' && numberFormat !== '0%') notes.push({option: 'dataLabels', message: `The label number format '${numberFormat}' cannot be expressed; labels import in the General format.`});
191
+ // RR-54: a label format that is a series' own number format imports with that column's format (or its own note).
192
+ if (numberFormat && numberFormat !== 'General' && numberFormat !== '#,##0' && numberFormat !== '0%' && !seriesFormats.includes(numberFormat)) notes.push({option: 'dataLabels', message: `The label number format '${numberFormat}' cannot be expressed; labels import in the General format.`});
192
193
  const out = {};
193
194
  const position = DLBL_POS_BACK[source['c:dLblPos']?.val ?? block['c:dLblPos']?.val];
194
195
  const supported = support?.dataLabels.positions ?? [];
@@ -82,7 +82,44 @@ export function writeChartCategoryHeading(entries,chartPart,heading){
82
82
  entries[context.workbookPart]=zipSync(workbook);
83
83
  }
84
84
 
85
- // PptxGenJS 4.0.1 writes broken range metadata into chart workbooks (upstream gitbrent/PptxGenJS#1531): the
85
+ // RR-54: the number formats of a generated chart's value cells, so Edit Data in PowerPoint shows them as the chart does.
86
+ // `ranges` are the chart's numeric series ranges with their Excel codes ([{formula: "Sheet1!$B$2:$B$5", code}]). Each
87
+ // distinct code becomes a custom numFmt (ids from 164) and a cellXfs entry; every cell of its range (blank gap cells too)
88
+ // takes that style. PptxGenJS writes no cellXfs, so the default style 0 is written with them. A chart without formats is
89
+ // never passed here, and its workbook keeps its bytes.
90
+ export function writeChartWorkbookFormats(entries,chartPart,ranges){
91
+ if(!ranges.length)return;
92
+ const chart=xml(entries,chartPart)?.chartSpace;
93
+ const ref=relationship(entries,chartPart,chart?.externalData?.id);
94
+ if(!ref?.type?.endsWith('/package')||!entries[ref.path])throw Error('Generated chart has no embedded workbook.');
95
+ const workbook=unzipSync(entries[ref.path]);
96
+ const stylesPart='xl/styles.xml';
97
+ let styles=workbook[stylesPart]&&decoder.decode(workbook[stylesPart]);
98
+ if(!styles||/<cellXfs\b/.test(styles)||!/<numFmts count="\d+">/.test(styles)||!/<dxfs\b/.test(styles))throw Error('Generated chart workbook styles are not in the expected form.');
99
+ const codes=[...new Set(ranges.map(range=>range.code))];
100
+ const numFmts=codes.map((code,index)=>`<numFmt numFmtId="${164+index}" formatCode="${escape(code)}"/>`).join('');
101
+ styles=styles.replace(/<numFmts count="(\d+)">([\s\S]*?)<\/numFmts>/,(_match,count,body)=>`<numFmts count="${Number(count)+codes.length}">${body}${numFmts}</numFmts>`);
102
+ const xfs=codes.map((_code,index)=>`<xf numFmtId="${164+index}" fontId="0" fillId="0" borderId="0" xfId="0" applyNumberFormat="1"/>`).join('');
103
+ // CT_Stylesheet order: numFmts, fonts, fills, borders, cellStyleXfs, cellXfs, cellStyles, dxfs.
104
+ styles=styles.replace(/<dxfs\b/,`<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs><cellXfs count="${codes.length+1}"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>${xfs}</cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles><dxfs`);
105
+ workbook[stylesPart]=encoder.encode(styles);
106
+ const sheets=array(xml(workbook,'xl/workbook.xml')?.workbook?.sheets?.sheet);
107
+ for(const {formula,code}of ranges){
108
+ const range=/^(?:'((?:[^']|'')+)'|([^'!]+))!\$?([A-Z]+)\$?(\d+):\$?([A-Z]+)\$?(\d+)$/i.exec(formula);
109
+ if(!range||range[3].toUpperCase()!==range[5].toUpperCase())throw Error('Generated chart series range is not one column.');
110
+ const name=range[1]?.replaceAll("''","'")??range[2];
111
+ const sheetRef=relationship(workbook,'xl/workbook.xml',sheets.find(sheet=>sheet.name===name)?.id);
112
+ if(!sheetRef?.type?.endsWith('/worksheet')||!workbook[sheetRef.path])throw Error('Generated chart series sheet is missing.');
113
+ const column=range[3].toUpperCase(),first=Number(range[4]),last=Number(range[6]),style=codes.indexOf(code)+1;
114
+ const sheet=decoder.decode(workbook[sheetRef.path]).replace(/<c r="([A-Z]+)(\d+)"(?![^>]*\bs=)/g,(cell,letters,row)=>letters===column&&Number(row)>=first&&Number(row)<=last?`<c r="${letters}${row}" s="${style}"`:cell);
115
+ workbook[sheetRef.path]=encoder.encode(sheet);
116
+ }
117
+ entries[ref.path]=zipSync(workbook);
118
+ }
119
+
120
+ // PptxGenJS 4.0.1 wrote broken range metadata into chart workbooks (upstream gitbrent/PptxGenJS#1531). pptxgenjs-plus
121
+ // 4.3.4 (vendored) fixed the category and scatter tables; its bubble table still takes its last row from the column
122
+ // count (lofcz/pptxgenjs-plus#15). The 4.0.1 shapes: the
86
123
  // category-chart branch (bar, line, area, pie, doughnut, radar, and the classic fallback of chartex charts) ends the
87
124
  // table ref with a stray apostrophe (ref="A1:C7'"), the bubble branch takes the table's last row from its column
88
125
  // count, and the category sheet dimension counts one label column (too narrow for multi-level categories). Keynote
package/dist/chartex.js CHANGED
@@ -17,6 +17,7 @@
17
17
  // cached dimensions restore the category-major data.
18
18
  import {CHARTEX_NAMESPACES, chartTypeFromChartex} from './chart-types.js';
19
19
  import {chartOptionsFromChartex} from './chart-options.js';
20
+ import {formattedColumns} from './chart-data.js';
20
21
 
21
22
  const NS = Object.freeze({
22
23
  a: 'http://schemas.openxmlformats.org/drawingml/2006/main',
@@ -131,16 +132,20 @@ const CX_POSITION = Object.freeze({center: 'ctr', 'inside-end': 'inEnd', 'inside
131
132
 
132
133
  // RR-35: an explicit dataLabels option writes the selected content, position and separator for every construct that takes it;
133
134
  // `dataLabels: false` removes the labels a treemap or funnel carries by default. Without the option the defaults are unchanged.
134
- function dataLabels(spec, text, options) {
135
+ // RR-54: a value label of a series with a number format (not a binned construct, whose labels are counts) shows it.
136
+ const labelFormat = (spec, code) => code === undefined || spec.binning ? '' : `<cx:numFmt formatCode="${escapeXml(code)}" sourceLinked="0"/>`;
137
+
138
+ function dataLabels(spec, text, options, code) {
135
139
  if (options?.dataLabelsOff) return '';
136
140
  const labels = options?.dataLabels;
137
141
  if (labels) {
138
142
  const has = part => labels.content.includes(part) ? 1 : 0;
139
143
  const position = labels.position === null ? (spec.layoutId === 'treemap' || spec.layoutId === 'funnel' ? 'ctr' : undefined) : CX_POSITION[labels.position];
140
- return `<cx:dataLabels${position ? ` pos="${position}"` : ''}>${text}<cx:visibility seriesName="0" categoryName="${has('category')}" value="${has('value')}"/><cx:separator>${escapeXml(labels.separator)}</cx:separator></cx:dataLabels>`;
144
+ // CT_DataLabels: numFmt precedes spPr and txPr.
145
+ return `<cx:dataLabels${position ? ` pos="${position}"` : ''}>${has('value') ? labelFormat(spec, code) : ''}${text}<cx:visibility seriesName="0" categoryName="${has('category')}" value="${has('value')}"/><cx:separator>${escapeXml(labels.separator)}</cx:separator></cx:dataLabels>`;
141
146
  }
142
147
  if (spec.layoutId === 'treemap') return `<cx:dataLabels pos="ctr">${text}<cx:visibility seriesName="0" categoryName="1" value="0"/></cx:dataLabels>`;
143
- if (spec.layoutId === 'funnel') return `<cx:dataLabels pos="ctr">${text}<cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels>`;
148
+ if (spec.layoutId === 'funnel') return `<cx:dataLabels pos="ctr">${labelFormat(spec, code)}${text}<cx:visibility seriesName="0" categoryName="0" value="1"/></cx:dataLabels>`;
144
149
  return '';
145
150
  }
146
151
 
@@ -164,14 +169,16 @@ const axisTitle = (value, text, run) => {
164
169
  `<a:p><a:pPr algn="ctr"><a:defRPr ${props}>${fill}</a:defRPr></a:pPr><a:r><a:rPr lang="en-US" ${props}>${fill}</a:rPr><a:t>${escapeXml(value)}</a:t></a:r></a:p></cx:rich></cx:tx></cx:title>`;
165
170
  };
166
171
 
167
- function axes(spec, gridColor, text, titles = {}, run = {}) {
172
+ function axes(spec, gridColor, text, titles = {}, run = {}, code) {
168
173
  if (!spec.axes) return '';
169
174
  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>`;
170
175
  const gapWidth = {clusteredColumn: '0.06', paretoLine: '0.06', boxWhisker: '1', waterfall: '0.5', funnel: '0.06'}[spec.layoutId] ?? '1';
171
176
  // CT_Axis order: scaling, units, majorGridlines, tickLabels, spPr, txPr.
172
177
  const category = `<cx:axis id="0"><cx:catScaling gapWidth="${gapWidth}"/>${axisTitle(titles.category, text, run)}<cx:tickLabels/>${text}</cx:axis>`;
173
178
  if (spec.layoutId === 'funnel') return `${category}<cx:axis id="1" hidden="1"><cx:valScaling/><cx:tickLabels/>${text}</cx:axis>`;
174
- const value = `<cx:axis id="1"><cx:valScaling/>${axisTitle(titles.value, text, run)}${gridlines}<cx:tickLabels/>${text}</cx:axis>`;
179
+ // RR-54: the value axis shows the first series' number format (none when that series has none) (CT_Axis: numFmt follows tickLabels); a binned axis counts.
180
+ const numberFormat = code === undefined || spec.binning ? '' : `<cx:numFmt formatCode="${escapeXml(code)}" sourceLinked="0"/>`;
181
+ const value = `<cx:axis id="1"><cx:valScaling/>${axisTitle(titles.value, text, run)}${gridlines}<cx:tickLabels/>${numberFormat}${text}</cx:axis>`;
175
182
  const percentage = spec.layoutId === 'paretoLine' ? `<cx:axis id="2"><cx:valScaling max="1" min="0"/><cx:units unit="percentage"/><cx:tickLabels/>${text}</cx:axis>` : '';
176
183
  return `${category}${value}${percentage}`;
177
184
  }
@@ -183,7 +190,7 @@ function axes(spec, gridColor, text, titles = {}, run = {}) {
183
190
  * layout is PptxGenJS's: Sheet1, headings in row 1, categories in column A and
184
191
  * one series per following column.
185
192
  */
186
- export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette, textSize, options}) {
193
+ export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette, textSize, options, formats = []}) {
187
194
  const rows = series[0].labels.length;
188
195
  const range = (letter) => `Sheet1!$${letter}$2:$${letter}$${rows + 1}`;
189
196
  const categories = hasCategories
@@ -191,7 +198,7 @@ export function chartexPartXml({spec, series, hasCategories, number, workbookRel
191
198
  : '';
192
199
  const data = series.map((entry, index) => {
193
200
  const points = entry.values.map((value, row) => value === null || !Number.isFinite(value) ? '' : `<cx:pt idx="${row}">${numberText(value)}</cx:pt>`).join('');
194
- 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>`;
201
+ return `<cx:data id="${index}">${categories}<cx:numDim type="${spec.dimension}"><cx:f>${range(columnLetters(index + 2))}</cx:f><cx:lvl ptCount="${rows}" formatCode="${escapeXml(formats[index] ?? 'General')}">${points}</cx:lvl></cx:numDim></cx:data>`;
195
202
  }).join('');
196
203
  const text = textProperties(labelColor, font, textSize);
197
204
  const plotted = series.map((entry, index) => {
@@ -203,7 +210,7 @@ export function chartexPartXml({spec, series, hasCategories, number, workbookRel
203
210
  const line = spec.layoutId === 'boxWhisker' ? `<a:ln w="9525">${solidFill(labelColor)}</a:ln>` : '';
204
211
  return `<cx:series layoutId="${spec.owner ?? spec.layoutId}" uniqueId="${seriesUniqueId(number, index)}">` +
205
212
  `<cx:tx><cx:txData><cx:f>Sheet1!$${letter}$1</cx:f><cx:v>${escapeXml(entry.name)}</cx:v></cx:txData></cx:tx>` +
206
- `<cx:spPr>${solidFill(color)}${line}</cx:spPr>${pointFills}${dataLabels(spec, text, options)}<cx:dataId val="${index}"/>${layoutProperties(spec, entry, hasCategories)}` +
213
+ `<cx:spPr>${solidFill(color)}${line}</cx:spPr>${pointFills}${dataLabels(spec, text, options, formats[index])}<cx:dataId val="${index}"/>${layoutProperties(spec, entry, hasCategories)}` +
207
214
  (spec.axes ? '<cx:axisId val="0"/><cx:axisId val="1"/>' : '') + '</cx:series>';
208
215
  });
209
216
  if (spec.owner) {
@@ -219,7 +226,7 @@ export function chartexPartXml({spec, series, hasCategories, number, workbookRel
219
226
  return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
220
227
  `<cx:chartSpace xmlns:a="${NS.a}" xmlns:r="${NS.r}" xmlns:cx="${NS.cx}">` +
221
228
  `<cx:chartData><cx:externalData r:id="${workbookRelId}" cx:autoUpdate="0"/>${data}</cx:chartData>` +
222
- `<cx:chart><cx:plotArea><cx:plotAreaRegion>${plotted.join('')}</cx:plotAreaRegion>${axes(spec, gridColor, text, options?.axisTitles, {labelColor, font, textSize})}</cx:plotArea>${legend}</cx:chart>` +
229
+ `<cx:chart><cx:plotArea><cx:plotAreaRegion>${plotted.join('')}</cx:plotAreaRegion>${axes(spec, gridColor, text, options?.axisTitles, {labelColor, font, textSize}, formats[0])}</cx:plotArea>${legend}</cx:chart>` +
223
230
  `<cx:spPr>${solidFill(fill.color, alpha)}<a:ln><a:noFill/></a:ln></cx:spPr>${text}` +
224
231
  '</cx:chartSpace>';
225
232
  }
@@ -396,7 +403,7 @@ export function chartFromChartex(doc, {heading, limits, invalid, path, report})
396
403
  return result;
397
404
  };
398
405
  let labels = null;
399
- const names = [], values = [];
406
+ const names = [], values = [], codes = [];
400
407
  plotted.forEach((series, index) => {
401
408
  const data = dataById.get(String(series['cx:dataId'].val));
402
409
  if (!data) invalid('series data is missing', `${path}#cx:series[${index}]/cx:dataId`);
@@ -405,6 +412,9 @@ export function chartFromChartex(doc, {heading, limits, invalid, path, report})
405
412
  if (categories && !labels) labels = level(categories, `${location}/cx:strDim`, false);
406
413
  const numeric = asArray(data['cx:numDim'])[0];
407
414
  values.push(numeric ? level(numeric, `${location}/cx:numDim`, true) : []);
415
+ // RR-54: the series' number format code (cx:lvl formatCode); core maps the codes it can back to column formats.
416
+ const code = asArray(numeric?.['cx:lvl'])[0]?.formatCode;
417
+ codes.push(typeof code === 'string' ? code : undefined);
408
418
  const name = series['cx:tx']?.['cx:txData']?.['cx:v'];
409
419
  names.push(name === undefined ? `Series ${index + 1}` : scalar(name));
410
420
  });
@@ -415,8 +425,8 @@ export function chartFromChartex(doc, {heading, limits, invalid, path, report})
415
425
  // RR-35: axis titles, legend position and data labels read back into the chart's option fields.
416
426
  const {options, notes} = chartOptionsFromChartex(space, {type, seriesCount: plotted.length});
417
427
  for (const note of notes) report?.(note);
418
- if (!labels) return {type, data: {columns: [names[0]], rows: values[0].slice(0, rowCount).map((value, index) => [values[0][index] ?? null])}, ...options};
428
+ if (!labels) return {type, data: {columns: formattedColumns([names[0]], [codes[0]], report), rows: values[0].slice(0, rowCount).map((value, index) => [values[0][index] ?? null])}, ...options};
419
429
  const rows = [];
420
430
  for (let index = 0; index < rowCount; index += 1) rows.push([labels[index] ?? null, ...values.map(row => row[index] ?? null)]);
421
- return {type, data: {columns: [heading ?? 'Category', ...names], rows}, ...options};
431
+ return {type, data: {columns: formattedColumns([heading ?? 'Category', ...names], [undefined, ...codes], report), rows}, ...options};
422
432
  }
@@ -53,7 +53,7 @@ export function attachTextTags(entries, records, tagName, prefix, kind, {picture
53
53
  rels = rels.replace('</Relationships>',`<Relationship Id="${id}" Type="${REL}" Target="../tags/${prefix}${count}.xml"/></Relationships>`);
54
54
  // Optional alternative text (a:cNvPr descr) for generated decoration such as the metric trend arrow.
55
55
  const descr = descriptions?.get(name);
56
- if (descr !== undefined) shape = shape.replace(/<p:cNvPr\b([^>]*?)>/, (_match, attributes) => `<p:cNvPr${attributes} descr="${descr.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;')}">`);
56
+ if (descr !== undefined) shape = shape.replace(/<p:cNvPr\b([^>]*?)(\/?)>/, (_match, attributes, close) => `<p:cNvPr${attributes} descr="${descr.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;')}"${close}>`);
57
57
  shape = shape.replace(/<p:nvPr\s*\/>/,'<p:nvPr></p:nvPr>');
58
58
  if (!shape.includes('</p:nvPr>')) throw new Error(`Generated ${kind} shape has no native application properties.`);
59
59
  return shape.replace('</p:nvPr>',`<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);