@openpresentation/opf-pptx 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -16
- package/dist/annotation-export.js +1 -1
- package/dist/annotation-provenance.js +4 -0
- package/dist/background.js +2 -3
- package/dist/body-text-import.js +16 -4
- package/dist/chart-alt.js +54 -0
- package/dist/chart-data.js +29 -54
- package/dist/chart-highlight.js +113 -0
- package/dist/chart-options.js +46 -24
- package/dist/chart-types.js +59 -58
- package/dist/code-provenance.js +20 -4
- package/dist/color-ref.js +12 -6
- package/dist/data-provenance.js +18 -14
- package/dist/document-provenance.js +20 -8
- package/dist/format-check.js +10 -0
- package/dist/furniture-provenance.js +30 -3
- package/dist/heading-provenance.js +10 -3
- package/dist/image-fallback-node.js +4 -1
- package/dist/index.d.ts +32 -25
- package/dist/index.js +481 -235
- package/dist/media-provenance.js +3 -3
- package/dist/metric-provenance.js +3 -3
- package/dist/native-text-style.js +2 -0
- package/dist/package-fonts.js +2 -2
- package/dist/quote-provenance.js +70 -26
- package/dist/rich-heading.js +119 -0
- package/dist/run-colors.js +3 -0
- package/dist/script-fonts.js +81 -54
- package/dist/theme-colors.js +1 -1
- package/dist/timeline-provenance.js +17 -6
- package/dist/typeface-inventory.js +7 -7
- package/dist/watermark-provenance.js +65 -3
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
+
API names (RR-55, unreleased; requires core 0.14, and opf-render 0.14 for the optional peer): `toPptx(presentation, { fonts })` takes the fonts handle that opf-render's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`; core's `Fonts`, an object with at least `textMeasurement`) and no longer reads a top-level `textMeasurement`. `fromPptx(bytes, { signals: true })` resolves to `{ presentation, signals }` (it was `{ document, signals }`). `OPFPptxError` carries `findings` (the `error` findings of core's `format` check, or the variable errors) and `diagnostics` (the layout diagnostics of a `layout-overflow`), not `issues`. `inventoryPptxTypefaces` and `checkPptxTypefaces` are `inventoryTypefaces` and `checkTypefaces`, and `checkTypefaces` takes `{ families }` where it took `{ fonts }`. Each slide is composed with the options of core's `resolveSlideContext`, so the deck's resolved font families (slide design, deck design, theme, then the engine default) are the ones the layout is measured in; a reference that matches no theme, colour scheme or layout is reported as `unresolved-theme`, `unresolved-color-scheme` or `unresolved-layout` (as an unknown font scheme is as `unresolved-font-scheme`) and never refuses the export: the default `minimal` theme and `cool-horizon` colour scheme are used, and a slide with no layout, or an unknown one, is composed with no layout record (automatic composition, as in the preview; it used to compose against the `blank` layout, so a layout-less cover is now centred vertically). The package no longer falls back when core lacks a function: it requires core 0.14.
|
|
4
|
+
|
|
3
5
|
Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and exports what that core composes, plus the export and import changes listed in the changelog; use it with opf-render 0.12.0 (optional peer ^0.12.0) and opf-editor 0.11.0 so preview and export resolve one core. Version 0.11.9 exports an SVG image as a native SVG picture over a PNG fallback (rasterized in Node by the optional `@openpresentation/opf-render` peer or `options.svgRasterizer`; without one it stays the placeholder), writes a deck footer's first text, date and slide number as native PowerPoint Header & Footer placeholders (output change for decks with a footer; every export now carries the footer placeholders on its master, layout and notes master; see [native header and footer](docs/native-header-footer.md)) and adds the opt-in `fromPptx(bytes, {signals: true})`, which also returns the raw per-shape signals of every slide (see Import signals; the option leaves the default import unchanged; core floor `^0.11.4` unchanged, no API removed). Version 0.11.8 re-imports rich text that wraps over several native lines as the one authored payload instead of reporting `content-structure-changed` (the export records the line count; decks exported by earlier versions import as before; core floor `^0.11.4` unchanged, no API change). Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). 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
6
|
|
|
5
7
|
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
8
|
|
|
7
|
-
Chart
|
|
9
|
+
Chart emphasis (FA-14, unreleased): `chart.highlight` exports as native series fills (`c:ser/c:spPr`, plus `c:marker/c:spPr` on line, scatter and radar series) and per-point fills (`c:dPt`: `c:spPr` on a column or bar, a circular `c:marker` on a line, the slice fill on a pie or doughnut). The accent is `a:schemeClr accent1` when the deck theme holds the primary exactly (a theme edit in PowerPoint recolors it) and an sRGB literal when the contrast adjustment moved it; the muted color is an sRGB literal (a mix of two theme colors is not a scheme color). Labels inside a mark take the color that contrasts with that mark. The highlight is not inferred on import: in `full` provenance mode it is recorded in the chart's `OPF_DATA_V1` record and restored while the chart's cached data is unchanged. Chartex constructs are not highlighted. See core `docs/chart-options.md`.
|
|
10
|
+
|
|
11
|
+
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 `fromExcelNumberFormat` 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
12
|
|
|
9
13
|
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.
|
|
10
14
|
|
|
@@ -95,12 +99,13 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
|
|
|
95
99
|
- `Slide.title`, `Slide.subtitle`, and `Slide.tag` become editable text boxes, not PowerPoint master placeholders.
|
|
96
100
|
- 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`).
|
|
97
101
|
- 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.
|
|
102
|
+
- A timeline event's `status` (`done`, `current`, `planned`) is drawn from the deck's colors with core's shared shapes (the same as the preview): a filled `ellipse` marker for `done` and for no status, a `current` marker inside a ring (a second `ellipse`, tagged with its marker) with a bold label, and a `planned` marker that is an `ellipse` with the background fill and an accent outline, with muted text kept at 4.5:1 or more. Import restores `status` from the marker tags, not from the shapes' look, and drops the timeline provenance (keeping plain text) when a current event's ring is missing or duplicated.
|
|
98
103
|
- 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.
|
|
99
104
|
- 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.
|
|
100
105
|
- 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.
|
|
101
106
|
- 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).
|
|
102
107
|
- 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)).
|
|
103
|
-
- `
|
|
108
|
+
- `checkTypefaces(bytes, {families, 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. `inventoryTypefaces()` returns the raw inventory.
|
|
104
109
|
|
|
105
110
|
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.
|
|
106
111
|
|
|
@@ -118,10 +123,12 @@ This intentionally tightens the previous host-dependent `Date` parsing contract.
|
|
|
118
123
|
|
|
119
124
|
### Quote provenance (FF-57)
|
|
120
125
|
|
|
121
|
-
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
|
|
126
|
+
A `quote` payload exports as native text lines: the body (the quoted text wrapped in straight quotation marks) and, when there is an attribution, role or source, one footer: the attribution, the `role` on its own line below it, and the source after ` - ` on the last line. 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 each separator falls, whether the quote has a photo, 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.
|
|
122
127
|
|
|
123
128
|
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.
|
|
124
129
|
|
|
130
|
+
A quote with a `photo` (FA-12) also exports the headshot as one native picture in the circle frame core computes beside the footer lines: cropped to fill it (`a:srcRect`), with the `ellipse` preset geometry, the asset's alt text as the picture description, and the same `OPF_QUOTE_V1` group tag (role `photo`, no words). Import restores `role` from the footer and `photo` (`src`, `alt`) from the current picture; a picture that was deleted imports the quote without a photo and reports `quote-photo-missing`, and a renamed or duplicated one rejects the group like a damaged text line. An SVG photo exports as its PNG raster (`svg-image-rasterized`), because the circular mask is not confirmed on an SVG picture in PowerPoint. An unresolved photo source draws the ordinary image placeholder.
|
|
131
|
+
|
|
125
132
|
### New in 0.10.0: current native body formatting
|
|
126
133
|
|
|
127
134
|
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.
|
|
@@ -132,9 +139,10 @@ The first importer is mechanical and schema-compatible:
|
|
|
132
139
|
|
|
133
140
|
- Presentation core properties map to OPF `name`, `description`, and `author`.
|
|
134
141
|
- The first slide master's theme `clrScheme` maps to `design.colorScheme`: a catalog id on an exact twelve-slot match, otherwise inline slots. A theme named like a catalog theme maps to `design.theme` when its colors or heading/body fonts corroborate it (see [Theme color scheme](#theme-color-scheme)).
|
|
142
|
+
- Rich headline text (FA-10): a `TextRun[]` `title`, `subtitle` or `tag` exports as one tagged native text box per laid-out line with its runs (color, weight, italic, links, super/subscript, citation markers), and a `TextRun[]` `quote.text` the same way under the quote tags. Import returns `TextRun[]` where a heading's native runs are formatted differently from one another and a plain string where they are uniform: a tagged export records the heading's own weight, color, size and family (`base`), so only authored run formatting comes back, and a native title placeholder is read like body text, dropping what every run shares. A weight that the export writes as bold on every run of a title (and on a quote body) is the default, so only a `bold: false` word in a title is kept; `bold: true` in a quote body is indistinguishable from the body weight and is not restored. Marker runs become `cite`/`footnote` again, and heading and quote runs take part in the run-color reference slots.
|
|
135
143
|
- Native title/subtitle placeholders retain their roles. On slides without complete OPF heading tags, recognizable text-box positions and sizes provide a fallback. If any complete OPF heading role is recovered, untagged body text stays in `blocks[]` instead of being promoted into an absent heading role. Damaged tags retain visible text through ordinary import and diagnostics.
|
|
136
144
|
- Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
|
|
137
|
-
- PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to OPF chart blocks whose `type` is the core chart type id for the native construct (for example a `percentStacked` column chart imports as `100pct-stacked-column
|
|
145
|
+
- PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to OPF chart blocks whose `type` is the core chart type id for the native construct (for example a `percentStacked` column chart imports as `100pct-stacked-column`, a filled radar as `filled-radar`; a scatter chart imports as `[Point, X, ...series]`).
|
|
138
146
|
- Chart cache points are placed by their native `c:pt@idx`, with `c:ptCount` retaining trailing gaps. Missing labels and values become `null`; explicitly empty labels and series names stay empty strings, and an actual numeric zero stays zero. Empty numeric cache values remain missing. Malformed or duplicate indices, contradictory counts, competing caches and allocation limits reject with `invalid-chart-cache` and the chart-part/cache path; hierarchical category caches reject with `unsupported-chart-cache` instead of flattening labels. Each cache is limited to 100,000 positions, and combined caches and the emitted table each to 1,000,000 cells. Complete decimal scientific notation in numeric caches is read as a number; exponent overflow or nonzero underflow to zero rejects with `unsupported-chart-cache` and the chart part, series, cache and logical point path. Zero coefficients remain zero. This finite-number boundary does not classify rejected text as invalid OOXML. Malformed exponent strings and other nonblank numeric text retain the existing legacy conversion policy. This cache-only import does not repair embedded worksheets, restore unsupported scatter X/point labels, change exporter/workbook parsing or export/re-export missing-data handling, or establish native chart fidelity.
|
|
139
147
|
- Table imports retain empty rows. A native `firstRow` flag of `1` or `true` maps the first row to column labels; absent/false flags retain every row as data. New exports set this flag from OPF columns. Older exports without the flag retain their labels as the first data row rather than inferring headers.
|
|
140
148
|
- Native table text preserves run/field/break order, significant whitespace, and blank paragraphs. Cells return canonical `{value, style}` objects; `value` retains scalar text or supported rich runs. Covered merge positions are `null`. Explicit normal headers override OPF’s bold header default. Numeric/boolean/null source types cannot be reconstructed from native display text.
|
|
@@ -156,15 +164,15 @@ A deck exported by this package carries OPF provenance tags, so its structure re
|
|
|
156
164
|
```js
|
|
157
165
|
import { fromPptx } from "@openpresentation/opf-pptx";
|
|
158
166
|
|
|
159
|
-
// Default: the
|
|
160
|
-
const
|
|
167
|
+
// Default: the presentation.
|
|
168
|
+
const presentation = await fromPptx(bytes);
|
|
161
169
|
|
|
162
|
-
// Opt in: the same
|
|
163
|
-
const {
|
|
170
|
+
// Opt in: the same presentation, and a signals value beside it.
|
|
171
|
+
const { presentation: same, signals } = await fromPptx(bytes, { signals: true });
|
|
164
172
|
// or with limits: fromPptx(bytes, { signals: { maxSlides: 100, maxShapesPerSlide: 150 } })
|
|
165
173
|
```
|
|
166
174
|
|
|
167
|
-
Without `signals` (or with `false`) the return value and the document are byte for byte what they were; with it the
|
|
175
|
+
Without `signals` (or with `false`) the return value and the document are byte for byte what they were; with it the presentation is identical and the result is `{presentation, signals}`. Invalid limits throw `OPFPptxError` with code `invalid-signals-option` before any work. `signals` is plain JSON (`JSON.stringify` and back loses nothing), the same bytes in always give the same signals, and the types are `PptxSignals` in `index.d.ts`.
|
|
168
176
|
|
|
169
177
|
**What it reports.** `signals.deck` has the slide size (EMU and reference px, 96 per inch), the theme (colour and font scheme names, major and minor font, the twelve colour slots) and whether the deck carries OPF tags. `signals.slides[i]` has the part, layout (`name`, `type`, part), master (part, name, theme name), whether the slide is hidden, whether it carries an OPF slide tag, and `shapes`: every shape in z-order (document order, back to front; `zOrder` 0 is the back), each with:
|
|
170
178
|
|
|
@@ -180,6 +188,10 @@ Without `signals` (or with `false`) the return value and the document are byte f
|
|
|
180
188
|
|
|
181
189
|
**Not claims.** Signals are facts, not conclusions: a `monospace` run is not a code block, and `opf: null` is not an error. They are read from the package, not rendered: the effective values follow the OOXML inheritance rules, not PowerPoint's layout engine (text wrapping and autofit are not computed, a group's rotation is not applied to its members' boxes, and an unresolvable colour or font is left out). They describe the upload, which can be hostile: the text is the deck's text, and a host that passes it on to a model should treat it as untrusted data, never as instructions.
|
|
182
190
|
|
|
191
|
+
## Combo charts (FA-15)
|
|
192
|
+
|
|
193
|
+
`type: "combo"` is exported as one classic chart part: one `c:plotArea` with a clustered column `c:barChart` on the primary axes and a `c:lineChart` (markers, `c:grouping standard`) for the line series. Lines named in `chart.secondaryAxis` form a second `c:lineChart` on a second `c:valAx` (`axPos r`, `crosses max`, its first series' number format, no gridlines, `axisTitles.secondary` as its `c:title`) and a deleted second `c:catAx`. Core's `resolveChartData` orders the series (columns, then primary-axis lines, then secondary-axis lines), each keeps its palette colour by its place in the whole chart, the embedded workbook holds every series, and a gap breaks a line (`c:dispBlanksAs gap`) as in the preview. The line groups are rewritten into `dml-chart.xsd` order (`c:grouping` first, no `c:invertIfNegative`, `c:marker` before `c:dLbls`). Import reads a plot area with one clustered column group and one or two line groups as `combo`, with `line` (only when it is not the default last series), `secondaryAxis`, the secondary axis title and the data label position; the authored column order comes back through the chart's data record.
|
|
194
|
+
|
|
183
195
|
## Chartex charts (FF-22b)
|
|
184
196
|
|
|
185
197
|
**Modes.** `toPptx(document, {chartex})` takes `"auto"` (default), `"native"` or `"fallback"`. The native check of 2026-09-30 (desktop PowerPoint, read-only, the program's FF-22b deck set) opened every chartex deck without a repair prompt and reported the native `Chart.ChartType` for treemap (117), histogram (118, by value and by category), pareto (122), box-and-whisker (121), waterfall (119) and funnel (123); for `world` PowerPoint took the `mc:Fallback` (51, clustered column) when the choice required `cx5`. The recheck of the same day showed that PowerPoint accepts the regionMap part when the choice requires `cx4` (the 2016/5/10 region-map namespace; `cx3`, `cx4` and `cx6` reported ChartType 140, `cx5` and `cx8` fell back) but, with no `cx:geoCache`, shows "There was a problem getting the information for your map chart" and draws nothing until it can fetch map data online. Supervisor decision: `"auto"` writes the chartex parts for the six confirmed constructs and keeps `world` on the clustered column chart, which always renders, with `chart-data-adapted` (`chartex-fallback`); `"native"` writes every chartex part, the map included with `Requires="cx4"` and the `chart-map-geodata` diagnostic (provider data is never fabricated); `"fallback"` writes clustered columns only, byte for byte as before FF-22b (`test/fixtures/chartex-fallback-main.json` pins it, with `chartex-fallback` and `histogram-binned`). The same check showed chartex labels drawn in the chart style's theme grey and box lines in black on a dark theme, so every chartex text element and the style part now carry the deck's label colour and font explicitly, and box series carry a line in the label colour. Import of chartex parts is always on.
|
|
@@ -222,7 +234,13 @@ When the package carries an FF-32 stored `language` ([document round trip](docs/
|
|
|
222
234
|
|
|
223
235
|
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.
|
|
224
236
|
|
|
225
|
-
|
|
237
|
+
## Small conveniences (FA-13)
|
|
238
|
+
|
|
239
|
+
- **`code.highlight`** exports one native rectangle per run of marked lines (`OPF code N highlight M`) between the code panel and the one-text-box-per-line shapes; marked lines keep the syntax colours on the band and the others are dimmed, both at least 4.5:1 (core `codeHighlightColors`). The bands carry code provenance tags, so they never import as text and a band deleted in PowerPoint loses nothing; import restores `highlight` from the code tag.
|
|
240
|
+
- **Text watermark** (`design.watermark = { text, opacity }`, or a slide's own): one native text box per slide named `OPF watermark text`, first in the shape tree, in the heading font, bold, the theme text color with the opacity as text alpha, centered and rotated 30 degrees counterclockwise (`a:xfrm rot="19800000"`), sized by core `layoutWatermark`. It carries an `OPF_WATERMARK_V1` tag with `kind: "text"`; import restores `design.watermark` from an unchanged box and reports `invalid-watermark-provenance` for an edited one (the text then imports as ordinary text).
|
|
241
|
+
- **`TextRun.code`** writes the design's code family (the font scheme's `code` role, else Roboto Mono) on the run. In a package this exporter wrote, import reads a run in the design's code family back as `code: true`.
|
|
242
|
+
- **`TextRun.lang`** writes `a:rPr lang` (core's curated OOXML tag for the language, such as `fr-FR` for `fr`) with `altLang="en-US"`, plus the run's own East Asian and complex-script faces for that language (`Meiryo` for ja-JP, `Microsoft YaHei` for zh-CN) where the language selects one. A run in the deck language writes no override. Import reads a run `lang` that differs from the deck language back into body text runs.
|
|
243
|
+
- **Presets `1:1`, `4:5`, `9:16`** export as `p:sldSz` 6858000 x 6858000, 6858000 x 8572500 and 6858000 x 12192000 EMU. The exporter's document provenance restores the authored preset on import; a package without it imports the exact size as inches, which resolve to the same canvas.
|
|
226
244
|
|
|
227
245
|
## Numbered lists (RR-33)
|
|
228
246
|
|
|
@@ -236,7 +254,7 @@ A deck that declares content variables, or is marked `"template": true`, is reso
|
|
|
236
254
|
const pptx = await toPptx(template, { variables: { client: 'Globex', revenue: 1250000 } });
|
|
237
255
|
```
|
|
238
256
|
|
|
239
|
-
A template exports with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic`; a normal deck with an unfilled required variable throws `OPFPptxError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. A template plus values exports the same bytes as the equivalent hand-written deck (`test/template-variables.mjs`). The package stores the resolved deck, not the template form: `fromPptx` returns the filled deck. Decks without content variables are untouched.
|
|
257
|
+
A template exports with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic` (a built-in variable such as `{{speaker.name}}` is read from the document's own `speaker`, `organization` and `name`; one with no source value exports as nothing and reports `variable-builtin-missing`); a normal deck with an unfilled required variable throws `OPFPptxError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. A template plus values exports the same bytes as the equivalent hand-written deck (`test/template-variables.mjs`). The package stores the resolved deck, not the template form: `fromPptx` returns the filled deck. Decks without content variables are untouched. See [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md).
|
|
240
258
|
|
|
241
259
|
## Runtime Policy
|
|
242
260
|
|
|
@@ -277,9 +295,9 @@ The current checkout uses `@openpresentation/opf/composition` for portable geome
|
|
|
277
295
|
|
|
278
296
|
Version 0.4.0 requires published `@openpresentation/opf@^0.6.0`. The optional renderer peer requires `@openpresentation/opf-render@^0.4.0`. Clean registry installs support the new composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
|
|
279
297
|
|
|
280
|
-
For crowded drafts, run `
|
|
298
|
+
For crowded drafts, run `paginate(presentation, { fonts })` from `@openpresentation/opf` first, then pass its returned presentation to both preview and `toPptx`. Native table row sizing now follows shared reference geometry; the exporter does not add hidden table continuation slides.
|
|
281
299
|
|
|
282
|
-
Pass the same `
|
|
300
|
+
Pass the same `fonts` handle used by preview and pagination to `toPptx`. Plain text and headings retain the measured line breaks in editable PowerPoint shapes.
|
|
283
301
|
|
|
284
302
|
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.
|
|
285
303
|
|
|
@@ -289,7 +307,7 @@ The exact [pptxgenjs-plus](https://github.com/lofcz/pptxgenjs-plus) 4.3.4 ESM di
|
|
|
289
307
|
|
|
290
308
|
Version 0.4.0 imports and exports supported rich table cells and headers as editable runs, retaining resolved fonts, emphasis, color/alpha, hyperlinks, script positions and explicit line breaks. Import reads native XML, including paragraph defaults and significant whitespace; unstyled body cells remain strings. Shared core 0.6.0 layout grows rows for multiline content and fits text consistently with renderer 0.4.0 without inserting measured soft wraps. Native PowerPoint rendering remains unverified.
|
|
291
309
|
|
|
292
|
-
The exporter measures every cell with the same
|
|
310
|
+
The exporter measures every cell with the same fonts handle, font roles and effective nested `minFontSize` used by the SVG preview. Native table cells retain the original strings and values as text, with matching fitted sizes, line spacing, alignment, margins and row/column geometry. Uneven rows receive empty cells for missing columns. Theme border colors now use the same slot as the preview.
|
|
293
311
|
|
|
294
312
|
`npm test` compares exported OOXML against the published SVG renderer across 168 cells, including 24 cases that require taller rows, Roboto, and Calibri measured with its metric-compatible Carlito substitute (the cells still name Calibri), two canvas sizes, headers and all three alignments. PowerPoint still performs its own natural wrapping and needs the named fonts installed. These document-property checks do not establish native raster parity or lossless typed-cell import.
|
|
295
313
|
|
|
@@ -310,7 +328,7 @@ Slide-image treatments export from core's normalized geometry as native DrawingM
|
|
|
310
328
|
- `recolor` becomes `a:grayscl` or `a:duotone`, followed by `a:alphaModFix` for `opacity`, on the blip only.
|
|
311
329
|
- `overlay` becomes one tagged `OPF slide image overlay slides.N` shape directly above the picture.
|
|
312
330
|
|
|
313
|
-
A deck or slide watermark (`design.watermark`) exports as one native picture named `OPF watermark` per slide, after the slide image and its overlay, if any, and before all content (the preview's paint order, so a background slide image lies beneath the watermark), at the preview's frame: the centered 40% by 40% box at 30%/30% of the slide, fitted without cropping. Its opacity is `a:alphaModFix` (the object form's `opacity`, otherwise the preview default 0.08), and its alt text is the asset's `alt`, otherwise `Watermark`. `design.watermark = false` on a slide exports none. An unchanged export imports back as `design.watermark` (at deck level when every slide carries the same one); an edited picture stays ordinary content and reports `invalid-watermark-provenance`. An unresolved image reports `unresolved-asset`
|
|
331
|
+
A deck or slide watermark (`design.watermark`) exports as one native picture named `OPF watermark` per slide, after the slide image and its overlay, if any, and before all content (the preview's paint order, so a background slide image lies beneath the watermark), at the preview's frame: the centered 40% by 40% box at 30%/30% of the slide, fitted without cropping. Its opacity is `a:alphaModFix` (the object form's `opacity`, otherwise the preview default 0.08), and its alt text is the asset's `alt`, otherwise `Watermark`. `design.watermark = false` on a slide exports none. An unchanged export imports back as `design.watermark` (at deck level when every slide carries the same one); an edited picture stays ordinary content and reports `invalid-watermark-provenance`. An unresolved image reports `unresolved-asset`; `src` is required by the schema, so a watermark with no image source is a validation error and not an export diagnostic.
|
|
314
332
|
|
|
315
333
|
### Brand assets, picture bullets and the accent font (spec-gap closure A)
|
|
316
334
|
|
|
@@ -319,7 +337,7 @@ These design fields need the core that composes them (`resolveLogo`, `geometry.l
|
|
|
319
337
|
- **Cover and section logo** (`design.logo`, a slide's own `design.logo`, else the primary organization's `logo`). One native picture named `OPF logo`, after the watermark and before the content, fitted without cropping into core's `geometry.logo` box (56 px high at 720 px, up to four times as wide), anchored at the box's left edge and vertically centered, exactly the preview's `xMinYMid meet`. Its alt text is the asset's `alt`, otherwise `Logo`. A LogoSet picks its variant from the slide background (a dark background takes the light variants); the exporter's dark test is the one that already chooses the text colour. An unresolved or unreadable logo draws the preview's "Image unavailable" panel in the box and reports `unresolved-asset` at the logo's path (`strictAssets` throws). Content slides never get one. The picture carries an `OPF_LOGO_V1` tag (slide, source path, variant, the exact picture properties): an unchanged export consumes the picture on import (it is not content; `design.logo` itself returns from the document tag), and an edited, ambiguous or damaged one stays an ordinary picture and reports `invalid-logo-provenance`. When nothing else restored a logo (an export with `provenance: false`, or with `references-only`, which stores no sources), the consumed picture's own image becomes `design.logo` (the slide's own for a slide-level logo), so the logo is never lost; it is a single image, not the LogoSet. A PPTX with no OPF tags imports the picture as an ordinary image block and invents no `design.logo`.
|
|
320
338
|
- **Header and footer `logo: true`.** A generated image part in the part's box (the icon variant), exported like an `image` part with the furniture picture tag replaced by an `OPF_LOGO_V1` tag with role `furniture`. The slide's furniture manifest lists it under a separate `logos` key (`{kind, zone, drawn}`), never in `parts` or `definitions`, because every released importer validates those two strictly. It re-imports as `logo: true`, never as an `image` data URI; without a resolvable logo core reports `unresolved-content` at `<zone>.logo`, nothing is drawn and `logo: true` still returns (`drawn: false`).
|
|
321
339
|
- **Picture bullets** (`design.listBullet: image`, resolved to the icon logo by core as `item.bulletImage`). Every entry's first line carries `<a:buSzPct val="100000"/><a:buBlip><a:blip r:embed=…/></a:buBlip>` in place of the character bullet, so the marker is the text size, like the preview's square marker. PptxGenJS embeds the icon once per slide (a picture named `OPF bullet image`); packaging removes that picture and keeps the media part and its relationship for the `a:buBlip` elements. Marker indent and text positions are those of the character bullets. An icon that cannot be embedded keeps the character bullets and reports `unresolved-asset` once per slide at the logo's path. Import recognizes `a:buBlip` paragraphs as list paragraphs; `design.listBullet` returns from the stored design hint (a PPTX without tags has no hint). PowerPoint shows a picture bullet at the picture's own aspect ratio; use a square icon for the preview's square marker. The released 0.11.6 importer does not know `a:buBlip` and imports such a list as plain text lines.
|
|
322
|
-
- **`fontScheme.accent`.** Core resolves the accent role for the slide tag (eyebrow) and the quote body, so their runs carry the accent typeface in `a:latin`, `a:ea` and `a:cs` with the catalog's pitch family when the family is known; theme major/minor fonts, every other run and the theme are unchanged. The accent family appears in `docProps/app.xml` "Fonts Used" like any other used font; `
|
|
340
|
+
- **`fontScheme.accent`.** Core resolves the accent role for the slide tag (eyebrow) and the quote body, so their runs carry the accent typeface in `a:latin`, `a:ea` and `a:cs` with the catalog's pitch family when the family is known; theme major/minor fonts, every other run and the theme are unchanged. The accent family appears in `docProps/app.xml` "Fonts Used" like any other used font; `checkTypefaces` callers list it in `options.fonts`. `design.fontScheme` (with `accent`) returns from the document tag.
|
|
323
341
|
|
|
324
342
|
An unchanged export imports every treatment field back. An edited overlay drops only the overlay and reports `invalid-slide-image-provenance`. An edited picture or effect drops the slide image. See core `docs/image-treatments.md` for the vocabulary and the unsupported effects: blur, shadows, soft edges and background removal. PowerPoint's luminance weights for grayscale and duotone are unverified natively.
|
|
325
343
|
|
|
@@ -37,7 +37,7 @@ export function addFitLines(slide, value, fit, box, context, helpers, {objectNam
|
|
|
37
37
|
return {text: fragment.text, options: {...helpers.nativeFontOptions(fragment.style), fontSize: (fragment.nominalSize ?? fragment.fontSize) * .75, color: native,
|
|
38
38
|
underline: fragment.run.underline ? {style: 'sng', color: native} : undefined, strike: fragment.run.strikethrough ? 'sngStrike' : undefined,
|
|
39
39
|
baseline: fragment.baselineShift ? -fragment.baselineShift / (fragment.nominalSize ?? fragment.fontSize) * 2000 : undefined,
|
|
40
|
-
hyperlink: fragment.kind !== 'marker' && fragment.run.link && /^(https?:|mailto:)/i.test(fragment.run.link) ? {url: fragment.run.link} : undefined}};
|
|
40
|
+
hyperlink: fragment.kind !== 'marker' && fragment.run.link && /^(https?:|mailto:|tel:)/i.test(fragment.run.link) ? {url: fragment.run.link} : undefined}};
|
|
41
41
|
});
|
|
42
42
|
if (!runs.length) runs.push({text: '', options: {}});
|
|
43
43
|
const placed = fit.placement?.lines[index];
|
|
@@ -176,6 +176,8 @@ function runArrays(slide) {
|
|
|
176
176
|
if (!object(host)) return;
|
|
177
177
|
if (Array.isArray(host.blocks)) { host.blocks.forEach((block, index) => visit(block, `${path}.blocks.${index}`)); return; }
|
|
178
178
|
if (Array.isArray(host.text)) found.push({path: `${path}.text`, get: () => host.text, set: value => { host.text = value; }});
|
|
179
|
+
// FA-10: a quote's text may carry markers too.
|
|
180
|
+
if (object(host.quote) && Array.isArray(host.quote.text)) found.push({path: `${path}.quote.text`, get: () => host.quote.text, set: value => { host.quote.text = value; }});
|
|
179
181
|
for (const field of ['bullets', 'items']) if (Array.isArray(host[field])) host[field].forEach((item, index) => {
|
|
180
182
|
if (Array.isArray(item)) found.push({path: `${path}.${field}.${index}`, get: () => host[field][index], set: value => { host[field][index] = value; }});
|
|
181
183
|
else if (object(item)) {
|
|
@@ -184,6 +186,8 @@ function runArrays(slide) {
|
|
|
184
186
|
}
|
|
185
187
|
});
|
|
186
188
|
};
|
|
189
|
+
// FA-10: the heading group (tag, title, subtitle) may carry markers when it is TextRun[].
|
|
190
|
+
for (const field of ['tag', 'title', 'subtitle']) if (Array.isArray(slide[field])) found.push({path: field, get: () => slide[field], set: value => { slide[field] = value; }});
|
|
187
191
|
const keys = Object.keys(slide).filter(key => /^(top|middle|bottom|left|center|right)([+:]|$)/.test(key) && object(slide[key]));
|
|
188
192
|
if (keys.length) for (const key of keys.sort()) visit(slide[key], key); else visit(slide, '');
|
|
189
193
|
return found;
|
package/dist/background.js
CHANGED
|
@@ -57,8 +57,6 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
|
|
|
57
57
|
}
|
|
58
58
|
if (background.type !== 'gradient') return null;
|
|
59
59
|
const stops = background.gradient?.stops ?? [];
|
|
60
|
-
if (!stops.length) return '<a:noFill/>';
|
|
61
|
-
if (stops.length === 1) return `<a:solidFill>${paint(stops[0].color, fallback)}</a:solidFill>`;
|
|
62
60
|
const radians = turn(background.gradient?.angle ?? 0) * Math.PI / 180;
|
|
63
61
|
const c = Math.cos(radians), s = Math.sin(radians), span = Math.abs(c) + Math.abs(s);
|
|
64
62
|
const angle = Math.round(turn(Math.atan2(s / height, c / width) * 180 / Math.PI) * 60000) % 21600000;
|
|
@@ -215,7 +213,8 @@ export function readNativeBackground(properties, {width, height}, report = () =>
|
|
|
215
213
|
if (!c || !Number.isFinite(position) || position < -.00002 || position > 1.00002) return null;
|
|
216
214
|
return {...c, position: clamp(position)};
|
|
217
215
|
});
|
|
218
|
-
|
|
216
|
+
// The schema needs at least two stops (DrawingML does too).
|
|
217
|
+
if (stops.length < 2 || stops.some(stop => !stop)) return unsupported();
|
|
219
218
|
const alpha = stops[0].alpha, uniform = stops.every(stop => stop.alpha === alpha);
|
|
220
219
|
return {type: 'gradient', gradient: {angle, stops: stops.map(stop => ({position: stop.position,
|
|
221
220
|
color: stop.hex + (!uniform && stop.alpha !== 1 ? Math.round(stop.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '')}))},
|
package/dist/body-text-import.js
CHANGED
|
@@ -17,7 +17,10 @@ export const joinNativeParagraphs = paragraphs => value(paragraphs.flatMap((para
|
|
|
17
17
|
...(index ? ['\n'] : []), ...(Array.isArray(paragraph.richText) ? paragraph.richText : [paragraph.richText ?? paragraph.text])
|
|
18
18
|
]));
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// FA-13: a run language that differs from the deck language comes back as TextRun.lang; a run in the design's code family
|
|
21
|
+
// comes back as an inline code run. `extras` is {deckLang, codeFamily} (either may be absent).
|
|
22
|
+
const RUN_LANG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
23
|
+
function bodyRunStyle(properties, context, relationships, report, extras = {}) {
|
|
21
24
|
const supported = {...properties};
|
|
22
25
|
for (const key of ['b', 'i']) if (properties[key] !== undefined && !['1','0','true','false','on','off'].includes(properties[key])) {
|
|
23
26
|
report('unsupported-body-text-style', `The native ${key} value is not a supported boolean; current text was retained.`);
|
|
@@ -52,10 +55,19 @@ function bodyRunStyle(properties, context, relationships, report) {
|
|
|
52
55
|
(properties.cap !== undefined && properties.cap !== 'none') || (properties.spc !== undefined && Number(properties.spc) !== 0)) {
|
|
53
56
|
report('unsupported-body-text-style', 'Native text effects, capitalization, spacing or underline decoration are not represented by OPF run properties; current text was retained.');
|
|
54
57
|
}
|
|
55
|
-
|
|
58
|
+
const style = nativeRunStyle(supported, context, relationships, report, 'body');
|
|
59
|
+
const lang = properties.lang;
|
|
60
|
+
if (extras.deckLang && typeof lang === 'string' && RUN_LANG.test(lang) && !/^(?:und|zxx)(?:-|$)/i.test(lang) && lang.toLowerCase() !== extras.deckLang.toLowerCase()) style.lang = lang;
|
|
61
|
+
const family = typeof style.fontFamily === 'string' ? style.fontFamily.trim().toLowerCase() : undefined;
|
|
62
|
+
if (family && extras.codeFamily && family === extras.codeFamily.trim().toLowerCase()
|
|
63
|
+
&& !['a:majorFont', 'a:minorFont'].some(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase() === family)) {
|
|
64
|
+
style.code = true;
|
|
65
|
+
delete style.fontFamily;
|
|
66
|
+
}
|
|
67
|
+
return style;
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
export function nativeBodyReader(slidePath, archive, relationships, report) {
|
|
70
|
+
export function nativeBodyReader(slidePath, archive, relationships, report, extras = {}) {
|
|
59
71
|
let bodies, context;
|
|
60
72
|
return index => {
|
|
61
73
|
if (!bodies) {
|
|
@@ -80,7 +92,7 @@ export function nativeBodyReader(slidePath, archive, relationships, report) {
|
|
|
80
92
|
const properties = mergeNativeRunProperties(defaults, drawingObject(node[tag])['a:rPr']);
|
|
81
93
|
// RR-05: a Latin phrase of a right-to-left paragraph is its own en-US run; its East Asian/complex-script slots can render nothing in it, so the face loss is not reported.
|
|
82
94
|
const phrase = nodes(content, 'a:pPr')[0]?.[':@']?.rtl === '1' && properties.lang === 'en-US' && LATIN_ONLY.test(current);
|
|
83
|
-
const style = bodyRunStyle(properties, context, relationships, (code, message) => { if (!(phrase && code === 'unsupported-body-font')) report({code,message,path}); });
|
|
95
|
+
const style = bodyRunStyle(properties, context, relationships, (code, message) => { if (!(phrase && code === 'unsupported-body-font')) report({code,message,path}); }, phrase ? {...extras, deckLang: undefined} : extras);
|
|
84
96
|
if (current) runs.push(Object.keys(style).length ? {text:current,...style} : current);
|
|
85
97
|
}
|
|
86
98
|
return {text:runs.map(run => typeof run === 'string' ? run : run.text).join(''), richText:value(runs)};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// FA-09: the text alternative of a chart (`Chart.alt`) is the chart frame's `p:nvGraphicFramePr/p:cNvPr/@descr`, the attribute a
|
|
2
|
+
// picture's alt text uses. An empty `alt` marks the chart decorative, which PowerPoint stores as the `adec:decorative` extension of
|
|
3
|
+
// the same `p:cNvPr` ("Mark as decorative"), not as an empty `descr`: PowerPoint writes `descr=""` for any shape without alt text.
|
|
4
|
+
const DECORATIVE_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}';
|
|
5
|
+
const DECORATIVE_NS = 'http://schemas.microsoft.com/office/drawing/2017/decorative';
|
|
6
|
+
const ESCAPES = {'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '\r': ' ', '\n': ' ', '\t': '	'};
|
|
7
|
+
const escape = value => value.replace(/[&<>"'\r\n\t]/g, char => ESCAPES[char]);
|
|
8
|
+
const decorativeExt = `<a:ext uri="${DECORATIVE_URI}"><adec:decorative xmlns:adec="${DECORATIVE_NS}" val="1"/></a:ext>`;
|
|
9
|
+
const decorativeExtPattern = new RegExp(`<a:ext\\b[^>]*\\buri="${DECORATIVE_URI.replace(/[{}]/g, '\\$&')}"[^>]*>[\\s\\S]*?</a:ext>`, 'g');
|
|
10
|
+
|
|
11
|
+
// The one a:extLst of a p:cNvPr is edited in place: PowerPoint's own extensions (a16:creationId and others) stay untouched and in
|
|
12
|
+
// order, an earlier decorative ext is replaced and never duplicated, and an extLst left with no ext is removed.
|
|
13
|
+
function withDecorative(body, decorative) {
|
|
14
|
+
const list = body.match(/<a:extLst(?:\s[^>]*)?(?:\/>|>[\s\S]*?<\/a:extLst>)/);
|
|
15
|
+
const inner = list ? list[0].replace(/^<a:extLst(?:\s[^>]*)?>|<\/a:extLst>$|^<a:extLst(?:\s[^>]*)?\/>$/g, '').replace(decorativeExtPattern, '') : '';
|
|
16
|
+
const exts = decorative ? inner + decorativeExt : inner;
|
|
17
|
+
const next = exts.trim() === '' ? '' : `<a:extLst>${exts}</a:extLst>`;
|
|
18
|
+
return list ? body.slice(0, list.index) + next + body.slice(list.index + list[0].length) : body + next;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Write `alt` on the first `p:cNvPr` of a graphic frame: `descr` for text, the decorative extension for the empty string. */
|
|
22
|
+
export function writeFrameAlt(frame, alt) {
|
|
23
|
+
const start = frame.match(/<p:cNvPr\b[^>]*?(\/?)>/);
|
|
24
|
+
if (!start) return frame;
|
|
25
|
+
const selfClosing = start[1] === '/';
|
|
26
|
+
const end = selfClosing ? start.index + start[0].length : frame.indexOf('</p:cNvPr>', start.index);
|
|
27
|
+
if (end < 0) return frame;
|
|
28
|
+
let tag = start[0].replace(/\sdescr="[^"]*"/, '');
|
|
29
|
+
const body = withDecorative(selfClosing ? '' : frame.slice(start.index + start[0].length, end), alt === '');
|
|
30
|
+
if (alt !== '') tag = tag.replace(/\s*\/?>$/, match => ` descr="${escape(alt)}"${match.trim()}`);
|
|
31
|
+
const tail = selfClosing ? start.index + start[0].length : end + '</p:cNvPr>'.length;
|
|
32
|
+
const open = selfClosing && body ? tag.replace(/\s*\/>$/, '>') : tag;
|
|
33
|
+
const close = !selfClosing || body ? '</p:cNvPr>' : '';
|
|
34
|
+
return `${frame.slice(0, start.index)}${open}${body}${close}${frame.slice(tail)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Apply every recorded chart alt to the frames (classic and chartex fallback) of a slide part's XML, matched by frame name. */
|
|
38
|
+
export function applyChartAlt(xml, alts) {
|
|
39
|
+
return xml.replace(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g, frame => {
|
|
40
|
+
const name = frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
41
|
+
return alts.has(name) ? writeFrameAlt(frame, alts.get(name)) : frame;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const list = value => (Array.isArray(value) ? value : value === undefined || value === null ? [] : [value]);
|
|
46
|
+
|
|
47
|
+
/** The alt of a parsed `p:cNvPr`: its `descr`, `""` when marked decorative, or undefined. */
|
|
48
|
+
export function readFrameAlt(cNvPr) {
|
|
49
|
+
if (!cNvPr || typeof cNvPr !== 'object') return undefined;
|
|
50
|
+
const descr = cNvPr.descr;
|
|
51
|
+
if (typeof descr === 'string' && descr.trim() !== '') return descr;
|
|
52
|
+
const decorative = list(list(cNvPr['a:extLst'])[0]?.['a:ext']).some(ext => list(ext?.['adec:decorative']).some(node => node?.val === '1' || node?.val === 'true'));
|
|
53
|
+
return decorative ? '' : undefined;
|
|
54
|
+
}
|
package/dist/chart-data.js
CHANGED
|
@@ -6,59 +6,25 @@
|
|
|
6
6
|
// c:numFmt and the embedded workbook cell formats (src/chart-workbook.js). A chart without number formats is written
|
|
7
7
|
// exactly as before.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
import
|
|
9
|
+
// The core functions are re-exported from one place so the exporter and importer read the same ones: core 0.14 always has
|
|
10
|
+
// them, so there is no fallback to fall into.
|
|
11
|
+
import {
|
|
12
|
+
chartNumber, fromExcelNumberFormat, inlineChartData, inlineTableData, isDatasetRef, resolveChartData, resolveTableData, tableCellDisplayValue, toExcelNumberFormat
|
|
13
|
+
} from '@openpresentation/opf';
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
const STRICT_DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
|
|
15
|
-
|
|
16
|
-
/** Core `chartNumber` (strict decimal syntax; everything else is a gap). */
|
|
17
|
-
export const chartNumber = typeof opfCore.chartNumber === 'function' ? opfCore.chartNumber : value => {
|
|
18
|
-
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
|
19
|
-
if (typeof value !== 'string') return null;
|
|
20
|
-
const text = value.trim();
|
|
21
|
-
if (!STRICT_DECIMAL.test(text)) return null;
|
|
22
|
-
const number = Number(text);
|
|
23
|
-
return Number.isFinite(number) && (!Number.isInteger(number) || Number.isSafeInteger(number)) ? number : null;
|
|
24
|
-
};
|
|
25
|
-
/** NumberFormat -> Excel format code ("General" when absent). */
|
|
26
|
-
export const excelNumberFormat = typeof opfCore.excelNumberFormat === 'function' ? opfCore.excelNumberFormat : () => 'General';
|
|
27
|
-
/** Excel format code -> NumberFormat, or undefined when it has no exact equivalent. */
|
|
28
|
-
export const numberFormatFromExcel = typeof opfCore.numberFormatFromExcel === 'function' ? opfCore.numberFormatFromExcel : () => undefined;
|
|
29
|
-
export const isDatasetRef = typeof opfCore.isDatasetRef === 'function' ? opfCore.isDatasetRef : value => object(value) && typeof value.dataset === 'string';
|
|
30
|
-
export const inlineChartData = typeof opfCore.inlineChartData === 'function' ? opfCore.inlineChartData : chart => chart;
|
|
31
|
-
export const inlineTableData = typeof opfCore.inlineTableData === 'function' ? opfCore.inlineTableData : table => table;
|
|
32
|
-
export const tableCellDisplayValue = typeof opfCore.tableCellDisplayValue === 'function' ? opfCore.tableCellDisplayValue : cell => cell;
|
|
33
|
-
export const resolveTableData = typeof opfCore.resolveTableData === 'function' ? opfCore.resolveTableData
|
|
34
|
-
: table => ({...(Array.isArray(table?.columns) ? {columns: table.columns} : {}), rows: Array.isArray(table?.rows) ? table.rows : [], formats: [], diagnostics: []});
|
|
35
|
-
|
|
36
|
-
const columnName = column => typeof column === 'string' ? column : object(column) && typeof column.name === 'string' ? column.name : '';
|
|
37
|
-
|
|
38
|
-
// Before RR-54 core: inline positional data only (the schema of that core has no datasets or mapping).
|
|
39
|
-
function positionalChartData(chart) {
|
|
40
|
-
const data = chart?.data;
|
|
41
|
-
if (!object(data) || !Array.isArray(data.columns)) return {ok: false, reason: 'no-columns', message: 'The chart data has no columns.', diagnostics: []};
|
|
42
|
-
if (!data.columns.length) return {ok: false, reason: 'no-columns', message: 'The chart data has no columns.', diagnostics: []};
|
|
43
|
-
if (!Array.isArray(data.rows) || !data.rows.length) return {ok: false, reason: 'no-rows', message: 'The chart data has no rows.', diagnostics: []};
|
|
44
|
-
const diagnostics = [];
|
|
45
|
-
const rows = data.rows.map((row, rowIndex) => data.columns.map((_, index) => {
|
|
46
|
-
const cell = Array.isArray(row) && index < row.length ? row[index] : null;
|
|
47
|
-
if (index === 0) return cell === undefined ? null : cell;
|
|
48
|
-
const number = chartNumber(cell);
|
|
49
|
-
if (number === null && cell !== null && cell !== undefined && cell !== '') diagnostics.push({code: 'chart-value-not-numeric', severity: 'warning', path: `/data/rows/${rowIndex}/${index}`, message: `chart value ${JSON.stringify(cell)} is not a number; it is plotted as a gap.`});
|
|
50
|
-
return number;
|
|
51
|
-
}));
|
|
52
|
-
return {ok: true, columns: data.columns.map(columnName), formats: data.columns.map(() => undefined), rows, diagnostics};
|
|
53
|
-
}
|
|
15
|
+
export {chartNumber, fromExcelNumberFormat, inlineChartData, inlineTableData, isDatasetRef, resolveChartData, resolveTableData, tableCellDisplayValue, toExcelNumberFormat};
|
|
54
16
|
|
|
55
|
-
|
|
56
|
-
export const resolveChartData = typeof opfCore.resolveChartData === 'function' ? opfCore.resolveChartData : positionalChartData;
|
|
17
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
57
18
|
|
|
58
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* True when a chart uses an RR-54 data field that the native cache alone cannot carry back, or is a combo chart whose `line` or
|
|
21
|
+
* `secondaryAxis` exports its series in another order than the data's (FA-15: column series first, then lines), so the record
|
|
22
|
+
* restores the authored column order.
|
|
23
|
+
*/
|
|
59
24
|
export function chartUsesDataFields(chart) {
|
|
60
25
|
const data = chart?.data;
|
|
61
|
-
return object(chart) && (chart.mapping !== undefined || isDatasetRef(data) || (object(data) && (data.source !== undefined || (Array.isArray(data.columns) && data.columns.some(object))))
|
|
26
|
+
return object(chart) && (chart.mapping !== undefined || isDatasetRef(data) || (object(data) && (data.source !== undefined || (Array.isArray(data.columns) && data.columns.some(object))))
|
|
27
|
+
|| (chart.type === 'combo' && (chart.line !== undefined || chart.secondaryAxis !== undefined)));
|
|
62
28
|
}
|
|
63
29
|
|
|
64
30
|
/** True when a table is dataset-backed or carries a DataColumn header or a number format. */
|
|
@@ -76,7 +42,7 @@ const escapeXml = value => String(value).replace(/&/g, '&').replace(/</g, '&
|
|
|
76
42
|
/** The Excel code of a NumberFormat, or undefined when it is absent or General (the exporter's default). */
|
|
77
43
|
export function excelCode(format) {
|
|
78
44
|
if (format === undefined) return undefined;
|
|
79
|
-
const code =
|
|
45
|
+
const code = toExcelNumberFormat(format);
|
|
80
46
|
return code && code !== 'General' ? code : undefined;
|
|
81
47
|
}
|
|
82
48
|
|
|
@@ -85,12 +51,13 @@ export function excelCode(format) {
|
|
|
85
51
|
* series the Excel code of each c:ser, in document order (undefined keeps General);
|
|
86
52
|
* x the X column's code (scatter c:xVal and the horizontal value axis);
|
|
87
53
|
* axis the value axis code (the first plotted series' format, as Excel does), or undefined to keep the axis;
|
|
54
|
+
* secondaryAxis a combo chart's secondary value axis code (its first series' format, FA-15), or undefined to keep it;
|
|
88
55
|
* labels false when the labels show percentages (the percent label keeps PowerPoint's own percent form);
|
|
89
56
|
* scatter true for an XY chart (two value axes: axPos b/t is X).
|
|
90
57
|
* Every c:numCache of a series takes its code, every c:numFmt of a series' data labels too; the chart group's labels
|
|
91
58
|
* take the first series' code.
|
|
92
59
|
*/
|
|
93
|
-
export function applyChartNumberFormats(xml, {series = [], x, axis, labels = true, scatter = false}) {
|
|
60
|
+
export function applyChartNumberFormats(xml, {series = [], x, axis, secondaryAxis, labels = true, scatter = false}) {
|
|
94
61
|
// CT_NumData: formatCode is the cache's first child (PptxGenJS writes none on pie and doughnut series).
|
|
95
62
|
const formatCode = (text, code) => code === undefined ? text : /<c:formatCode>/.test(text)
|
|
96
63
|
? text.replace(/<c:formatCode>[^<]*<\/c:formatCode>/g, `<c:formatCode>${escapeXml(code)}</c:formatCode>`)
|
|
@@ -107,10 +74,18 @@ export function applyChartNumberFormats(xml, {series = [], x, axis, labels = tru
|
|
|
107
74
|
const result = ser.replace(/<c:(val|yVal|xVal)>[\s\S]*?<\/c:\1>/g, (role, name) => formatCode(role, name === 'xVal' ? x : code));
|
|
108
75
|
return labelFormats(result, code);
|
|
109
76
|
});
|
|
110
|
-
// The chart group's own label block (outside every c:ser) follows the first series.
|
|
111
|
-
|
|
77
|
+
// The chart group's own label block (outside every c:ser) follows the group's first series. Only a combo chart has more than
|
|
78
|
+
// one group (FA-15: its line groups follow their own first series).
|
|
79
|
+
let first = 0;
|
|
80
|
+
const grouped = out.replace(/<c:(barChart|lineChart|areaChart|pieChart|doughnutChart|scatterChart|radarChart)>[\s\S]*?<\/c:\1>/g, group => {
|
|
81
|
+
const code = series[first];
|
|
82
|
+
first += group.match(/<c:ser>/g)?.length ?? 0;
|
|
83
|
+
return group.split(/(<c:ser>[\s\S]*?<\/c:ser>)/).map((part, position) => position % 2 ? part : labelFormats(part, code)).join('');
|
|
84
|
+
});
|
|
85
|
+
// The first c:valAx is the value axis; a combo chart's second one is its secondary axis (a scatter chart's two are X and Y).
|
|
86
|
+
let valueAxis = 0;
|
|
112
87
|
return grouped.replace(/<c:valAx>[\s\S]*?<\/c:valAx>/g, valAx => {
|
|
113
|
-
const code = scatter
|
|
88
|
+
const code = scatter ? (/<c:axPos val="[bt]"\/>/.test(valAx) ? x : axis) : valueAxis++ === 0 ? axis : secondaryAxis;
|
|
114
89
|
return code === undefined ? valAx : valAx.replace(/<c:numFmt\b[^>]*\/>/, `<c:numFmt formatCode="${escapeXml(code)}" sourceLinked="0"/>`);
|
|
115
90
|
});
|
|
116
91
|
}
|
|
@@ -139,7 +114,7 @@ export function numericRanges(xml, {series = [], x}) {
|
|
|
139
114
|
*/
|
|
140
115
|
export function formatFromCode(code) {
|
|
141
116
|
if (typeof code !== 'string' || code.trim() === '' || /^general$/i.test(code.trim())) return {};
|
|
142
|
-
const format =
|
|
117
|
+
const format = fromExcelNumberFormat(code);
|
|
143
118
|
return format === undefined ? {unmapped: code} : {format};
|
|
144
119
|
}
|
|
145
120
|
|