@openpresentation/opf-pptx 0.9.0 → 0.10.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/DEPENDENCY-NOTES.md +1 -1
- package/README.md +108 -17
- package/dist/background-import.js +59 -3
- package/dist/background.js +86 -1
- package/dist/body-text-import.js +86 -0
- package/dist/code-provenance.js +9 -3
- package/dist/document-provenance.js +998 -0
- package/dist/furniture-fields.js +176 -0
- package/dist/furniture-provenance.js +138 -18
- package/dist/image-geometry.js +77 -5
- package/dist/index.d.ts +112 -2
- package/dist/index.js +869 -106
- package/dist/media-provenance.js +237 -0
- package/dist/native-text-style.js +57 -0
- package/dist/package-fonts.js +161 -0
- package/dist/script-fonts.js +290 -0
- package/dist/slide-image-provenance.js +169 -0
- package/dist/table-import.js +1 -53
- package/dist/theme-colors.js +204 -0
- package/dist/typeface-inventory.js +227 -0
- package/package.json +14 -7
package/DEPENDENCY-NOTES.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Coordinated `@openpresentation/opf` pin
|
|
4
4
|
|
|
5
|
-
PPTX 0.
|
|
5
|
+
PPTX 0.10.0 depends on published [`@openpresentation/opf@^0.11.1`](https://www.npmjs.com/package/@openpresentation/opf) (reference layer: ColorRef, document `variables`, exported `resolveColorRef()`). The optional renderer peer is [`@openpresentation/opf-render@^0.10.0`](https://www.npmjs.com/package/@openpresentation/opf-render) so editor 0.9.0 + render 0.10.0 + pptx 0.10.0 install together. CI checks out OPF [`b8a1faf`](https://github.com/OpenPresentation/opf/commit/b8a1faf24203635bd36b09ea19f44bcd53781f16) (`opf-v0.11.0`) for coordinated source linking. Named content colors resolve through core `resolveColorRef()`; eight-digit hex alpha stays a local export concern because core `normalizeHexColor` drops the alpha byte. Native `schemeClr` / theme `clrScheme` writes remain follow-up work.
|
|
6
6
|
|
|
7
7
|
OPF PPTX 0.5.1 ships the exact, unmodified PptxGenJS 4.0.1 ESM distribution in `vendor/pptxgenjs`, with its MIT license, upstream archive integrity and per-file SHA-256 hashes. Its actual JSZip dependency is declared directly. Ordinary npm installations therefore omit the unused image-size parser without requiring consumer overrides. This removes the affected dependency; it does not patch the parser. Published OPF PPTX 0.5.0 retains the older graph.
|
|
8
8
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
-
Version 0.
|
|
3
|
+
Version 0.10.0 requires core `^0.11.1` and the optional `@openpresentation/opf-render` 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
|
+
|
|
5
|
+
Version 0.9.1 kept core `^0.11.0` and raised the optional `@openpresentation/opf-render` peer to `^0.9.0` so it coexists with editor 0.8.0. ColorRef / `variables` on content colors still hex-resolve through core `resolveColorRef()` before PptxGenJS `srgbClr` export. Unrecognized run colors such as `color:'invalid'` still validate and fall back to the theme text color. Native DrawingML `schemeClr` and theme `clrScheme` writes, and native `p:hf` headers/footers, are not in this release. Import still flattens theme colors to hex. Metric, quote and timeline layout placeholders and the corrected text-bullet contract from 0.8.1 are retained.
|
|
4
6
|
|
|
5
7
|
Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
|
|
6
8
|
|
|
@@ -66,34 +68,88 @@ await fs.promises.writeFile("round-trip.pptx", roundTripBytes);
|
|
|
66
68
|
|
|
67
69
|
The importer reads core properties, slide order, text boxes, speaker notes, embedded images, tables, and basic cached chart data from the OOXML parts. Slides or objects that do not map cleanly fall back to editable `blocks[]` payloads; OOXML positions are used for deterministic ordering and title/subtitle detection while keeping the emitted OPF schema-valid.
|
|
68
70
|
|
|
71
|
+
### New in 0.10.0: native notes and property whitespace
|
|
72
|
+
|
|
73
|
+
Current source preserves spaces, tabs, NBSP and authored CR/LF/CRLF in speaker notes and scalar presentation `name`, `description` and `author`; published npm `0.9.1` does not contain this repair (0.10.0 does). Import reads current native notes body paragraphs in run/field/line-break order, retaining blank paragraphs. Explicit paragraph and line-break boundaries import as LF. XML character references decode once, so literal text such as ` ` remains literal. Export writes authored CR as character references in native text, without adding source-recovery tags; ordinary exports with no authored CR remain byte-identical.
|
|
74
|
+
|
|
75
|
+
Native edits, cleared/deleted note bodies or parts, core-property edits/deletions and slide relationship order remain authoritative in all provenance modes. Empty notes and absent notes still both import absent; an empty or absent title uses the existing fallback, and empty description/author import absent. Author arrays still export as a joined scalar. External literal XML line endings follow XML normalization; exact CR requires character references. These are portable XML conversion controls, not native Office or visual acceptance.
|
|
76
|
+
|
|
69
77
|
## v1 Placeholder and OOXML Mapping
|
|
70
78
|
|
|
71
79
|
The first exporter keeps the public API stable while using `pptxgenjs` internally:
|
|
72
80
|
|
|
73
81
|
- `Slide.title`, `Slide.subtitle`, and `Slide.tag` become editable text boxes, not PowerPoint master placeholders.
|
|
74
82
|
- Root payloads, `blocks[]`, and promoted region keys become editable slide objects in deterministic regions. Promoted keys use the OPF 3x3 region vocabulary (`top`, `middle`, `bottom`, `left`, `center`, `right`).
|
|
75
|
-
- 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>`)
|
|
83
|
+
- Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)).
|
|
84
|
+
- A chart whose data is one column of values (a histogram or dot plot) has no category column. It exports as a native chart and reports `chart-data-adapted`: a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts, and any other chart type plots the values against their row numbers. PowerPoint's own histogram chart is not exported (histogram is a column chart at this engine). Cells parse as in every chart ("12%", "$5" and "1,234" count) and cells that hold no number are skipped, not plotted as 0. Binned counts do not round-trip: re-importing the file gives a `Bin`/`Frequency` column chart, not the raw values. Chart data that cannot be plotted (no numbers, no rows, or an external data source), an empty table and unsupported content keep a placeholder frame with a plain-language description (never a dump of the source data or URLs) and report `chart-data-unplottable` or `content-placeholder` with a `reason`; content is never dropped without a diagnostic.
|
|
76
85
|
- Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path.
|
|
77
|
-
- ZIP entries, generated chart/workbook part names, core-property timestamps, and nested chart workbook timestamps are normalized for reproducible bytes.
|
|
86
|
+
- ZIP entries, generated chart/workbook part names, core-property timestamps, and nested chart workbook timestamps are normalized for reproducible bytes. [Export determinism](docs/export-determinism.md) lists the tested time zone, locale, clock and host-font controls and each known variance (WebP conversion, font registries, ICU segmentation, timestamps).
|
|
87
|
+
- The package names only the document's chosen fonts. Chart text (data labels, axes, legend, titles) uses the chart slide's body font in `latin`/`ea`/`cs`; each embedded chart workbook uses the same fonts in its styles and theme; run `pitchFamily` follows the font scheme type (monospace is fixed pitch, serif is roman); and `docProps/app.xml` "Fonts Used" lists the fonts the package actually uses. The theme keeps PptxGenJS's per-script supplements (`THEME_SCRIPT_SUPPLEMENTS`) and empty `ea`/`cs` slots.
|
|
88
|
+
- `checkPptxTypefaces(bytes, {fonts, monospace})` inventories every `typeface`, workbook font name and "Fonts Used" entry in every XML part, including nested packages, and reports each font outside that policy. `inventoryPptxTypefaces()` returns the raw inventory.
|
|
78
89
|
|
|
79
90
|
This pass did not require an OPF schema change. The deferred full OOXML placeholder mapping from `docs/plans/layout-placeholders.md` remains a later hand-written OOXML emitter concern.
|
|
80
91
|
|
|
92
|
+
### New in 0.10.0: explicit ZIP dates
|
|
93
|
+
|
|
94
|
+
The following behavior is in current source; the published npm `0.9.1` package does not contain this repair or tightened option contract; `0.10.0` does.
|
|
95
|
+
|
|
96
|
+
Omitting `zipDate` (or passing `undefined`) keeps the established fixed 1980 ZIP bytes. Explicit `zipDate` values now encode **UTC calendar fields** in both the PPTX and embedded workbook ZIPs, independently of the host timezone. This option changes ZIP metadata; the separate `timestamp` option controls core-property XML.
|
|
97
|
+
|
|
98
|
+
Accepted values are a valid `Date`, finite epoch milliseconds, `YYYY-MM-DD` (UTC midnight), or `YYYY-MM-DDTHH:mm[:ss[.fraction]]` ending in `Z` or `±HH:mm`. Calendar components must be valid and the resulting UTC year must be **1980–2099**, matching the existing ZIP writer's supported range. ZIP timestamps have two-second resolution: fractional and odd seconds are truncated.
|
|
99
|
+
|
|
100
|
+
This intentionally tightens the previous host-dependent `Date` parsing contract. Datetimes without a timezone, legacy date strings, invalid dates, and values outside that range throw `OPFPptxError` with code `invalid-zip-date` and path `options.zipDate`. Explicit `null`, `''` and `0` no longer silently use the default (`0` is a 1970 epoch date). Existing successful UTC output and default output remain byte-identical; explicit dates on other timezones change to the canonical UTC result.
|
|
101
|
+
|
|
81
102
|
## v1 Import Mapping
|
|
82
103
|
|
|
104
|
+
### New in 0.10.0: current native body formatting
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
This does not reconstruct original source run identities or boundaries between native shapes, infer master/layout text styles, or recover cached authored content. Native paragraph/break boundaries become LF; character-reference CR remains explicit. Unsupported properties report body-path diagnostics rather than claim exact formatting. Reading order still uses current native positions. Published npm `0.9.1` does not contain this representation change; portable conversion checks do not establish Office rendering or general rich-text round-trip fidelity.
|
|
109
|
+
|
|
83
110
|
The first importer is mechanical and schema-compatible:
|
|
84
111
|
|
|
85
112
|
- Presentation core properties map to OPF `name`, `description`, and `author`.
|
|
113
|
+
- 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)).
|
|
86
114
|
- 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.
|
|
87
115
|
- Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
|
|
88
116
|
- PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to basic OPF chart blocks.
|
|
117
|
+
- 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.
|
|
89
118
|
- 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.
|
|
90
119
|
- 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.
|
|
120
|
+
- Run `lang` maps back to the presentation `language` (a catalog id when it round-trips, else the tag); see [Languages, right-to-left text and script fonts](#languages-right-to-left-text-and-script-fonts).
|
|
91
121
|
- Imported runs retain bold, italic, underline, strike, point sizes, Latin font families, solid colors/alpha, external hyperlink URLs, and superscript/subscript direction. List-level and paragraph defaults apply before run overrides; supported theme fonts/colors resolve from the archive. Field values become their cached text, and underline/strike variants and baseline magnitudes reduce to OPF booleans.
|
|
92
122
|
- Conditional table styles, merged-cell geometry, cell fills/borders/alignment, unsupported text fills/colors, and internal hyperlink actions are not fully reconstructed. `onDiagnostic` reports unsupported table style references, merges, fonts/colors/fills and links with native frame/cell paths. Table paths use the native graphic-frame and row indexes, including a header row. The shared core 0.6.0 layout sizes rows from their content and reports `text-overflow` when text cannot fit at the minimum size. These checks establish native XML conversion, not visual parity with PowerPoint.
|
|
93
123
|
- Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
|
|
124
|
+
- Catalog references (`design.theme`, `colorScheme`, `fontScheme`, `dimensions`, `background`, slide `layout`), slide ids and authoring metadata (`narrative`, `tone`, `audience`, `purpose`, `language`, `organization`, `speaker`, ...) are stored at export in `OPF_DOCUMENT_V1` / `OPF_SLIDE_V1` customer-data tags. Import restores a reference while the theme colors, theme fonts, slide size, background or slide arrangement it produced are unchanged. After an edit, the observed native values stay and `design-reference-changed` / `layout-reference-changed` name the reference. `toPptx` option `provenance: 'references-only' | false` limits or disables these invisible tags. Slide layout intent (layout id, type, composition, composition hints and the inline layout record) lives in each `OPF_SLIDE_V1` record, so a slide keeps its layout even without the document tag, for example when it is pasted into another deck (FF-29). See [document round trips](docs/document-roundtrip.md) for exactly what is embedded.
|
|
94
125
|
|
|
95
126
|
There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
|
|
96
127
|
|
|
128
|
+
## Languages, right-to-left text and script fonts
|
|
129
|
+
|
|
130
|
+
The exporter reads the presentation `language` through core `resolveScriptFonts()` (FF-07; the model is core `docs/programs/font-fidelity-everywhere/script-font-model.md`):
|
|
131
|
+
|
|
132
|
+
- Every run, end-of-paragraph and default run property carries `lang` set to the resolved OOXML tag instead of a fixed `en-US`. That tag is the catalog's curated `ooxmlLang` (for example `ja-JP` or `ar-SA`) or an authored region tag such as `en-NZ`. `altLang` is not written: it names the editing-UI language, which OPF does not model.
|
|
133
|
+
- For a language that uses an East Asian or complex-script slot (CJK, Arabic, Hebrew, Indic, Thai and others), theme major/minor `a:ea`/`a:cs` name the resolved heading/body fonts instead of the vendored empty values. For a language written in the latin slot (Latin, Cyrillic, Greek and others) they stay empty unless the design font scheme sets `eastAsian`/`complexScript` explicitly. Filling them with the latin family is gated on FF-05: native evidence shows it does not remove the nameless and Aptos entries PowerPoint lists at open, and empty slots keep PowerPoint's per-script theme fallback for CJK or Arabic text typed later. So en-US decks export byte-identically.
|
|
134
|
+
- The theme's per-script entry for the language's own script (for example `Jpan`, `Hang`, `Arab` or `Deva`) names the resolver's supplement. The rest of the vendored Office per-script list is unchanged; that list is FF-08's call.
|
|
135
|
+
- Run `a:ea`/`a:cs` name the resolved slot when the language or the font scheme supplies a script-specific font, for example Meiryo in `a:ea` for Japanese or Arabic Typesetting in `a:cs` for Arabic. Headings take the heading font and other text the body font. Otherwise the slots keep repeating the run's latin face, so Latin, Cyrillic and Greek decks keep their run bytes.
|
|
136
|
+
- In a right-to-left deck, each slide and notes paragraph takes its direction from core `paragraphDirection(text, direction)`, the same rule the renderer uses: `rtl="1"` when its first strong character is right-to-left or it has none (digits, punctuation, empty), else an explicit `rtl="0"` (an English quote, a code line). The master, layout and presentation default paragraph levels start right-to-left only in a right-to-left deck. Left-to-right decks write no paragraph direction. Alignment is unchanged: `algn` stays the composed absolute alignment (left stays `l`), so native line placement keeps matching the renderer's geometry. Right-aligning RTL text by default is a composition decision in core; until then, set `contentAlignment` or `titleAlignment` to `right`.
|
|
137
|
+
- 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.
|
|
138
|
+
|
|
139
|
+
Charts keep their `c:lang` and left-to-right label paragraphs. The embedded chart workbook is untouched.
|
|
140
|
+
|
|
141
|
+
When the package carries an FF-32 stored `language` ([document round trip](docs/document-roundtrip.md)), that reference is restored while the runs still carry its OOXML tag (or none); if the runs now use another tag, the observed language below is kept and `metadata-reference-changed` is reported once. Otherwise `fromPptx` sets `language` from the most common run `lang`. It uses a catalog id when that record exports the same tag (`ja-JP` imports as `japanese`, `en-US` as `english-us`). Otherwise it keeps the tag itself (`en-NZ`), which still resolves to its catalog record. `english` (`en`) exports `en-US`, so it imports as `english-us`. Diagnostics:
|
|
142
|
+
|
|
143
|
+
- `mixed-run-languages`: runs use several tags; the most common one is imported.
|
|
144
|
+
- `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.
|
|
145
|
+
- `language-uncatalogued`: no catalog record matches; the tag is imported.
|
|
146
|
+
- `rtl-language-mismatch`: right-to-left paragraphs under a left-to-right language.
|
|
147
|
+
- `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.
|
|
148
|
+
|
|
149
|
+
Exports report `language-unresolved` when the document's language cannot be resolved locally (a URL, `pkg:` reference or unknown id) and `en-US` is used.
|
|
150
|
+
|
|
151
|
+
**Core without the resolver.** Core `@openpresentation/opf` 0.11.0 and earlier have no `resolveScriptFonts` (this release requires ^0.11.1, so this only applies to a forced older core). Export with it is byte-identical to the output before FF-07 (`lang="en-US"`, empty theme `ea`/`cs`, no `rtl`), and a document that names a language gets a `language-export-unavailable` diagnostic. A core with the resolver but without `paragraphDirection` marks no paragraph direction and reports `paragraph-direction-unavailable` for a right-to-left deck. Import then matches run tags against the installed catalog's `bcp47` and primary language. `npm run test:packed` exercises this path against the registry release. CI links core at a pinned commit that has the resolver.
|
|
152
|
+
|
|
97
153
|
## Runtime Policy
|
|
98
154
|
|
|
99
155
|
The package runtime must stay local and deterministic:
|
|
@@ -121,15 +177,11 @@ LibreOffice is not a runtime dependency. When it is installed in CI or a local v
|
|
|
121
177
|
|
|
122
178
|
## Release Lane
|
|
123
179
|
|
|
124
|
-
Public npm package publication is handled by `.github/workflows/release.yml` with npm provenance.
|
|
125
|
-
|
|
126
|
-
Required first-publish setup:
|
|
127
|
-
|
|
128
|
-
1. An npm owner for the `@openpresentation` scope must run the first publish or reserve/grant the `@openpresentation/opf-pptx` package.
|
|
129
|
-
2. Configure npm Trusted Publishing for GitHub repository `OpenPresentation/opf-pptx` and workflow `.github/workflows/release.yml`.
|
|
130
|
-
3. Publish by creating a GitHub Release or manually running the Release workflow after CI passes.
|
|
180
|
+
Public npm package publication is handled by `.github/workflows/release.yml` through npm Trusted Publishing (GitHub Actions OIDC) with npm provenance; no npm token is stored. The owner authorized agents to prepare and publish npm releases whenever a release is required (2026-09-29). This authorization does not waive any gate.
|
|
131
181
|
|
|
132
|
-
|
|
182
|
+
1. Open a release-prep PR containing only the version bump, `CHANGELOG.md`, dependency ranges, lockfile and current-instruction docs. Publish in dependency order (core, then renderer, then PPTX, then editor): refresh this repo's lockfile only after the required `@openpresentation/opf` and `@openpresentation/opf-render` versions are on the registry (`npm install --package-lock-only`), then run `npm run test:packed` against them.
|
|
183
|
+
2. Merge after CI is green, then publish by pushing the git tag `opf-pptx-v<version>` (or `@openpresentation/opf-pptx@v<version>`) at the merge commit. The workflow verifies that the tag matches `package.json` and reruns audit, typecheck, validate, tests, packed and browser checks before `npm publish --access public --provenance`. A manual `workflow_dispatch` runs the same job without the tag check and is a fallback only.
|
|
184
|
+
3. Verify with `npm view @openpresentation/opf-pptx@<version> version gitHead dist.attestations` and, from the core repo, `node scripts/test-pptx-publication.mjs <version> <release-commit> <this-checkout>`. Never republish an existing version.
|
|
133
185
|
|
|
134
186
|
## Shared dynamic composition
|
|
135
187
|
|
|
@@ -139,7 +191,9 @@ Version 0.4.0 requires published `@openpresentation/opf@^0.6.0`. The optional re
|
|
|
139
191
|
|
|
140
192
|
For crowded drafts, run `paginatePresentation` from `@openpresentation/opf/pagination` 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.
|
|
141
193
|
|
|
142
|
-
Pass the same `textMeasurement` provider used by preview and pagination to `toPptx`. Plain text and headings retain the measured line breaks
|
|
194
|
+
Pass the same `textMeasurement` provider used by preview and pagination to `toPptx`. Plain text and headings retain the measured line breaks in editable PowerPoint shapes.
|
|
195
|
+
|
|
196
|
+
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.
|
|
143
197
|
|
|
144
198
|
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.
|
|
145
199
|
|
|
@@ -149,7 +203,7 @@ Version 0.4.0 imports and exports supported rich table cells and headers as edit
|
|
|
149
203
|
|
|
150
204
|
The exporter measures every cell with the same `textMeasurement` provider, 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.
|
|
151
205
|
|
|
152
|
-
`npm test` compares exported OOXML against the published SVG renderer across 168 cells, including 24 cases that require taller rows, Roboto and Calibri-
|
|
206
|
+
`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.
|
|
153
207
|
|
|
154
208
|
A local macOS Quick Look check opened both Roboto and system-Arial specimens. Quick Look substituted a serif font for uninstalled Roboto; the Arial specimen used a sans-serif face but still differed in table wrapping and row proportions. This is evidence of remaining viewer differences, not a passing PowerPoint raster comparison.
|
|
155
209
|
|
|
@@ -159,6 +213,17 @@ Native image exports now follow the browser's `design.imageFill`: `fit` (the def
|
|
|
159
213
|
|
|
160
214
|
JPEG EXIF orientations 1–8 are represented by native picture rotation and mirroring. The embedded copy's orientation tag is normalized to 1 to avoid viewer-dependent double rotation. Compressed pixels and other metadata remain unchanged; input data is not mutated. EXIF orientation in other containers, animated playback, SVG/vector assets, effects and lossless crop/orientation import are not covered by this change.
|
|
161
215
|
|
|
216
|
+
A slide-level image (`design.slideImage`, composed by core as `geometry.slideImage`) exports as one native picture named `OPF slide image slides.N`. It sits beneath the slide's other shapes. Its frame is the shared composition frame for both fills: `crop` writes positive `a:srcRect` insets and `fit` writes negative insets that pad the centered image. The frame therefore matches the preview's `<image>` box exactly. An `OPF_SLIDE_IMAGE_V1` shape tag records the placement and the native picture geometry. On import, an unchanged tagged picture becomes the slide's `design.slideImage` again, with the embedded bytes as its data URI source and `imageFill: "fit"` when the frame was fitted. An edited, duplicated or ambiguous tagged picture is imported as an ordinary image block and reports `invalid-slide-image-provenance` at `slides.N.design.slideImage`. Native PowerPoint raster parity for negative `a:srcRect` insets has not been checked with Office yet.
|
|
217
|
+
|
|
218
|
+
Slide-image treatments export from core's normalized geometry as native DrawingML:
|
|
219
|
+
|
|
220
|
+
- `shape` becomes the picture's `a:prstGeom` (`rect`, `roundRect`, `ellipse` or `hexagon`) with core's guide values.
|
|
221
|
+
- `border` becomes a centered solid `a:ln` with a miter join.
|
|
222
|
+
- `recolor` becomes `a:grayscl` or `a:duotone`, followed by `a:alphaModFix` for `opacity`, on the blip only.
|
|
223
|
+
- `overlay` becomes one tagged `OPF slide image overlay slides.N` shape directly above the picture.
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
162
227
|
Tests compare SVG/native fit and crop geometry across nine synthetic raster fixtures and cover all eight JPEG orientations. Keynote 14.4 visually preserves proportions for wide/tall fit/crop and displays all eight orientations correctly. This does not establish Microsoft PowerPoint raster parity or WebP support in every Office version.
|
|
163
228
|
|
|
164
229
|
The structural export/import corpus gate covers every installed core example (126 decks / 805 slides for core 0.4.0). It explicitly substitutes a bundled fallback font and synthetic images, then checks slide XML, unique native object IDs, finite geometry, table grids and imported slide counts. It does not establish original-asset, typography or viewer fidelity. The focused table and image tests separately exercise measured geometry and real fixture bytes.
|
|
@@ -186,8 +251,30 @@ Diagonal gradients require a coordinate conversion: the preview uses an SVG obje
|
|
|
186
251
|
|
|
187
252
|
Import reads supported native RGB solid/linear fills directly; it uses no hidden source copy. Uniform alpha becomes OPF background opacity, and differing stop alpha uses eight-bit RGBA colors (which can round alpha). Native path gradients, color transforms outside the supported luminance/alpha set, non-default tile/flip geometry and stop intervals outside OPF's fixed-endpoint representation are not imported. Pass `fromPptx(bytes, {onDiagnostic: issue => ...})` to observe `unsupported-background-gradient` with a slide path.
|
|
188
253
|
|
|
189
|
-
Node 20/24 tests and the 126-deck / 805-slide structural corpus pass. This proves serialization and the mathematical mapping, not native viewer pixels. Keynote 14.4 recognizes the editable native gradients. Twelve captured native PNGs now support 18 comparisons, including a Keynote-generated PPTX import: opaque differences are at most 4/255 per channel (mean below 0.38), and transparent portrait alpha differs by at most 1/255. The checked-in references run in ordinary Node tests without Keynote. Quick Look still renders these specimens as a flat average color, so its thumbnails are not evidence of their native appearance. Microsoft PowerPoint remains unavailable and unverified.
|
|
254
|
+
Node 20/24 tests and the 126-deck / 805-slide structural corpus pass. This proves serialization and the mathematical mapping, not native viewer pixels. Keynote 14.4 recognizes the editable native gradients. Twelve captured native PNGs now support 18 comparisons, including a Keynote-generated PPTX import: opaque differences are at most 4/255 per channel (mean below 0.38), and transparent portrait alpha differs by at most 1/255. The checked-in references run in ordinary Node tests without Keynote. Quick Look still renders these specimens as a flat average color, so its thumbnails are not evidence of their native appearance. Microsoft PowerPoint remains unavailable and unverified. Theme-aware native fills and other design decorations remain separate fidelity work.
|
|
255
|
+
|
|
256
|
+
### Pattern and picture backgrounds (FF-25)
|
|
257
|
+
|
|
258
|
+
Pattern backgrounds export as a native `<a:pattFill>` with explicit foreground and background colors. OPF presets named by DrawingML's 54 `ST_PresetPatternVal` values (for example `pct5`, `ltHorz`, `openDmnd`, `wave`) are written unchanged. As in the SVG preview, a missing foreground uses the slide text color and a missing background uses white; text contrast follows the pattern's background color. The preview's engine id `diagStripe` has no DrawingML name. It is still accepted and is written as the closest preset, `wdUpDiag`; new documents should use `wdUpDiag` so that re-import keeps the name. Any other engine-defined id keeps only its background color, like the preview, and reports `unsupported-pattern`. Opacity becomes alpha on both colors. Under the FF-24 theme-color rule, a pattern color authored as a scheme slot or role becomes `a:schemeClr`, with the background opacity as alpha, when the deck theme holds exactly the color the preview draws. Like the preview, pattern colors resolve only hex, so any other name is drawn as the default color and stays literal RGB. Import resolves theme pattern colors to RGB.
|
|
190
259
|
|
|
260
|
+
Image backgrounds export as a native `<a:blipFill>` for the embedded raster: `data:` sources, declared `asset:` ids and `imageResolver` results. `cover` (the default) crops the centered source to the slide aspect with `a:srcRect`. `contain` letterboxes the source with `a:fillRect` insets. `tile` repeats square cells that are min(width, height)/4 in size, from the top-left, as the preview does. The deck `imageFill` decides whether each cell is covered or contained; contained cells use negative (transparent) source insets. With `dpi="0"`, a native tile is sized from the raster's own resolution: PNG `pHYs` in metres; JPEG JFIF density in inches or centimetres, otherwise EXIF `XResolution`/`YResolution`; 96 dpi when absent. The tile scale compensates on each axis, so a cell matches the preview's CSS pixels. WebP sources become PNG parts as for pictures. Opacity becomes `a:alphaModFix`. An unresolved image keeps the background color and reports `unresolved-asset` (`strictAssets` throws). A JPEG EXIF orientation cannot rotate a background fill, so it reports `unsupported-background-image-orientation`.
|
|
261
|
+
|
|
262
|
+
Import reads `a:pattFill` with a DrawingML preset and resolvable foreground/background colors, including slide/layout/master inheritance and theme references, as an OPF pattern background. Import does not guess undefined default colors. Import also reads embedded `a:blipFill` pictures as an image background with the exact image bytes as a `data:` URI. Tiles become `tile`. If the tile scale, offset, alignment, flip or source insets differ from the geometry OPF `tile` exports (contained cells at the raster's resolution), the import reports `approximate-background-image`. A centered crop matching the slide aspect becomes `cover`, and centered fill insets matching the image aspect become `contain`. Off-center, distorted or effect-bearing fills become the closest `cover` and report `approximate-background-image`. Linked or unreadable pictures report `unsupported-background-image`. `test/background-fills.mjs` covers export, import, native edits, inheritance and diagnostics. Microsoft PowerPoint rendering of these fills, especially its resolution-based tile scale and negative tile insets, has not been verified.
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
## Theme color scheme
|
|
266
|
+
|
|
267
|
+
Export writes the deck's resolved color scheme into `ppt/theme/theme1.xml` `a:clrScheme`, named after the scheme. The OPF slots map one to one: `dark1`/`light1`/`dark2`/`light2` to `dk1`/`lt1`/`dk2`/`lt2`, `accent1`-`accent6` to themselves, and `hyperlink`/`followedHyperlink` to `hlink`/`folHlink`. An abstract role fills a slot only when the scheme leaves that slot unset. Previously every export carried the vendored Office palette (`accent1` `4472C4`). When the deck names a catalog `design.theme`, `a:theme` and its `thm15:themeFamily` take that theme's name. The rewrite happens during package normalization; the vendored PptxGenJS bytes are unchanged.
|
|
268
|
+
|
|
269
|
+
Document colors that name a slot or role (`accent2`, `textSecondary`, `surface`, ...) become `a:schemeClr` in runs, table cell fills and text, and table borders. Theme-slot backgrounds (`{type:'theme', slot}` or a slot name) do the same. Slide content reaches the theme through the master color map, so `dark1`, `light1`, `dark2` and `light2` are written as `tx1`, `bg1`, `tx2` and `bg2`. A reference becomes `schemeClr` only when the deck theme slot holds exactly the color the slide resolved. PowerPoint has one theme per master, so a slide-level `design.colorScheme` that changes a slot keeps that color literal. These stay `a:srgbClr`:
|
|
270
|
+
|
|
271
|
+
- literal hex colors, even when they equal a scheme slot;
|
|
272
|
+
- `var:<id>` variables;
|
|
273
|
+
- translucent colors;
|
|
274
|
+
- engine-derived chrome: default text, headings, list markers, muted text, card and chart panels, chart series, and default table header fill. These colors are contrast-selected per slide, not named by the document.
|
|
275
|
+
- `hyperlink` and `followedHyperlink` run and cell colors, because PptxGenJS 4.0.1 cannot emit `hlink`/`folHlink`. Borders and backgrounds can.
|
|
276
|
+
|
|
277
|
+
Import reads the first slide master's theme. An exact twelve-slot match with a bundled catalog scheme returns its id; the `clrScheme` name breaks ties. Otherwise the importer returns inline slots, relative to the catalog scheme the `clrScheme` is named after when there is one (`{id:'boost', accent1:'#123456'}`). A theme whose name equals a catalog theme's name maps to `design.theme` only if the package's color scheme or heading/body fonts match that theme; otherwise `theme-unverified` is reported. A missing theme, or slots that are not opaque sRGB/system colors, report `unsupported-theme-colors` on `design.colorScheme`. Slide colors are still imported as resolved hex. Role overrides such as `primary` have no theme slot and are not recovered. `test/theme-colors.mjs` covers all 14 catalog schemes and 4 catalog themes, override, foreign and damaged themes, and the literal-color boundaries. This establishes package structure and round-trip, not PowerPoint rendering.
|
|
191
278
|
|
|
192
279
|
## JPEG orientation on import
|
|
193
280
|
|
|
@@ -203,7 +290,7 @@ Since 0.2.1, the importer follows slide → layout → master background inherit
|
|
|
203
290
|
|
|
204
291
|
The 0.2.1 importer applies `lum`, `lumMod`, `lumOff`, `alpha`, `alphaMod` and `alphaOff` in XML order, including repeated/interleaved transforms in theme definitions, style placeholders and gradient stops. Following DrawingML’s [luminance modulation](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.luminancemodulation?view=openxml-3.0.1) and [luminance offset](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.luminanceoffset?view=openxml-3.0.1) semantics, luminance adjustments retain hue and saturation, and opacity operations clamp after each step. Colors are rounded to RGB only after the full reference/transform chain. Tint/shade, saturation/hue, gamma and other transforms still produce diagnostics. The regression suite covers 70 inheritance, transform and diagnostic cases; these mathematical tests do not establish native viewer pixel parity.
|
|
205
292
|
|
|
206
|
-
These colors become explicit editable OPF RGB fills. Import does not preserve a live link to the original PowerPoint master/theme. No external theme URL is fetched. Missing themes, unknown colors, unsupported color transforms, and unsupported
|
|
293
|
+
These colors become explicit editable OPF RGB fills. Import does not preserve a live link to the original PowerPoint master/theme. No external theme URL is fetched. Missing themes, unknown colors, unsupported color transforms, and unsupported fills (including patterns without a DrawingML preset or explicit colors) report `unsupported-background-fill` (or `unsupported-background-gradient` for gradients) at the slide background path.
|
|
207
294
|
|
|
208
295
|
The regression fixtures cover inheritance, overrides, interleaved style lists, repeated export/import, source preservation and observable failures. The style indexes follow the [Open XML background-reference definition](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.presentation.backgroundstylereference?view=openxml-3.0.1). They prove conversion semantics, not universal native appearance. Keynote displayed a fixture referencing `fillStyleLst` index 2 as white; native comparison of other reference forms is still incomplete. Microsoft PowerPoint remains unverified. This work is not included in npm 0.2.0.
|
|
209
296
|
|
|
@@ -237,6 +324,10 @@ Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `
|
|
|
237
324
|
|
|
238
325
|
Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
|
|
239
326
|
|
|
240
|
-
|
|
327
|
+
Version 0.9.1 exports core's accepted header/footer text and picture geometry as editable tagged slide shapes. Native Office Header/Footer objects (`p:hf` / notes master) remain deferred [core issue87](https://github.com/OpenPresentation/opf/issues/87) work. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates, section/organization metadata and page-number intent when the current number matches the current slide position.
|
|
328
|
+
|
|
329
|
+
Slide numbers inside those shapes are native PowerPoint slide-number fields (`<a:fld type="slidenum">`), so PowerPoint renumbers them when slides move. A `slideNumberFormat` such as `"A-{current}"` or `"{current} / {total}"` keeps its literal text and `{total}` (the exported slide count) as fixed runs around the field; PowerPoint has no slide-count field. A current date (`date: true`) needs the host's `date` option (ISO `YYYY-MM-DD`); it becomes a native `datetime1`–`datetime7` field when its `dateFormat` matches that en-US field type, and otherwise fixed text with a `furniture-date-fixed` diagnostic. An eligible date or number range that spans accepted text lines stays static and reports `furniture-field-fixed` at its source path; no native field is created by merging shapes. A string date with `dateFormat` is fixed text. The slide manifest records `slideNumberFormat`/`dateFormat` settings, never their rendered words: reimport keeps a format only while the current native text still matches it (a slide number at its current position, a fixed date that parses back to the same ISO date), and a live date keeps the pattern of its current field type. Otherwise the current words import as literal text. Hiding furniture on a title slide is a slide-level `design.footer: false`/`design.header: false`, which round-trips as before.
|
|
330
|
+
|
|
331
|
+
When a supported current-date field wraps across lines, full provenance records a versioned static-date reason, its format and exact line fingerprints, without cached date words. Complete unchanged soft-wrapped lines can then recover `date: true` and the authored format on reimport, so a later OPF export can use a new host date. The original PPTX date remains static: this does not establish native refresh or Office compatibility. Actual current native date fields take precedence. Edited or cleared text, damaged evidence, older unmarked exports, `references-only` and `provenance: false` remain literal/current-content fallbacks. Fingerprints detect changed text; writable tags are not authentication. Native formatting and geometry are not reconstructed.
|
|
241
332
|
|
|
242
|
-
Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates;
|
|
333
|
+
Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; published tagged-shape furniture does not establish native Header/Footer support.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
-
import {readNativeBackground, readBackgroundColor, colorTransforms} from './background.js';
|
|
2
|
+
import {readNativeBackground, readBackgroundColor, colorTransforms, nativeTileScale, nativeTileAlignment} from './background.js';
|
|
3
|
+
import {rasterMetadata} from './image-geometry.js';
|
|
3
4
|
|
|
4
5
|
const orderedParser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', preserveOrder: true, parseAttributeValue: false, parseTagValue: false, trimValues: false});
|
|
5
6
|
const defaultMapping = {bg1:'lt1',tx1:'dk1',bg2:'lt2',tx2:'dk2'};
|
|
@@ -66,13 +67,15 @@ export function readSlideTheme(slidePath, {part, relationships, bytes}) {
|
|
|
66
67
|
|
|
67
68
|
export function importBackground(slidePath, dimensions, archive, report) {
|
|
68
69
|
const {chain, colors, mapping, format, formatPath, parsedPart} = readSlideTheme(slidePath, archive);
|
|
69
|
-
const
|
|
70
|
+
const owner = chain.find(item => item.root?.['p:cSld']?.['p:bg'] !== undefined);
|
|
71
|
+
const background = owner?.root['p:cSld']['p:bg'];
|
|
70
72
|
if (!background) return undefined;
|
|
71
73
|
const unsupported = () => {
|
|
72
74
|
report({code:'unsupported-background-fill',message:'The native background style reference or its theme color could not be resolved from this PPTX archive.'});
|
|
73
75
|
return undefined;
|
|
74
76
|
};
|
|
75
|
-
|
|
77
|
+
// Picture relationships belong to the part that holds the fill.
|
|
78
|
+
const context = {colors, mapping, image: fill => readImageBackground(fill, owner.path, archive, dimensions, report)};
|
|
76
79
|
if (background['p:bgPr']) return readNativeBackground(background['p:bgPr'], dimensions, report, context);
|
|
77
80
|
const reference = background['p:bgRef'];
|
|
78
81
|
if (!/^\d+$/.test(reference?.idx ?? '')) return unsupported();
|
|
@@ -90,5 +93,58 @@ export function importBackground(slidePath, dimensions, archive, report) {
|
|
|
90
93
|
const style = styles?.[index < 1000 ? index - 1 : index - 1001];
|
|
91
94
|
if (!style) return unsupported();
|
|
92
95
|
context.placeholder = readBackgroundColor(reference, context);
|
|
96
|
+
context.image = fill => readImageBackground(fill, formatPath, archive, dimensions, report);
|
|
93
97
|
return readNativeBackground(drawingObject([style]), dimensions, report, context);
|
|
94
98
|
}
|
|
99
|
+
|
|
100
|
+
const imageEffects = new Set(['r:embed', 'r:link', 'cstate', 'a:alphaModFix', 'a:lum', 'a:extLst']);
|
|
101
|
+
const inset = (rect, key) => Number(rect?.[key] ?? 0) / 100000;
|
|
102
|
+
const near = (a, b) => Math.abs(a - b) <= .005;
|
|
103
|
+
|
|
104
|
+
// Import an embedded raster picture fill as an OPF image background. OPF's
|
|
105
|
+
// cover/contain/tile fits are recovered from the stretch/tile geometry; any
|
|
106
|
+
// other stretch (off-center crop, distortion) or unsupported picture effect is
|
|
107
|
+
// imported as the closest centered cover and reported.
|
|
108
|
+
function readImageBackground(fill, partPath, {relationships, bytes}, dimensions, report) {
|
|
109
|
+
const blip = fill['a:blip'] ?? {};
|
|
110
|
+
const relationship = blip['r:embed'] && relationships(partPath).get(blip['r:embed']);
|
|
111
|
+
const data = relationship && relationship.targetMode !== 'External' ? bytes(relationship.path) : undefined;
|
|
112
|
+
const metadata = data && rasterMetadata(data);
|
|
113
|
+
if (!metadata) {
|
|
114
|
+
report({code: 'unsupported-background-image', message: 'The native background picture is linked, missing, or not an embedded PNG, JPEG, GIF or WebP raster; its background was not imported.'});
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const approximate = reason => report({code: 'approximate-background-image', message: `The native background picture ${reason}; it was imported as the closest OPF image background.`});
|
|
118
|
+
const lum = blip['a:lum'];
|
|
119
|
+
if (Object.keys(blip).some(key => !imageEffects.has(key)) || (lum && Object.keys(lum).length)) approximate('uses picture effects outside OPF opacity');
|
|
120
|
+
const amount = blip['a:alphaModFix'] ? Number(blip['a:alphaModFix'].amt ?? 100000) / 100000 : 1;
|
|
121
|
+
const opacity = Number.isFinite(amount) ? Math.max(0, Math.min(1, amount)) : 1;
|
|
122
|
+
let fit = 'cover';
|
|
123
|
+
if (fill['a:tile']) {
|
|
124
|
+
fit = 'tile';
|
|
125
|
+
// OPF tile has one geometry: top-left min(w,h)/4 cells holding the whole
|
|
126
|
+
// image (the default contain imageFill), at the raster's own resolution.
|
|
127
|
+
const tile = fill['a:tile'], crop = fill['a:srcRect'];
|
|
128
|
+
const expected = nativeTileScale(metadata, dimensions);
|
|
129
|
+
const x = (1 - expected.cell / (metadata.width * expected.scale)) / 2, y = (1 - expected.cell / (metadata.height * expected.scale)) / 2;
|
|
130
|
+
const value = (raw, fallback) => raw === undefined ? fallback : Number(raw);
|
|
131
|
+
const scaled = (raw, target) => Math.abs(value(raw, 100000) - target) <= Math.max(2, target * .005);
|
|
132
|
+
const matches = scaled(tile.sx, expected.sx) && scaled(tile.sy, expected.sy)
|
|
133
|
+
&& Object.entries(nativeTileAlignment).every(([key, native]) => String(tile[key] ?? native) === native)
|
|
134
|
+
&& near(inset(crop, 'l'), x) && near(inset(crop, 'r'), x) && near(inset(crop, 't'), y) && near(inset(crop, 'b'), y);
|
|
135
|
+
if (!matches) approximate('tiles with a scale, offset, alignment, flip or crop that the OPF tile fit does not express');
|
|
136
|
+
} else {
|
|
137
|
+
const crop = fill['a:srcRect'], box = fill['a:stretch']?.['a:fillRect'];
|
|
138
|
+
const [l, t, r, b] = ['l', 't', 'r', 'b'].map(key => inset(crop, key));
|
|
139
|
+
const [fl, ft, fr, fb] = ['l', 't', 'r', 'b'].map(key => inset(box, key));
|
|
140
|
+
const source = metadata.width * (1 - l - r) / (metadata.height * (1 - t - b));
|
|
141
|
+
const target = dimensions.width * (1 - fl - fr) / (dimensions.height * (1 - ft - fb));
|
|
142
|
+
const aspect = Number.isFinite(source) && Number.isFinite(target) && source > 0 && target > 0 && Math.abs(source / target - 1) <= .005;
|
|
143
|
+
const noCrop = [l, t, r, b].every(value => near(value, 0)), noInset = [fl, ft, fr, fb].every(value => near(value, 0));
|
|
144
|
+
if (aspect && noInset && near(l, r) && near(t, b) && Math.min(l, t) >= 0) fit = 'cover';
|
|
145
|
+
else if (aspect && noCrop && near(fl, fr) && near(ft, fb) && Math.min(fl, ft) >= 0) fit = 'contain';
|
|
146
|
+
else approximate('is cropped off-center, distorted or offset');
|
|
147
|
+
}
|
|
148
|
+
const binary = typeof Buffer !== 'undefined' ? Buffer.from(data).toString('base64') : btoa(Array.from(data, byte => String.fromCharCode(byte)).join(''));
|
|
149
|
+
return {type: 'image', image: {src: `data:${metadata.mediaType};base64,${binary}`, fit}, ...(opacity === 1 ? {} : {opacity})};
|
|
150
|
+
}
|
package/dist/background.js
CHANGED
|
@@ -16,11 +16,43 @@ function colorXml(value, opacity, fallback) {
|
|
|
16
16
|
return `<a:srgbClr val="${c.hex}">${alpha === 100000 ? '' : `<a:alpha val="${alpha}"/>`}</a:srgbClr>`;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
// ECMA-376 Part 1, 20.1.10.51 ST_PresetPatternVal. OPF pattern presets with
|
|
20
|
+
// these names are DrawingML presets; any other id is engine-defined.
|
|
21
|
+
export const presetPatterns = new Set(['pct5', 'pct10', 'pct20', 'pct25', 'pct30', 'pct40', 'pct50', 'pct60', 'pct70', 'pct75', 'pct80', 'pct90',
|
|
22
|
+
'horz', 'vert', 'ltHorz', 'ltVert', 'dkHorz', 'dkVert', 'narHorz', 'narVert', 'dashHorz', 'dashVert', 'cross',
|
|
23
|
+
'dnDiag', 'upDiag', 'ltDnDiag', 'ltUpDiag', 'dkDnDiag', 'dkUpDiag', 'wdDnDiag', 'wdUpDiag', 'dashDnDiag', 'dashUpDiag', 'diagCross',
|
|
24
|
+
'smCheck', 'lgCheck', 'smGrid', 'lgGrid', 'dotGrid', 'smConfetti', 'lgConfetti', 'horzBrick', 'diagBrick',
|
|
25
|
+
'solidDmnd', 'openDmnd', 'dotDmnd', 'plaid', 'sphere', 'weave', 'divot', 'shingle', 'wave', 'trellis', 'zigZag']);
|
|
26
|
+
|
|
27
|
+
// Engine-defined presets drawn by the SVG preview, with their closest DrawingML
|
|
28
|
+
// preset. diagStripe is a 2px rising diagonal on an 8px cell, as wdUpDiag.
|
|
29
|
+
// Import reports the native preset name; it does not guess the OPF alias.
|
|
30
|
+
const patternAliases = {diagStripe: 'wdUpDiag'};
|
|
31
|
+
export const nativePatternPreset = preset => presetPatterns.has(preset) ? preset : Object.hasOwn(patternAliases, preset) ? patternAliases[preset] : undefined;
|
|
32
|
+
|
|
33
|
+
// The SVG preview paints the pattern background color and foreground marks,
|
|
34
|
+
// defaulting to white and the slide text color. Other engine-defined presets
|
|
35
|
+
// have no DrawingML equivalent; like the preview, only their background color remains.
|
|
36
|
+
// `scheme(reference, hex)` names the theme color for an authored slot/role
|
|
37
|
+
// reference whose drawn color the deck theme holds exactly (FF-24); pattern
|
|
38
|
+
// colors then become a:schemeClr, keeping any background opacity as alpha.
|
|
39
|
+
export function nativeBackgroundFill(background, {width, height}, fallback = 'FFFFFF', foreground = '000000', scheme = () => undefined) {
|
|
20
40
|
if (typeof background === 'string' && /^#[\da-f]{3}(?:[\da-f]{3}(?:[\da-f]{2})?)?$/i.test(background)) background = {type: 'solid', color: background};
|
|
21
41
|
if (!background || typeof background !== 'object') return null;
|
|
22
42
|
const opacity = background.opacity ?? 1;
|
|
23
43
|
if (background.type === 'solid' || background.type === 'theme') return `<a:solidFill>${colorXml(background.type === 'theme' ? fallback : background.color, opacity, fallback)}</a:solidFill>`;
|
|
44
|
+
if (background.type === 'pattern') {
|
|
45
|
+
const paint = (value, base) => {
|
|
46
|
+
const c = color(value, base), themeValue = c.alpha === 1 ? scheme(value, c.hex) : undefined;
|
|
47
|
+
if (!themeValue) return colorXml(value, opacity, base);
|
|
48
|
+
const alpha = Math.round(clamp(opacity) * 100000);
|
|
49
|
+
return `<a:schemeClr val="${themeValue}">${alpha === 100000 ? '' : `<a:alpha val="${alpha}"/>`}</a:schemeClr>`;
|
|
50
|
+
};
|
|
51
|
+
const pattern = background.pattern ?? {}, back = paint(pattern.backgroundColor, 'FFFFFF');
|
|
52
|
+
const preset = nativePatternPreset(pattern.preset);
|
|
53
|
+
if (!preset) return `<a:solidFill>${back}</a:solidFill>`;
|
|
54
|
+
return `<a:pattFill prst="${preset}"><a:fgClr>${paint(pattern.foregroundColor, foreground)}</a:fgClr><a:bgClr>${back}</a:bgClr></a:pattFill>`;
|
|
55
|
+
}
|
|
24
56
|
if (background.type !== 'gradient') return null;
|
|
25
57
|
const stops = background.gradient?.stops ?? [];
|
|
26
58
|
if (!stops.length) return '<a:noFill/>';
|
|
@@ -38,6 +70,42 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
|
|
|
38
70
|
return `<a:gradFill rotWithShape="0"><a:gsLst>${nativeStops}</a:gsLst><a:lin ang="${angle}" scaled="0"/></a:gradFill>`;
|
|
39
71
|
}
|
|
40
72
|
|
|
73
|
+
const rectXml = (name, rect = {}) => `<a:${name}${['l', 't', 'r', 'b'].filter(key => rect[key]).map(key => ` ${key}="${rect[key]}"`).join('')}/>`;
|
|
74
|
+
const percent = value => Math.round(value * 100000);
|
|
75
|
+
|
|
76
|
+
// Picture fill for an OPF image background, following the SVG preview:
|
|
77
|
+
// cover crops the centered source to the slide aspect, contain letterboxes it
|
|
78
|
+
// with fill-rectangle insets, and tile repeats square cells of min(w,h)/4
|
|
79
|
+
// from the top-left corner. Each cell holds the image by the deck imageFill
|
|
80
|
+
// (crop = cover, otherwise contain; negative source insets are transparent
|
|
81
|
+
// padding). With dpi="0", DrawingML sizes a tile from the raster's own
|
|
82
|
+
// resolution (PNG pHYs, JPEG JFIF or EXIF; 96 dpi when absent), while the
|
|
83
|
+
// preview draws CSS pixels, so the tile scale compensates on each axis.
|
|
84
|
+
export const nativeTileAlignment = {tx: '0', ty: '0', flip: 'none', algn: 'tl'};
|
|
85
|
+
export function nativeTileScale(image, {width, height, imageFill = 'fit'}) {
|
|
86
|
+
const cell = Math.min(width, height) / 4;
|
|
87
|
+
const scale = (imageFill === 'crop' ? Math.max : Math.min)(cell / image.width, cell / image.height);
|
|
88
|
+
return {cell, scale, sx: percent(scale * (image.dpiX ?? 96) / 96), sy: percent(scale * (image.dpiY ?? 96) / 96)};
|
|
89
|
+
}
|
|
90
|
+
export function nativeImageBackgroundFill(relationshipId, image, {fit = 'cover', opacity = 1, width, height, imageFill = 'fit'}) {
|
|
91
|
+
const alpha = clamp(opacity);
|
|
92
|
+
const blip = `<a:blip r:embed="${relationshipId}">${alpha === 1 ? '' : `<a:alphaModFix amt="${percent(alpha)}"/>`}</a:blip>`;
|
|
93
|
+
if (fit === 'tile') {
|
|
94
|
+
const {cell, scale, sx, sy} = nativeTileScale(image, {width, height, imageFill});
|
|
95
|
+
const x = percent((1 - cell / (image.width * scale)) / 2), y = percent((1 - cell / (image.height * scale)) / 2);
|
|
96
|
+
const {tx, ty, flip, algn} = nativeTileAlignment;
|
|
97
|
+
return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: x, t: y, r: x, b: y})}<a:tile tx="${tx}" ty="${ty}" sx="${sx}" sy="${sy}" flip="${flip}" algn="${algn}"/></a:blipFill>`;
|
|
98
|
+
}
|
|
99
|
+
if (fit === 'contain') {
|
|
100
|
+
const scale = Math.min(width / image.width, height / image.height);
|
|
101
|
+
const x = percent((1 - image.width * scale / width) / 2), y = percent((1 - image.height * scale / height) / 2);
|
|
102
|
+
return `<a:blipFill dpi="0" rotWithShape="1">${blip}<a:srcRect/><a:stretch>${rectXml('fillRect', {l: x, t: y, r: x, b: y})}</a:stretch></a:blipFill>`;
|
|
103
|
+
}
|
|
104
|
+
const scale = Math.max(width / image.width, height / image.height);
|
|
105
|
+
const x = percent((1 - width / (image.width * scale)) / 2), y = percent((1 - height / (image.height * scale)) / 2);
|
|
106
|
+
return `<a:blipFill dpi="0" rotWithShape="1">${blip}${rectXml('srcRect', {l: x, t: y, r: x, b: y})}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`;
|
|
107
|
+
}
|
|
108
|
+
|
|
41
109
|
// Internal metadata supplied by the ordered XML reader. A Symbol cannot collide
|
|
42
110
|
// with a document attribute, and repeated transforms keep their original order.
|
|
43
111
|
export const colorTransforms = Symbol('DrawingML color transforms');
|
|
@@ -98,11 +166,28 @@ export function readBackgroundColor(node, context = {}, seen = new Set()) {
|
|
|
98
166
|
return {hex: luminanceHex(luminance), alpha, luminance};
|
|
99
167
|
}
|
|
100
168
|
|
|
169
|
+
// A preset pattern with explicit foreground and background colors. ECMA-376
|
|
170
|
+
// defines no default colors, so a pattern missing either one is not guessed.
|
|
171
|
+
function readNativePattern(pattern, report, context) {
|
|
172
|
+
const foreground = pattern['a:fgClr'] && readBackgroundColor(pattern['a:fgClr'], context);
|
|
173
|
+
const background = pattern['a:bgClr'] && readBackgroundColor(pattern['a:bgClr'], context);
|
|
174
|
+
if (!presetPatterns.has(pattern.prst) || !foreground || !background) {
|
|
175
|
+
report({code: 'unsupported-background-fill', message: 'This native pattern needs a DrawingML preset with resolvable foreground and background colors to become an OPF pattern background.'});
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
const uniform = foreground.alpha === background.alpha;
|
|
179
|
+
const hex = c => c.hex + (!uniform && c.alpha !== 1 ? Math.round(c.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
|
|
180
|
+
return {type: 'pattern', pattern: {preset: pattern.prst, foregroundColor: hex(foreground), backgroundColor: hex(background)},
|
|
181
|
+
...(uniform && foreground.alpha !== 1 ? {opacity: foreground.alpha} : {})};
|
|
182
|
+
}
|
|
183
|
+
|
|
101
184
|
export function readNativeBackground(properties, {width, height}, report = () => {}, context = {}) {
|
|
102
185
|
if (!properties) return undefined;
|
|
103
186
|
if (Object.hasOwn(properties, 'a:noFill')) return {type: 'solid', color: '#FFFFFF', opacity: 0};
|
|
104
187
|
const solid = readBackgroundColor(properties['a:solidFill'], context);
|
|
105
188
|
if (solid) return {type: 'solid', color: solid.hex, ...(solid.alpha === 1 ? {} : {opacity: solid.alpha})};
|
|
189
|
+
if (properties['a:pattFill']) return readNativePattern(properties['a:pattFill'], report, context);
|
|
190
|
+
if (properties['a:blipFill'] && context.image) return context.image(properties['a:blipFill']);
|
|
106
191
|
const gradient = properties['a:gradFill'];
|
|
107
192
|
if (!gradient) {
|
|
108
193
|
report({code: 'unsupported-background-fill', message: 'This native background fill or color cannot be represented by the OPF background importer.'});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Current ordinary native body text only. This reader never reads OPF tags or
|
|
2
|
+
// recovers authoring boundaries across separate native text shapes.
|
|
3
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
4
|
+
import {drawingObject, readSlideTheme} from './background-import.js';
|
|
5
|
+
import {nativeRunStyle, mergeNativeRunProperties} from './native-text-style.js';
|
|
6
|
+
|
|
7
|
+
const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', preserveOrder:true, parseAttributeValue:false, parseTagValue:false, trimValues:false, htmlEntities:true});
|
|
8
|
+
const nodes = (tree, tag) => (tree ?? []).filter(node => Object.hasOwn(node, tag));
|
|
9
|
+
const child = (tree, tag) => nodes(tree, tag)[0]?.[tag];
|
|
10
|
+
const text = tree => (tree ?? []).map(node => node['#text'] ?? '').join('');
|
|
11
|
+
// Match nativeTextShapes/nativeShapeParagraphs indexing; positional ordering is
|
|
12
|
+
// still performed by the existing slide importer, not by this reader.
|
|
13
|
+
const shapes = tree => [...nodes(tree, 'p:sp').map(node => node['p:sp']), ...nodes(tree, 'p:grpSp').flatMap(node => shapes(node['p:grpSp']))];
|
|
14
|
+
const value = runs => runs.some(run => typeof run !== 'string') ? runs : runs.join('');
|
|
15
|
+
export const joinNativeParagraphs = paragraphs => value(paragraphs.flatMap((paragraph, index) => [
|
|
16
|
+
...(index ? ['\n'] : []), ...(Array.isArray(paragraph.richText) ? paragraph.richText : [paragraph.richText ?? paragraph.text])
|
|
17
|
+
]));
|
|
18
|
+
|
|
19
|
+
function bodyRunStyle(properties, context, relationships, report) {
|
|
20
|
+
const supported = {...properties};
|
|
21
|
+
for (const key of ['b', 'i']) if (properties[key] !== undefined && !['1','0','true','false','on','off'].includes(properties[key])) {
|
|
22
|
+
report('unsupported-body-text-style', `The native ${key} value is not a supported boolean; current text was retained.`);
|
|
23
|
+
delete supported[key];
|
|
24
|
+
}
|
|
25
|
+
for (const [key, allowed] of [['u', ['none','sng']], ['strike', ['noStrike','sngStrike']]]) if (properties[key] !== undefined && !allowed.includes(properties[key])) {
|
|
26
|
+
report('unsupported-body-text-style', `The native ${key} variant is not represented by an OPF boolean; current text was retained.`);
|
|
27
|
+
delete supported[key];
|
|
28
|
+
}
|
|
29
|
+
if (properties.sz !== undefined && (!Number.isFinite(Number(properties.sz)) || Number(properties.sz) <= 0)) {
|
|
30
|
+
report('unsupported-body-text-style', 'The native font size is not a positive finite value; current text was retained.');
|
|
31
|
+
delete supported.sz;
|
|
32
|
+
}
|
|
33
|
+
if (properties.baseline !== undefined) {
|
|
34
|
+
const baseline = Number(properties.baseline);
|
|
35
|
+
if (!Number.isFinite(baseline)) {
|
|
36
|
+
report('unsupported-body-text-style', 'The native baseline is not finite; current text was retained.');
|
|
37
|
+
delete supported.baseline;
|
|
38
|
+
} else if (![0,30000,-30000].includes(baseline)) {
|
|
39
|
+
report('approximate-body-text-baseline', 'OPF retains the native baseline direction, but cannot represent its exact offset.');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const key of ['a:latin','a:solidFill','a:hlinkClick']) if (Array.isArray(properties[key])) {
|
|
43
|
+
report('unsupported-body-text-style', `Repeated native ${key} properties are ambiguous; current text was retained.`);
|
|
44
|
+
delete supported[key];
|
|
45
|
+
}
|
|
46
|
+
for (const key of ['a:ea','a:cs','a:sym']) if (properties[key]?.typeface && properties[key].typeface !== properties['a:latin']?.typeface) {
|
|
47
|
+
report('unsupported-body-font', 'Script-specific native faces cannot be represented by one OPF run font family; the Latin face and current text were retained.');
|
|
48
|
+
}
|
|
49
|
+
if (properties['a:hlinkMouseOver']) report('unsupported-body-link', 'A native mouse-over action cannot be represented by an OPF run URL.');
|
|
50
|
+
if (['a:uLn', 'a:uFill', 'a:highlight', 'a:effectLst', 'a:effectDag', 'a:ln'].some(key => properties[key]) ||
|
|
51
|
+
(properties.cap !== undefined && properties.cap !== 'none') || (properties.spc !== undefined && Number(properties.spc) !== 0)) {
|
|
52
|
+
report('unsupported-body-text-style', 'Native text effects, capitalization, spacing or underline decoration are not represented by OPF run properties; current text was retained.');
|
|
53
|
+
}
|
|
54
|
+
return nativeRunStyle(supported, context, relationships, report, 'body');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function nativeBodyReader(slidePath, archive, relationships, report) {
|
|
58
|
+
let bodies, context;
|
|
59
|
+
return index => {
|
|
60
|
+
if (!bodies) {
|
|
61
|
+
const tree = archive.part(slidePath, parser);
|
|
62
|
+
bodies = shapes(child(child(child(tree, 'p:sld'), 'p:cSld'), 'p:spTree')).map(shape => child(shape, 'p:txBody'));
|
|
63
|
+
context = readSlideTheme(slidePath, archive);
|
|
64
|
+
}
|
|
65
|
+
const body = bodies[index];
|
|
66
|
+
if (!body) return undefined;
|
|
67
|
+
const listStyle = drawingObject(child(body, 'a:lstStyle'));
|
|
68
|
+
return nodes(body, 'a:p').map((paragraph, paragraphIndex) => {
|
|
69
|
+
const content = paragraph['a:p'], pPr = drawingObject(child(content, 'a:pPr'));
|
|
70
|
+
const level = Number(nodes(content, 'a:pPr')[0]?.[':@']?.lvl ?? 0) + 1;
|
|
71
|
+
const defaults = mergeNativeRunProperties(listStyle['a:defPPr']?.['a:defRPr'], listStyle[`a:lvl${level}pPr`]?.['a:defRPr'], pPr['a:defRPr']);
|
|
72
|
+
const runs = [];
|
|
73
|
+
let runIndex = 0;
|
|
74
|
+
for (const node of content) {
|
|
75
|
+
const tag = ['a:r','a:fld','a:br'].find(name => Object.hasOwn(node, name));
|
|
76
|
+
if (!tag) continue;
|
|
77
|
+
const path = `shapes.${index}.paragraphs.${paragraphIndex}.runs.${runIndex++}`;
|
|
78
|
+
const current = tag === 'a:br' ? '\n' : text(child(node[tag], 'a:t'));
|
|
79
|
+
const properties = mergeNativeRunProperties(defaults, drawingObject(node[tag])['a:rPr']);
|
|
80
|
+
const style = bodyRunStyle(properties, context, relationships, (code, message) => report({code,message,path}));
|
|
81
|
+
if (current) runs.push(Object.keys(style).length ? {text:current,...style} : current);
|
|
82
|
+
}
|
|
83
|
+
return {text:runs.map(run => typeof run === 'string' ? run : run.text).join(''), richText:value(runs)};
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
}
|