@openpresentation/opf-pptx 0.15.0 → 0.16.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 +5 -4
- package/dist/data-provenance.js +68 -11
- package/dist/document-provenance.js +41 -5
- package/dist/{chart-alt.js → frame-alt.js} +4 -4
- package/dist/image-fallback-browser.js +5 -0
- package/dist/image-fallback-node.js +20 -2
- package/dist/index.d.ts +7 -3
- package/dist/index.js +73 -15
- package/dist/script-fonts.js +7 -0
- package/package.json +15 -4
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Chart options (RR-35, unreleased): `axisTitles`, `legend` and `dataLabels` on a
|
|
|
10
10
|
|
|
11
11
|
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`.
|
|
12
12
|
|
|
13
|
-
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
|
|
13
|
+
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 frame that uses these fields and every table frame) 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 native table evidence 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`). Full provenance also records inline tables as authored, including scalar numbers and absent styling. Unchanged native cells restore that representation; cell text, formatting, table geometry or theme/style edits retain the observed native cells and report `table-data-provenance-changed`. `references-only` and no-provenance tables import displayed text and observed formatting, so scalar types and authored style absence are lossy.
|
|
14
14
|
|
|
15
15
|
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.
|
|
16
16
|
|
|
@@ -63,13 +63,13 @@ const bytes = await toPptx({
|
|
|
63
63
|
await fs.promises.writeFile("quarterly-review.pptx", bytes);
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
`toPptx` returns a `Uint8Array` containing a PowerPoint-openable `.pptx`. It does not fetch remote assets. Data URI images and local paths can be embedded directly; hosts that need private asset loading should pass `imageResolver(src, context)`. Set `strictAssets: true` to turn unresolved or remote image assets into structured `OPFPptxError` failures instead of editable placeholder boxes.
|
|
66
|
+
`toPptx` returns a `Uint8Array` containing a PowerPoint-openable `.pptx`. It does not fetch remote assets. Data URI images and local paths can be embedded directly; hosts that need private asset loading should pass `imageResolver(src, context)`. Every picture path (image blocks and region images, quote photos, slide and deck background pictures, watermarks, cover logos, header and footer images and logos, picture bullets) reports an unresolved source with exactly one `unresolved-asset` diagnostic at its OPF path and draws the preview's fallback (the placeholder, the background colour, no watermark, or the character bullets), so strict CLI export (`--fail-on warning`) gates it. A local file path that cannot be read is unresolved like any other source (`reason: 'file-unreadable'`), instead of failing the export. Set `strictAssets: true` to turn unresolved or remote image assets into structured `OPFPptxError` failures instead of editable placeholder boxes.
|
|
67
67
|
|
|
68
68
|
Embedded picture bytes are a PNG, JPEG, GIF, WebP or SVG. Bytes that are no readable image (or an SVG that cannot be exported: malformed XML, no `xmlns`, no width and height and no viewBox, or no rasterizer, see below) export what the preview shows, like an unresolved asset: the "Image unavailable" placeholder (nothing for a watermark, the background colour for a background image) and one `unresolved-asset` diagnostic with a `reason` (`unsupported-format`, `svg-malformed`, `svg-no-size`, `svg-too-large`, `svg-unsafe`, `svg-unreadable`, `svg-rasterizer-unavailable`, `svg-render-failed`); `strictAssets` throws `unsupported-image-dimensions` for an unreadable raster, `invalid-svg-image` for an unusable SVG, `svg-rasterizer-unavailable` or `svg-render-failed`.
|
|
69
69
|
|
|
70
70
|
SVG pictures are native SVG pictures, as PowerPoint 2016 and Microsoft 365 store them: a `p:pic` whose `a:blip` embeds a PNG fallback raster and carries the `asvg:svgBlip` extension that points at the SVG media part (`image/svg+xml`, content type registered). PowerPoint 2016+ draws the SVG (crisp at any zoom; Graphics Format, Convert to Shape); older viewers draw the PNG. This covers image blocks (placed or not), the alt-text picture of an image background, the watermark, header and footer images and logos, and cover and section logos. The picture takes the same frame and fit (`cover`, `contain`, `stretch`) as a raster of the same proportions; the SVG's size is its `width` and `height` (any unit), else its `viewBox`. Exceptions, each a raster only: a picture bullet (`a:buBlip` can reference a raster only, so it is the PNG), an image background without alt text (a background picture fill carries no SVG), a tiled background picture, and an image block with a duotone recolor or a non-rectangular shape (the effect is written on the PNG blip, so it applies as in the preview, and `svg-image-rasterized` is reported). Opacity (a translucent watermark, slide image opacity), grayscale and the slide image border stay native SVG pictures: PowerPoint applies them to an SVG picture.
|
|
71
71
|
|
|
72
|
-
The PNG fallback is drawn at 192 dpi of the displayed size (a multiple of 128 px on its long side, 128 to 2304 px, transparent background) by `options.svgRasterizer(svg, {width, height, scale, text})`. In Node the default is opf-render's `svgToPng` (resvg with the bundled fonts and no system fonts, so the same SVG gives the same bytes on every machine), loaded only when an SVG is exported: it is the optional peer `@openpresentation/opf-render`. Without opf-render and without `svgRasterizer` (a browser build has no default; pass one that draws the SVG deterministically), the SVG picture is the placeholder with `reason: "svg-rasterizer-unavailable"`. An `imageResolver` that returns a raster for an SVG source still gives a plain raster picture, and one that returns SVG bytes (`mediaType: "image/svg+xml"`) gives a native SVG picture. A local `.svg` path is read in Node.
|
|
72
|
+
The PNG fallback is drawn at 192 dpi of the displayed size (a multiple of 128 px on its long side, 128 to 2304 px, transparent background) by `options.svgRasterizer(svg, {width, height, scale, text})`. In Node the default is opf-render's `svgToPng` (resvg with the bundled fonts and no system fonts, so the same SVG gives the same bytes on every machine), loaded only when an SVG is exported: it is the optional peer `@openpresentation/opf-render`. Without opf-render and without `svgRasterizer` (a browser build has no default; pass one that draws the SVG deterministically), the SVG picture is the placeholder with `reason: "svg-rasterizer-unavailable"`. From opf-render 0.16 the PNG converter is itself an optional peer of opf-render: install `@resvg/resvg-js` next to it (`npm install @resvg/resvg-js@^2.6.2`; with text, also the font packages of the `base` pack, `@expo-google-fonts/roboto` and `roboto-mono`). When opf-render rejects with `converter-missing` the exporter reports the same `svg-rasterizer-unavailable` and the message carries opf-render's install command; with `strictAssets` the export throws that code. An `imageResolver` that returns a raster for an SVG source still gives a plain raster picture, and one that returns SVG bytes (`mediaType: "image/svg+xml"`) gives a native SVG picture. A local `.svg` path is read in Node.
|
|
73
73
|
|
|
74
74
|
An SVG is never run or fetched. It is validated as XML and sanitized before it is embedded (and on import): `<script>`, `<foreignObject>`, event-handler attributes, link animations, every `href`/`url()` that is not a `#fragment` or an inline PNG, JPEG, GIF or WebP, `@import` rules, `xml-stylesheet` instructions and DOCTYPE declarations are removed (a DOCTYPE entity that is plain text is expanded; an external or nested entity is refused as `svg-unsafe`), and `svg-sanitized` reports what was removed. An SVG that needs none of that is embedded byte for byte, so import returns the authored source.
|
|
75
75
|
|
|
@@ -153,7 +153,7 @@ The first importer is mechanical and schema-compatible:
|
|
|
153
153
|
- Citations, footnotes and captions (core RR-34 fields): a marker exports as a native superscript run (`baseline="30000"`) after its run; the slide's footnote area is a line shape plus one tagged text box per listed line (`OPF_FOOTNOTES_V1`), a caption one tagged text box per line (`OPF_CAPTION_V1`) naming its media shape, both at core's geometry; `references` travel in the document record. Import re-attaches tagged captions to their media block, rebuilds `references`, `cite` and `footnote` from the footnote tags and the marker runs (removing the marker runs; an edited note keeps its edited text), and guesses nothing without tags: a superscript number stays a superscript run and a text box stays a text block.
|
|
154
154
|
- 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.
|
|
155
155
|
- Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
|
|
156
|
-
- 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 reference, type, composition, composition hints and the embedded layout record with its catalogs group and source) 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). A layout that resolves only in a registered catalog stores no record and restores where `fromPptx` is given that catalog. See [document round trips](docs/document-roundtrip.md) for exactly what is embedded.
|
|
156
|
+
- 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. In `full` mode the document tag also lists the document-level defaults the document left to the engine (`$schema`, `name`, `language`, the theme, colour scheme, font scheme and slide size), and import leaves them absent while the package still shows the default, so an untouched round trip is deep-equal to the source (RR-59); a value edited in PowerPoint imports as observed. Slide layout intent (layout reference, type, composition, composition hints and the embedded layout record with its catalogs group and source) 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). A layout that resolves only in a registered catalog stores no record and restores where `fromPptx` is given that catalog. See [document round trips](docs/document-roundtrip.md) for exactly what is embedded.
|
|
157
157
|
- The slide's content structure is part of `OPF_SLIDE_V1` too (`full` mode): nested `group` blocks, promoted regions (`left`, `top:left`, ...), a root payload (`text`, `items`, `chart`, ...) versus `blocks`, block `id`s and `extensions`, group `composition`, with the reference-pixel box of every leaf from the same composition the export drew. Import matches the native shapes to those boxes and rebuilds the authored form while the slide's arrangement is unchanged, so `slide.type` validates again; a slide whose blocks no longer fit the stored boxes keeps its flat blocks and reports `content-structure-changed` at `slides.N`; a duplicated slide reports `duplicate-block-id` and drops the repeated id. The document tag also stores `filename`, root `extensions`, slide `section` and `extensions`, `design.logo` (deck and slide) and the whole `assets` registry; a `data:` source that is not an exported picture is stored inline up to 256 KiB, so an organization logo or speaker photo given as a data URI round-trips.
|
|
158
158
|
- Slide `section` labels are written as PowerPoint's native section list (`p14:sectionLst` in `ppt/presentation.xml`, whatever the `provenance` option): one section per run of consecutive slides with the same label, runs without a label as `Default Section`, with deterministic ids. Import reads the list back (`Default Section` means no section); it wins over the stored value and over the section text a footer shows (`section-reference-changed` at `slides.N.section`), except that an edited footer line keeps its text while the list still equals the stored value. The native PowerPoint check of the sections pane and save/reopen is a separate gate (`scratchpad/spec-gaps-native/`).
|
|
159
159
|
|
|
@@ -220,6 +220,7 @@ The exporter reads the presentation `language` through core `resolveScriptFonts(
|
|
|
220
220
|
- Theme major/minor `a:ea`/`a:cs` are written only for a slot where a script font is actually selected (FF-49): an explicit `eastAsian`/`complexScript` on the design font scheme, the scheme's own script family, or the language's script font (a language that uses the East Asian slot, such as Japanese, writes `ea`; one that uses the complex-script slot, such as Arabic, Hebrew, Indic or Thai, writes `cs`). Every other slot keeps the vendored empty typeface, exactly as Office's own themes leave it, so PowerPoint picks its per-language default for script text typed later and the package names nothing the author did not select (owner font policy). Latin, Cyrillic, Greek, Armenian, Georgian and Ethiopic decks write neither; the other slot of a CJK or complex-script deck is empty too. The language never changes the theme's latin fonts (Model C): `design.fontScheme` alone sets them. The preview resolves the same slots (opf-render's script profile: the theme's family where it names one, the latin family where it is empty), and `test/theme-script-slots.mjs` checks preview against export for every catalog font scheme.
|
|
221
221
|
- 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.
|
|
222
222
|
- Slide and notes runs name no `a:ea`/`a:cs` (FF-05: PowerPoint lists an explicit run East Asian or complex-script typeface as an empty-name font in `Presentation.Fonts`); their script text takes the theme slots through `+mn-ea`/`+mn-cs`. The theme `ea` always names a font (the selected script font, an East Asian font for East Asian text in the deck, else the latin family), so no paragraph end mark reads an empty font. Chart text keeps its own latin/ea/cs in the chart's body font.
|
|
223
|
+
- Measured export uses the optional renderer's public script planner for each resolved slide and each run language. The same loaded `fonts` handle can measure Arabic/Latin text in SVG and PPTX; native font names still follow the document's selected scheme. Custom measurement providers remain active when the optional renderer is absent. These controls establish shared line geometry and editable source recovery; native Office appearance is separate.
|
|
223
224
|
- Per-slide script fonts (opf-pptx#168): the presentation theme is built from the first slide. A slide whose own font scheme, slide theme or inline `eastAsian`/`complexScript` selects script fonts that theme does not carry gets its own slide master: a copy of slide master 1 and its layout, with a theme that is the presentation theme plus that slide's East Asian / complex-script slots and per-script entry (named `1_<theme>`, `2_<theme>`, ... as PowerPoint names a second master's theme). Slides with the same script fonts share a master, a slide that selects none stays on master 1, and masters are numbered in slide order. A deck with one script profile keeps one master and is byte-identical to the export before this change. A presentation has one notes master, whose theme is a copy of the presentation theme, so the notes of a slide on another master draw in the presentation theme's script fonts: `script-font-notes-not-exported` (path `slides.N.notes`) reports each such slide with notes.
|
|
224
225
|
- In a right-to-left deck, each slide and notes paragraph takes its direction from core `paragraphDirection(text, direction)`, the same rule the renderer uses: `rtl="1"` when its first strong character is right-to-left or it has none (digits, punctuation, empty), else an explicit `rtl="0"` (an English quote, a code line). The master, layout and presentation default paragraph levels start right-to-left only in a right-to-left deck. Left-to-right decks write no paragraph direction. Alignment is logical (RR-05, needs the core release that composes right-to-left decks): `left` is the start edge, so a right-to-left paragraph writes `rtl="1"` with `algn="r"` (PowerPoint's `algn` is physical, and `marL`/`indent` are the start-side margin and hanging indent, so a bullet hangs at the right), and every wrapped line shares its paragraph's direction and alignment (core computes the direction once per paragraph, not per line). The composition is mirrored through core: the `left` region and the first column are drawn at the right, a right-to-left table writes `a:tblPr rtl="1"` with its columns in logical order, column, line and area charts write `c:catAx` orientation `maxMin` (first category and value axis at the right), cover logos and header/footer zones swap sides, the master, layout and notes default levels start right-aligned, and notes paragraphs start at the right. Each Latin phrase inside an Arabic or Hebrew run (letters, joining spaces and word punctuation, digits) is written as its own `en-US` run, so PowerPoint orders `v2.0` and `PowerPoint 365` left to right instead of splitting them into items; digits that touch Arabic words stay in the Arabic run. Re-import reads right alignment of a right-to-left table cell as the logical start. Left-to-right decks are written exactly as before.
|
|
225
226
|
- 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.
|
package/dist/data-provenance.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {checkFormat, isValidFormat} from './format-check.js';
|
|
3
3
|
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
4
|
-
import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableData, tableCellDisplayValue
|
|
4
|
+
import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableData, tableCellDisplayValue} from './chart-data.js';
|
|
5
5
|
|
|
6
6
|
// RR-54: chart and table data survive a PPTX round trip (core docs/chart-table-data.md).
|
|
7
7
|
//
|
|
@@ -27,7 +27,9 @@ import {chartUsesDataFields, isDatasetRef, fromExcelNumberFormat, resolveTableDa
|
|
|
27
27
|
//
|
|
28
28
|
// Import restores a record only while its native evidence is unchanged: a chart while its caches hash the same (an edit in
|
|
29
29
|
// PowerPoint's Edit Data changes them), a dataset table while every native cell still shows the dataset's display text,
|
|
30
|
-
// and an inline table cell by cell while
|
|
30
|
+
// and an inline table cell by cell while its native text, formatting and grid evidence is unchanged (RR-59).
|
|
31
|
+
// RR-59 adds `authored`, per-cell `evidence` and an `environment` hash of theme/table style parts; the legacy headers/cells
|
|
32
|
+
// fields remain readable by older importers. Exact authored style absence is restored only with all of this evidence. Otherwise the native values stay (with
|
|
31
33
|
// the format codes core maps back, src/chart-data.js) and a diagnostic names what was not restored. Tags are untrusted
|
|
32
34
|
// input: they are size-limited (16 MiB, the limit of the document tag), depth- and shape-checked, validated as OPF, and
|
|
33
35
|
// never executed; a record that fails a check, or that a restore cannot use, is reported and the native values stay.
|
|
@@ -113,6 +115,32 @@ export function chartEvidence(xml) {
|
|
|
113
115
|
return hash(parts.join('\u0000'));
|
|
114
116
|
}
|
|
115
117
|
|
|
118
|
+
/** Native cell text, formatting and table geometry, canonicalized independently of XML attribute order. */
|
|
119
|
+
export function tableCellEvidence(table, relationships = new Map()) {
|
|
120
|
+
const related = value => {
|
|
121
|
+
const ids = new Set(), stack = [value];
|
|
122
|
+
while (stack.length) {
|
|
123
|
+
const item = stack.pop();
|
|
124
|
+
if (!item || typeof item !== 'object') continue;
|
|
125
|
+
for (const [key, child] of Object.entries(item)) {
|
|
126
|
+
if (/^r:(id|embed|link)$/.test(key) && typeof child === 'string') ids.add(child);
|
|
127
|
+
else if (child && typeof child === 'object') stack.push(child);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return ids.size ? {relationships: Object.fromEntries([...ids].sort().map(id => {
|
|
131
|
+
const link = relationships.get(id);
|
|
132
|
+
return [id, link ? {type: link.type, target: link.target, targetMode: link.targetMode} : null];
|
|
133
|
+
}))} : {};
|
|
134
|
+
};
|
|
135
|
+
const properties = {properties: table?.['a:tblPr'], grid: table?.['a:tblGrid']};
|
|
136
|
+
return array(table?.['a:tr']).map(row => array(row?.['a:tc']).map(cell => hash(canonical({properties, row: Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'a:tc')), cell, ...related({properties, cell})}))));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Theme and table-style edits can change effective cell formatting without changing the cell XML. */
|
|
140
|
+
export function tableEnvironmentEvidence(entries) {
|
|
141
|
+
return hash(canonical(Object.fromEntries(Object.keys(entries).filter(path => /^ppt\/theme\/[^/]+\.xml$/.test(path) || path === 'ppt/tableStyles.xml').sort().map(path => [path, parser.parse(dec.decode(entries[path]))]))));
|
|
142
|
+
}
|
|
143
|
+
|
|
116
144
|
// ---------------------------------------------------------------------------
|
|
117
145
|
// Export
|
|
118
146
|
|
|
@@ -126,19 +154,16 @@ export function chartDataRecord(chart, datasets) {
|
|
|
126
154
|
return {v: 1, kind: 'chart', data: clone(chart.data), ...(chart.mapping !== undefined ? {mapping: clone(chart.mapping)} : {}), ...(object(chart.highlight) ? {highlight: clone(chart.highlight)} : {}), ...(dataset ? {dataset} : {})};
|
|
127
155
|
}
|
|
128
156
|
|
|
129
|
-
/**
|
|
157
|
+
/** Full table provenance: authored inline cells (with legacy numeric recovery fields), or a dataset reference. */
|
|
130
158
|
export function tableDataRecord(table, layout) {
|
|
131
|
-
if (!tableUsesDataFields(table)) return undefined;
|
|
132
159
|
if (isDatasetRef(table)) return {v: 1, kind: 'table', table: clone(table)};
|
|
133
160
|
const hasHeaders = Array.isArray(table.columns) && table.columns.length > 0;
|
|
134
161
|
const shown = (row, column) => cellText(layout?.rows?.[row]?.cells?.find(cell => cell.column === column)?.value);
|
|
135
162
|
// A header's format: a DataColumn's or a header StyledTableCell's (both are the column format).
|
|
136
163
|
const formatOf = value => object(value) && typeof value.format === 'string' ? value.format : undefined;
|
|
137
164
|
const headers = [], cells = [];
|
|
138
|
-
const columnFormats = [];
|
|
139
165
|
(hasHeaders ? table.columns : []).forEach((header, column) => {
|
|
140
166
|
const format = formatOf(header);
|
|
141
|
-
columnFormats[column] = format;
|
|
142
167
|
const dataColumn = object(header) && typeof header.name === 'string' && !own(header, 'value');
|
|
143
168
|
if (dataColumn || format !== undefined) headers.push([column, clone(header), shown(0, column)]);
|
|
144
169
|
});
|
|
@@ -147,11 +172,11 @@ export function tableDataRecord(table, layout) {
|
|
|
147
172
|
row.forEach((cell, column) => {
|
|
148
173
|
const value = own(cell, 'value') ? cell.value : cell;
|
|
149
174
|
const format = own(cell, 'value') && typeof cell.format === 'string' ? cell.format : undefined;
|
|
150
|
-
if (typeof value !== 'number'
|
|
175
|
+
if (typeof value !== 'number') return;
|
|
151
176
|
cells.push([rowIndex, column, value, format ?? null, shown(rowIndex + (hasHeaders ? 1 : 0), column)]);
|
|
152
177
|
});
|
|
153
178
|
});
|
|
154
|
-
return {v: 1, kind: 'table', headers, cells};
|
|
179
|
+
return {v: 1, kind: 'table', headers, cells, authored: clone(table)};
|
|
155
180
|
}
|
|
156
181
|
|
|
157
182
|
function relationshipsXml(entries, path) {
|
|
@@ -205,7 +230,9 @@ export function attachDataProvenance(entries, {records, paths = new Map(), datas
|
|
|
205
230
|
if (seen.has(name)) throw Error(`Duplicate generated frame name ${name}.`);
|
|
206
231
|
seen.add(name);
|
|
207
232
|
const record = records.get(name);
|
|
208
|
-
const
|
|
233
|
+
const native = record.kind === 'table' ? parser.parse(frames.find(frame => frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] === name))?.['p:graphicFrame']?.['a:graphic']?.['a:graphicData']?.['a:tbl'] : undefined;
|
|
234
|
+
const value = record.kind === 'chart' ? {...record, evidence: evidence.get(name) ?? []} : {...record, evidence: tableCellEvidence(native, relationships), environment: tableEnvironmentEvidence(entries)};
|
|
235
|
+
if (!fits(value, paths.get(name) ?? name)) {records.delete(name); seen.delete(name); continue;}
|
|
209
236
|
const part = `ppt/tags/opfData${++count}.xml`;
|
|
210
237
|
if (entries[part]) throw Error('Colliding data tag part.');
|
|
211
238
|
entries[part] = tagPart(DATA_TAG, value);
|
|
@@ -310,6 +337,9 @@ export function readDataTag(entries, frame, relationships) {
|
|
|
310
337
|
&& (entry[3] === null || typeof entry[3] === 'string') && typeof entry[4] === 'string';
|
|
311
338
|
if (!record.headers.every(header) || !record.cells.every(cell)) throw Error('Invalid table data record.');
|
|
312
339
|
}
|
|
340
|
+
if (record.kind === 'table' && record.authored !== undefined && !object(record.authored)) throw Error('Invalid authored table record.');
|
|
341
|
+
if (record.kind === 'table' && record.evidence !== undefined && (!Array.isArray(record.evidence) || !record.evidence.every(row => Array.isArray(row) && row.every(cell => typeof cell === 'string')) || typeof record.environment !== 'string')) throw Error('Invalid table evidence record.');
|
|
342
|
+
if (record.kind === 'table' && record.authored !== undefined && record.evidence === undefined) throw Error('Authored table record has no native evidence.');
|
|
313
343
|
return record;
|
|
314
344
|
}
|
|
315
345
|
|
|
@@ -341,12 +371,17 @@ export function restoreChartData(chart, record, evidence, {datasets, report}) {
|
|
|
341
371
|
return restored;
|
|
342
372
|
}
|
|
343
373
|
|
|
344
|
-
/** The authored table data where the native
|
|
345
|
-
export function restoreTableData(table, record, {datasets, report}) {
|
|
374
|
+
/** The authored table data where the native evidence is unchanged; edited native cells otherwise. */
|
|
375
|
+
export function restoreTableData(table, record, {datasets, report, evidence, environment}) {
|
|
346
376
|
if (!object(table)) return table;
|
|
347
377
|
const columns = Array.isArray(table.columns) ? table.columns : undefined;
|
|
348
378
|
const rows = Array.isArray(table.rows) ? table.rows : [];
|
|
379
|
+
const sameCell = (row, column) => record.environment === environment && record.evidence?.[row]?.[column] === evidence?.[row]?.[column];
|
|
349
380
|
if (record.table) {
|
|
381
|
+
if (record.evidence && (record.environment !== environment || canonical(record.evidence) !== canonical(evidence))) {
|
|
382
|
+
report({code: 'table-data-provenance-changed', message: 'The table text, formatting or geometry changed after export; the dataset reference was not restored and native cells are retained.'});
|
|
383
|
+
return table;
|
|
384
|
+
}
|
|
350
385
|
const id = record.table.dataset;
|
|
351
386
|
if (!own(datasets, id)) {
|
|
352
387
|
report({code: 'table-dataset-unavailable', message: `The table referenced dataset '${id}', which this package's datasets record does not hold; the table imports its cells inline.`});
|
|
@@ -363,6 +398,28 @@ export function restoreTableData(table, record, {datasets, report}) {
|
|
|
363
398
|
}
|
|
364
399
|
return clone(record.table);
|
|
365
400
|
}
|
|
401
|
+
if (record.authored) {
|
|
402
|
+
if (!validWith({table: record.authored})) {
|
|
403
|
+
report({code: 'invalid-data-provenance', message: 'The authored table recorded at export is invalid; native cells are retained.'});
|
|
404
|
+
return table;
|
|
405
|
+
}
|
|
406
|
+
const authored = record.authored, headerOffset = authored.columns?.length ? 1 : 0;
|
|
407
|
+
const expected = [...(headerOffset ? [authored.columns] : []), ...authored.rows];
|
|
408
|
+
if (record.environment === environment && canonical(record.evidence) === canonical(evidence)) return clone(authored);
|
|
409
|
+
const next = {...table, ...(columns ? {columns: [...columns]} : {}), rows: rows.map(row => [...row])};
|
|
410
|
+
let changed = 0;
|
|
411
|
+
expected.forEach((row, r) => row.forEach((cell, c) => {
|
|
412
|
+
const target = r < headerOffset ? next.columns : next.rows[r - headerOffset];
|
|
413
|
+
if (target && c < target.length && sameCell(r, c)) target[c] = clone(cell);
|
|
414
|
+
else changed++;
|
|
415
|
+
}));
|
|
416
|
+
if (!validWith({table: next})) {
|
|
417
|
+
report({code: 'table-data-provenance-changed', message: 'The authored cells no longer form a valid table with the native grid; native cells are retained.'});
|
|
418
|
+
return table;
|
|
419
|
+
}
|
|
420
|
+
if (changed) report({code: 'table-data-provenance-changed', message: `${changed} authored table ${changed === 1 ? 'cell was' : 'cells were'} not restored because ${changed === 1 ? 'its' : 'their'} native text, formatting or geometry changed; native edits are retained.`});
|
|
421
|
+
return next;
|
|
422
|
+
}
|
|
366
423
|
const next = {...table, ...(columns ? {columns: [...columns]} : {}), rows: rows.map(row => Array.isArray(row) ? [...row] : row)};
|
|
367
424
|
let changed = 0;
|
|
368
425
|
for (const entry of record.headers) {
|
|
@@ -125,6 +125,16 @@ const SLIDE_DESIGN_FIELDS = [...STYLE_REFERENCES, 'background', ...COMPOSITION_H
|
|
|
125
125
|
// every new design or metadata key here, never to the legacy sections.
|
|
126
126
|
const DOCUMENT_SUPPLEMENT = Object.freeze({design: BRAND_ASSETS, metadata: Object.freeze(['filename', 'extensions'])});
|
|
127
127
|
const SLIDE_SUPPLEMENT = Object.freeze({design: BRAND_ASSETS});
|
|
128
|
+
// RR-59: document-level values the import reads back although export only baked them in from engine defaults: the
|
|
129
|
+
// canonical `$schema`, the package title (`name`), the language the runs carry, and the theme, colour scheme, font scheme
|
|
130
|
+
// and slide size of the generated theme. In `full` mode OPF_DOCUMENT_V1.absent lists the ones the source did not state
|
|
131
|
+
// (a top-level key, which every published importer ignores), and import leaves each of them absent while the native
|
|
132
|
+
// value it would be read from is still the default the export wrote; a value edited in PowerPoint is imported as observed.
|
|
133
|
+
// (`author`, the deck background and the slides inheriting it already return absent whenever the document tag exists.)
|
|
134
|
+
export const DEFAULTED = Object.freeze(['$schema', 'name', 'language', 'design.theme', 'design.colorScheme', 'design.fontScheme', 'design.dimensions']);
|
|
135
|
+
// The package title export writes when the document has no `name` (and no `filename`).
|
|
136
|
+
export const DEFAULT_TITLE = 'OPF Presentation';
|
|
137
|
+
const statedAt = (presentation, path) => path.startsWith('design.') ? own(presentation.design, path.slice(7)) : own(presentation, path);
|
|
128
138
|
|
|
129
139
|
// Storage shape: move the supplement keys out of the legacy sections.
|
|
130
140
|
function toStoredShape(record, spec) {
|
|
@@ -496,6 +506,11 @@ export function documentProvenance(presentation, {mode = 'full', layoutOf = () =
|
|
|
496
506
|
return record;
|
|
497
507
|
});
|
|
498
508
|
const document = {v: 1, slides: slides.length};
|
|
509
|
+
// 'references-only' stores no absence: it leaves stated values out, so a missing key there proves nothing.
|
|
510
|
+
if (!referencesOnly) {
|
|
511
|
+
const absent = DEFAULTED.filter(path => !statedAt(presentation, path));
|
|
512
|
+
if (absent.length) document.absent = absent;
|
|
513
|
+
}
|
|
499
514
|
if (Object.keys(design).length) document.design = design;
|
|
500
515
|
if (Object.keys(metadata).length) document.metadata = metadata;
|
|
501
516
|
// 'full' stores the whole registry, so unreferenced assets return as authored;
|
|
@@ -504,8 +519,9 @@ export function documentProvenance(presentation, {mode = 'full', layoutOf = () =
|
|
|
504
519
|
// The embedded records (and group declarations) the stored values reference.
|
|
505
520
|
const catalogRecords = referencedCatalogs(presentation.catalogs, documentReferences({...metadata, design, slides}));
|
|
506
521
|
if (catalogRecords) document.catalogs = catalogRecords;
|
|
507
|
-
// Slide content topology is added per slide by the exporter (recordContentTopology).
|
|
508
|
-
|
|
522
|
+
// Slide content topology is added per slide by the exporter (recordContentTopology). In 'full' mode every document states
|
|
523
|
+
// something: the defaults it leaves to the engine (`absent`) are worth the tags, so import can leave them absent.
|
|
524
|
+
const stated = !referencesOnly || Object.keys(design).length || Object.keys(metadata).length || document.assets !== undefined || slides.some(record => Object.keys(record).length > 2);
|
|
509
525
|
return {mode, document, slides, report, stated: Boolean(stated)};
|
|
510
526
|
}
|
|
511
527
|
|
|
@@ -648,6 +664,7 @@ function storable(entries, provenance) {
|
|
|
648
664
|
};
|
|
649
665
|
const source = provenance.document;
|
|
650
666
|
const document = {v: 1, slides: source.slides};
|
|
667
|
+
if (source.absent) document.absent = [...source.absent];
|
|
651
668
|
const assets = {};
|
|
652
669
|
for (const [id, asset] of Object.entries(source.assets ?? {})) {
|
|
653
670
|
const value = prepare(`assets.${id}`, asset);
|
|
@@ -738,8 +755,9 @@ function storable(entries, provenance) {
|
|
|
738
755
|
* `entries` maps part paths to bytes and is updated in place.
|
|
739
756
|
*/
|
|
740
757
|
export function attachDocumentProvenance(entries, provenance) {
|
|
741
|
-
// A document that states nothing (
|
|
742
|
-
// content topology) gets no tags and its bytes are
|
|
758
|
+
// A document that states nothing (in 'references-only' mode: no references,
|
|
759
|
+
// metadata, slide fields or content topology) gets no tags and its bytes are
|
|
760
|
+
// unchanged. In 'full' mode the defaults it leaves absent are always recorded.
|
|
743
761
|
if (!provenance || !(provenance.stated || provenance.slides.some(record => record.content !== undefined))) return;
|
|
744
762
|
const presentationXml = dec.decode(entries['ppt/presentation.xml']);
|
|
745
763
|
const presentationRoot = parser.parse(presentationXml)['p:presentation'];
|
|
@@ -829,6 +847,9 @@ function validateDocument(stored) {
|
|
|
829
847
|
const references = stored.references;
|
|
830
848
|
if (references !== undefined && (!Array.isArray(references) || references.length > 4096 || !references.every(object))) throw Error('Invalid references record.');
|
|
831
849
|
validateOmitted(value.omitted);
|
|
850
|
+
// Untrusted: only a short list of strings is read, and a path this importer does not know is ignored (a newer exporter's).
|
|
851
|
+
if (value.absent !== undefined && (!Array.isArray(value.absent) || value.absent.length > 64 || !value.absent.every(path => typeof path === 'string' && path.length <= 256))) throw Error('Invalid absent field list.');
|
|
852
|
+
if (value.absent !== undefined) value.absent = value.absent.filter(path => DEFAULTED.includes(path));
|
|
832
853
|
if (author !== undefined || references !== undefined) {
|
|
833
854
|
const {author: _author, references: _references, ...rest} = value;
|
|
834
855
|
return {...rest, metadata: {...rest.metadata, ...(author !== undefined ? {author} : {}), ...(references !== undefined ? {references} : {})}};
|
|
@@ -928,7 +949,7 @@ export function slideListBreaks(entries, path, root, rels) {
|
|
|
928
949
|
* `nativeSections` is the package's native section list as one name (or
|
|
929
950
|
* undefined) per slide, or null when the package has no list.
|
|
930
951
|
*/
|
|
931
|
-
export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, catalogs: hostCatalogs, nativeSections = null}, report) {
|
|
952
|
+
export function restoreDocumentProvenance(imported, {entries, presentationRoot, presentationRels, slides, organizationConflict = false, speakerConflict = false, catalogs: hostCatalogs, nativeSections = null, schemaOption = false}, report) {
|
|
932
953
|
const invalid = message => report({code: 'invalid-document-provenance', path: '', message: `${message} Ordinary import keeps the values observed in the PPTX.`});
|
|
933
954
|
let document;
|
|
934
955
|
try {
|
|
@@ -1079,6 +1100,21 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
1079
1100
|
group(`design.${key}`, [set(['design', key], value)]);
|
|
1080
1101
|
}
|
|
1081
1102
|
const styleIntact = STYLE_REFERENCES.every(key => restoredStyle[key] !== false);
|
|
1103
|
+
// RR-59: a default the source left absent stays absent while the native value it is read from is the one export wrote
|
|
1104
|
+
// (FF-24's theme recovery, for example, gives a theme-only deck its colour scheme). A native edit is imported as observed.
|
|
1105
|
+
const absent = new Set(array(document.absent));
|
|
1106
|
+
const defaultGates = {theme: match.colors && match.fonts, colorScheme: match.colors, fontScheme: match.fonts, dimensions: match.size};
|
|
1107
|
+
for (const [key, unchanged] of Object.entries(defaultGates)) {
|
|
1108
|
+
if (absent.has(`design.${key}`) && unchanged && storedDesign[key] === undefined && own(imported.design, key)) group(`design.${key}`, [remove(['design', key])]);
|
|
1109
|
+
}
|
|
1110
|
+
// No native field holds `$schema`; an importer option that names one keeps it.
|
|
1111
|
+
if (absent.has('$schema') && !schemaOption && own(imported, '$schema')) group('$schema', [remove(['$schema'])]);
|
|
1112
|
+
// The package title export wrote for a document with no name: its filename, else the default title.
|
|
1113
|
+
const defaultTitle = own(document.metadata, 'filename') ? document.metadata.filename : array(document.omitted).includes('filename') ? undefined : DEFAULT_TITLE;
|
|
1114
|
+
if (absent.has('name') && defaultTitle !== undefined && imported.name === defaultTitle) group('name', [remove(['name'])]);
|
|
1115
|
+
// The runs' language is compared with the engine default in reconcileLanguage (src/script-fonts.js), which keeps this
|
|
1116
|
+
// removal only while the runs still carry that default.
|
|
1117
|
+
if (absent.has('language') && own(imported, 'language')) group('language', [remove(['language'])]);
|
|
1082
1118
|
// Brand images have no native gate (the P2 logo picture is consumed separately).
|
|
1083
1119
|
for (const key of BRAND_ASSETS) {
|
|
1084
1120
|
if (storedDesign[key] === undefined) continue;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// FA-09: the text alternative of a chart (`Chart.alt`) is
|
|
2
|
-
// picture's alt text uses. An empty `alt` marks the
|
|
1
|
+
// FA-09, FA-27: the text alternative of a chart (`Chart.alt`) or a table (`Table.alt`) is its graphic frame's
|
|
2
|
+
// `p:nvGraphicFramePr/p:cNvPr/@descr`, the attribute a picture's alt text uses. An empty `alt` marks the frame decorative, which PowerPoint stores as the `adec:decorative` extension of
|
|
3
3
|
// the same `p:cNvPr` ("Mark as decorative"), not as an empty `descr`: PowerPoint writes `descr=""` for any shape without alt text.
|
|
4
4
|
const DECORATIVE_URI = '{C183D7F6-B498-43B3-948B-1728B52AA6E4}';
|
|
5
5
|
const DECORATIVE_NS = 'http://schemas.microsoft.com/office/drawing/2017/decorative';
|
|
@@ -34,8 +34,8 @@ export function writeFrameAlt(frame, alt) {
|
|
|
34
34
|
return `${frame.slice(0, start.index)}${open}${body}${close}${frame.slice(tail)}`;
|
|
35
35
|
}
|
|
36
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
|
|
37
|
+
/** Apply every recorded chart and table alt to the frames (classic and chartex fallback charts, tables) of a slide part's XML, matched by frame name. */
|
|
38
|
+
export function applyFrameAlt(xml, alts) {
|
|
39
39
|
return xml.replace(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g, frame => {
|
|
40
40
|
const name = frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
41
41
|
return alts.has(name) ? writeFrameAlt(frame, alts.get(name)) : frame;
|
|
@@ -43,3 +43,8 @@ export async function svgToPng() {
|
|
|
43
43
|
export async function readLocalFile() {
|
|
44
44
|
throw new Error('A browser cannot read a local file: pass the SVG as a data URI or through imageResolver.');
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
// A browser cannot check a local path; PptxGenJS loads it as before.
|
|
48
|
+
export async function localFileReadable() {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
@@ -10,7 +10,8 @@ export async function webpToPng(bytes) {
|
|
|
10
10
|
// The default rasterizer for the PNG fallback of an SVG picture: opf-render's resvg renderer (an optional peer), which
|
|
11
11
|
// is deterministic (no system fonts, no network or file access for the SVG's own references). `text` selects the
|
|
12
12
|
// bundled fonts; an SVG with no text needs none. Rejects with code `svg-rasterizer-unavailable` when opf-render is
|
|
13
|
-
// not installed
|
|
13
|
+
// not installed, and also when it is installed but its PNG converter (`@resvg/resvg-js`, an optional peer of opf-render
|
|
14
|
+
// 0.16) is not: opf-render's `converter-missing` error maps to the same code, with its install command on `install`.
|
|
14
15
|
//
|
|
15
16
|
// This is the one place the exporter calls the renderer. opf-render's svgToPng(svg, {fonts, scale, background}) takes the
|
|
16
17
|
// fonts handle for the faces it may draw with: here only the bundled ones, never the system's.
|
|
@@ -23,7 +24,18 @@ export async function svgToPng(svg, {scale, text}) {
|
|
|
23
24
|
unavailable.code = 'svg-rasterizer-unavailable';
|
|
24
25
|
throw unavailable;
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
try {
|
|
28
|
+
return new Uint8Array(await render(svg, {fonts: {useBundledFonts: text === true, loadSystemFonts: false}, scale, background: 'rgba(0, 0, 0, 0)'}));
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.code !== 'converter-missing') throw error;
|
|
31
|
+
// opf-render 0.16: @resvg/resvg-js (the PNG converter) is an optional peer. Same path as a missing opf-render, keeping
|
|
32
|
+
// opf-render's own message and install command.
|
|
33
|
+
const install = typeof error.details?.install === 'string' ? error.details.install : undefined;
|
|
34
|
+
const unavailable = new Error(`SVG pictures need the PNG converter of @openpresentation/opf-render (or an options.svgRasterizer): ${error.message}`, {cause: error});
|
|
35
|
+
unavailable.code = 'svg-rasterizer-unavailable';
|
|
36
|
+
if (install) unavailable.install = install;
|
|
37
|
+
throw unavailable;
|
|
38
|
+
}
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
// A local SVG file (a path source), read as bytes; PptxGenJS reads raster paths itself.
|
|
@@ -31,3 +43,9 @@ export async function readLocalFile(path) {
|
|
|
31
43
|
const { readFile } = await import('node:fs/promises');
|
|
32
44
|
return new Uint8Array(await readFile(path));
|
|
33
45
|
}
|
|
46
|
+
|
|
47
|
+
// Whether PptxGenJS will be able to read a local raster path (it reads the file itself, and fails the whole export when it cannot).
|
|
48
|
+
export async function localFileReadable(path) {
|
|
49
|
+
const { stat } = await import('node:fs/promises');
|
|
50
|
+
try { return (await stat(path)).isFile(); } catch { return false; }
|
|
51
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -40,14 +40,17 @@ export interface ChartDataUnplottableDiagnostic { code: "chart-data-unplottable"
|
|
|
40
40
|
export interface ContentPlaceholderDiagnostic { code: "content-placeholder"; path: string; message: string; reason: "table-has-no-rows" | "unsupported-payload" }
|
|
41
41
|
/**
|
|
42
42
|
* An image that cannot be exported as a picture: the preview's placeholder was exported instead (or no watermark, or the background colour).
|
|
43
|
+
* `reason: "unresolved-source"`: no embedded bytes, declared asset or host resolution was available (remote URLs are never fetched).
|
|
43
44
|
* `reason: "unsupported-format"`: the embedded bytes are no readable PNG, JPEG, GIF, WebP or SVG. For an SVG (a native SVG picture over a PNG fallback):
|
|
44
45
|
* `"svg-malformed"` (not well-formed XML, or no `xmlns` SVG root), `"svg-no-size"` (no width and height and no viewBox), `"svg-too-large"` (over 8 MiB),
|
|
45
46
|
* `"svg-unsafe"` (a DOCTYPE with external or markup entities), `"svg-rasterizer-unavailable"` (no `options.svgRasterizer` and `@openpresentation/opf-render`
|
|
46
47
|
* is not installed; always the case in a browser build without `svgRasterizer`) `"svg-render-failed"` (the rasterizer threw or returned no PNG) or `"svg-unreadable"` (a local `.svg` path that could not be read).
|
|
48
|
+
* `reason: "file-unreadable"`: a local raster path that is not a readable file (Node; a browser build leaves local paths to the engine).
|
|
47
49
|
* `strictAssets` throws `unsupported-image-dimensions` for an unreadable raster, `invalid-svg-image` for the SVG content reasons above (and `svg-unreadable`), `svg-rasterizer-unavailable` for a
|
|
48
|
-
* missing rasterizer
|
|
50
|
+
* missing rasterizer, `svg-render-failed` for a failed one and `missing-asset` for an unreadable local file instead.
|
|
51
|
+
* Every picture path reports this (image blocks and region images, quote photos, background pictures, watermarks, logos, header/footer images, picture bullets), once per picture.
|
|
49
52
|
*/
|
|
50
|
-
export interface UnresolvedAssetDiagnostic { code: "unresolved-asset"; path: string; message: string; reason?: "unsupported-format" | "svg-malformed" | "svg-no-size" | "svg-too-large" | "svg-unsafe" | "svg-rasterizer-unavailable" | "svg-render-failed" | "svg-unreadable" }
|
|
53
|
+
export interface UnresolvedAssetDiagnostic { code: "unresolved-asset"; path: string; message: string; reason?: "unresolved-source" | "unsupported-format" | "svg-malformed" | "svg-no-size" | "svg-too-large" | "svg-unsafe" | "svg-rasterizer-unavailable" | "svg-render-failed" | "svg-unreadable" | "file-unreadable" }
|
|
51
54
|
/** An SVG picture had scripts, `foreignObject`, event handlers, references outside the file, `@import` rules or a DOCTYPE; they were removed from the embedded SVG (on import too). Nothing in an SVG is run or fetched. */
|
|
52
55
|
export interface SvgSanitizedDiagnostic { code: "svg-sanitized"; path: string; message: string }
|
|
53
56
|
/** An SVG image block with a duotone recolor or a non-rectangular shape (or an SVG image background with the tile fit) exports as its PNG raster, not as a native SVG picture, so the effect applies as in the preview (PowerPoint applies opacity, grayscale and a border to an SVG picture, and those stay native). */
|
|
@@ -114,7 +117,8 @@ export interface ToPptxOptions {
|
|
|
114
117
|
* The fonts handle: what the renderer's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`), or any object with a
|
|
115
118
|
* `textMeasurement` (core's `Fonts`). The exporter lays text out with `fonts.textMeasurement` and always names the chosen
|
|
116
119
|
* families, never a substitute face. Without it, layout uses core's portable estimate. This is the only way to pass a measurement:
|
|
117
|
-
* a top-level `textMeasurement` option is not read.
|
|
120
|
+
* a top-level `textMeasurement` option is not read. When the optional renderer is installed, resolved slide/run script
|
|
121
|
+
* slots use its shared script-aware measurement planner; custom providers remain usable without the renderer.
|
|
118
122
|
*/
|
|
119
123
|
fonts?: Fonts;
|
|
120
124
|
/** Match preview/pagination clearance around supplied vector text outlines; default 1. */
|
package/dist/index.js
CHANGED
|
@@ -7,10 +7,10 @@ import {giveNotesMastersOwnThemes, giveSlidesScriptMasters} from './master-theme
|
|
|
7
7
|
import {legacyVendorOutput} from './vendor-compat.js';
|
|
8
8
|
import {readChartCategoryHeading,writeChartCategoryHeading,writeChartWorkbookFormats,repairChartWorkbookRanges} from './chart-workbook.js';
|
|
9
9
|
import {applyChartNumberFormats,chartNumber,excelCode,formattedColumns,inlineChartData,inlineTableData,isDatasetRef,numericRanges,resolveChartData} from './chart-data.js';
|
|
10
|
-
import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord} from './data-provenance.js';
|
|
10
|
+
import {attachDataProvenance,chartDataRecord,chartEvidence,readDataTag,readDatasetsTag,restoreChartData,restoreTableData,tableDataRecord,tableCellEvidence,tableEnvironmentEvidence} from './data-provenance.js';
|
|
11
11
|
import {CHARTEX_FALLBACK,resolveChartType,chartTypeFromNative,applyChartConstruct,comboFromNative,NATIVE_CHART_ELEMENTS} from './chart-types.js';
|
|
12
12
|
import {attachChartexParts,chartFromChartex,CHARTEX_GRAPHIC_DATA_URI} from './chartex.js';
|
|
13
|
-
import {
|
|
13
|
+
import {applyFrameAlt,readFrameAlt} from './frame-alt.js';
|
|
14
14
|
import {applyDataLabels,chartOptionsFromClassic,chartTargetFor,classicChartOptions,reportChartOptionDiagnostics,resolveChartOptionsFor} from './chart-options.js';
|
|
15
15
|
import {attachCodeTags, attachTextTags, codeManifest, importCodeGroups, nativeShapeParagraphs, nativeTextShapes} from './code-provenance.js';
|
|
16
16
|
import {attachMetricTags,metricManifest,importMetricGroups} from './metric-provenance.js';
|
|
@@ -42,13 +42,13 @@ import {importBackground} from './background-import.js';
|
|
|
42
42
|
import {chartHighlightPlan, applyChartHighlight} from './chart-highlight.js';
|
|
43
43
|
import {themeSlotColors, writeThemeColors, schemeColorValue, schemeBackgroundFill, schemeBackgroundValue, defaultTextSchemeValues, tableTextSchemeValue, solidColorXml, writeMasterBackground, inheritLayoutBackground, readThemeSlotColors, recoverColorScheme, recoverTheme, presentationThemePath} from './theme-colors.js';
|
|
44
44
|
import {languageDiagnostics, observeLanguage, observedRtl, partScriptFonts, physicalAlignment, planScriptFonts, planSlideThemes, reconcileLanguage, reportPerSlideNotesScriptFonts, runLanguageFonts, runLanguageTag, stripRunScriptFonts, themeEastAsianFromLatin} from './script-fonts.js';
|
|
45
|
-
import { webpToPng, svgToPng, readLocalFile } from '#image-fallback';
|
|
45
|
+
import { webpToPng, svgToPng, readLocalFile, localFileReadable } from '#image-fallback';
|
|
46
46
|
import { prepareSvg, svgDataUriBytes, svgRasterScale, svgBlipRelationship, attachSvgPictures } from './svg-image.js';
|
|
47
47
|
import { rasterMetadata, pictureTransform, normalizeImageOrientation } from './image-geometry.js';
|
|
48
48
|
import {
|
|
49
49
|
catalogRecords, chartPaletteForFill, codeHighlightBands, codeHighlightColors, codeHighlightLines, codeLineNumbers, codeLineRuns, codeSyntaxPaletteForScheme, composeSlide,
|
|
50
50
|
ENGINE_DEFAULT_FONT_SCHEME, defaultSlideBackground, fitImage, fitText, layoutTable, layoutWatermark, metricTrendMark, resolveCanvasDimensions, resolveColorRoles, resolveFontFamilies,
|
|
51
|
-
resolveReference, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
|
|
51
|
+
resolveReference, resolveScriptFonts, resolveTextStyle, textColorForFill, textWidthMeasurer, timelineMarkerShapes, timelineTextColor, tokenizeCode
|
|
52
52
|
} from "@openpresentation/opf/composition";
|
|
53
53
|
import { colorContext, resolveColorRefValue, resolveExportColor, resolveVariableColors } from "./color-ref.js";
|
|
54
54
|
import PptxGenJS from "../vendor/pptxgenjs/pptxgen.es.js";
|
|
@@ -241,6 +241,12 @@ export async function toPptx(input, options = {}) {
|
|
|
241
241
|
// `options.fonts` is the fonts handle (the renderer's loadFonts()); the exporter reads its measurement, wrapped so the package names the
|
|
242
242
|
// chosen family (FF-31). Internally `options.textMeasurement` is that wrapped measurement; it is not a public option.
|
|
243
243
|
options = {...options, textMeasurement: chosenFamilyMeasurement(options.fonts?.textMeasurement), svgRasters: new Map()};
|
|
244
|
+
// The renderer remains optional for custom measurement providers. Its public script planner supplies the same
|
|
245
|
+
// shaping/measurement segmentation as preview when installed; the selected native family wrapper stays outside it.
|
|
246
|
+
if (options.textMeasurement) {
|
|
247
|
+
try { options.createScriptFonts = (await import("@openpresentation/opf-render/fonts")).createScriptFonts; }
|
|
248
|
+
catch (error) { if (error?.code !== "ERR_MODULE_NOT_FOUND") throw error; }
|
|
249
|
+
}
|
|
244
250
|
|
|
245
251
|
const context = resolvePresentationContext(presentation, {...options,textMeasurement:undefined});
|
|
246
252
|
context.listMarkers = new Map();
|
|
@@ -280,7 +286,7 @@ export async function toPptx(input, options = {}) {
|
|
|
280
286
|
context.metricTags = new Map();
|
|
281
287
|
context.metricDescriptions = new Map();
|
|
282
288
|
context.chartHeadings = new Map();
|
|
283
|
-
context.
|
|
289
|
+
context.frameAlts = new Map();
|
|
284
290
|
context.chartFonts = new Map();
|
|
285
291
|
context.chartPalettes = new Map();
|
|
286
292
|
context.chartex = new Map();
|
|
@@ -454,6 +460,8 @@ export async function fromPptx(input, options = {}) {
|
|
|
454
460
|
// Stored layout ids also resolve in the host's registered catalogs (FA-23).
|
|
455
461
|
catalogs: options.catalogs,
|
|
456
462
|
nativeSections: slideSections,
|
|
463
|
+
// An importer `schema` option names the document's $schema, so an absent source $schema does not remove it (RR-59).
|
|
464
|
+
schemaOption: options.schema !== undefined,
|
|
457
465
|
slides: slidePaths.map((path, index) => ({path, root: furnitureContexts[index].root, relationships: furnitureContexts[index].relationships, contentBounds: furnitureContexts[index].contentBounds}))}, report);
|
|
458
466
|
// The stored language wins while the runs still carry its tag (FF-07).
|
|
459
467
|
restored.groups = reconcileLanguage(restored.groups, observedLanguage, report);
|
|
@@ -968,13 +976,17 @@ function importGraphicFrame(entries, frame, slidePath, relationships, importedTa
|
|
|
968
976
|
};
|
|
969
977
|
if (table) {
|
|
970
978
|
const stored = importedTable && record('table');
|
|
979
|
+
const restoredTable = stored ? restore('table', importedTable, () => restoreTableData(importedTable, stored, {evidence: tableCellEvidence(table, relationships), environment: tableEnvironmentEvidence(entries), datasets: data.datasets, report: diagnostic => data.report?.('table', diagnostic)})) : importedTable;
|
|
980
|
+
// FA-27: the frame's descr (or PowerPoint's decorative marker) is the table's text alternative.
|
|
981
|
+
const alt = restoredTable && typeof restoredTable === 'object' ? readFrameAlt(frame["p:nvGraphicFramePr"]?.["p:cNvPr"]) : undefined;
|
|
971
982
|
return {
|
|
972
983
|
kind: "table",
|
|
973
984
|
bounds,
|
|
974
985
|
name,
|
|
975
986
|
payload: {
|
|
976
987
|
type: "table",
|
|
977
|
-
|
|
988
|
+
// RR-59: an authored table restored from provenance takes its alt from the frame too, so an alt removed in PowerPoint stays removed.
|
|
989
|
+
table: restoredTable && typeof restoredTable === 'object' ? (({alt: _recorded, ...rest}) => alt === undefined ? rest : {...rest, alt})(restoredTable) : restoredTable
|
|
978
990
|
}
|
|
979
991
|
};
|
|
980
992
|
}
|
|
@@ -1892,6 +1904,7 @@ function configurePresentation(pptx, presentation, context) {
|
|
|
1892
1904
|
}
|
|
1893
1905
|
|
|
1894
1906
|
async function addSlide(pptx, presentation, opfSlide, slideIndex, context, options) {
|
|
1907
|
+
options = {...options, textMeasurement: slideTextMeasurement(presentation, slideIndex, options)};
|
|
1895
1908
|
const slide = pptx.addSlide();
|
|
1896
1909
|
const slideContext = exportSlideContext(presentation, slideIndex, context, options);
|
|
1897
1910
|
const { widthInches, heightInches } = slideContext.dimensions;
|
|
@@ -2215,7 +2228,11 @@ async function addImagePayload(slide, presentation, asset, region, path, context
|
|
|
2215
2228
|
async function addImagePicture(slide, presentation, asset, box, path, context, options, {fit, focus, effects = null, shrink = true, treatment, alt, role = 'block', objectName} = {}) {
|
|
2216
2229
|
const outcome = { box, cover: fit === 'cover' };
|
|
2217
2230
|
const resolved = await resolveImage(asset, presentation, options, path, outcome);
|
|
2218
|
-
if (!resolved)
|
|
2231
|
+
if (!resolved) {
|
|
2232
|
+
// RR-59 (AUTO-26): a content picture never vanishes silently. A background names its own fallback (the slide colour).
|
|
2233
|
+
if (!outcome.reported) options.onDiagnostic?.(role === 'background' ? backgroundUnresolved(path) : {code: "unresolved-asset", path, reason: "unresolved-source", message: "The image needs an embedded raster, a declared asset or a host imageResolver; the image exports as the unavailable-image placeholder. Remote images are never fetched."});
|
|
2234
|
+
return undefined;
|
|
2235
|
+
}
|
|
2219
2236
|
const name = objectName ?? imageName(1 + [...context.images.keys()].filter(key => /^OPF image \d+$/.test(key)).length);
|
|
2220
2237
|
// PowerPoint applies opacity (a:alphaModFix), grayscale (a:grayscl) and the border (a:ln) to an SVG picture (native check
|
|
2221
2238
|
// 2026-10-01). A duotone recolor and a non-rectangular mask are not confirmed on an SVG picture, and a tiled picture fill
|
|
@@ -2305,10 +2322,8 @@ async function addBackgroundImage(slide, presentation, background, slideIndex, s
|
|
|
2305
2322
|
fit: background.fit, focus: background.focus, effects: background.opacity === undefined && !background.recolor ? null : effects, shrink: false,
|
|
2306
2323
|
treatment, alt, role: 'background', objectName: backgroundImageName(slidePath)
|
|
2307
2324
|
});
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
return;
|
|
2311
|
-
}
|
|
2325
|
+
// addImagePicture reported the unresolved source once (opf-pptx#210).
|
|
2326
|
+
if (!name) return;
|
|
2312
2327
|
owner = name;
|
|
2313
2328
|
overlayName = imageOverlayName(name);
|
|
2314
2329
|
} else {
|
|
@@ -2316,7 +2331,7 @@ async function addBackgroundImage(slide, presentation, background, slideIndex, s
|
|
|
2316
2331
|
const outcome = { box, cover: background.fit === 'cover' };
|
|
2317
2332
|
const resolved = await resolveImage(background.src, presentation, options, background.path, outcome);
|
|
2318
2333
|
if (!resolved) {
|
|
2319
|
-
if (!outcome.reported) options.onDiagnostic?.(
|
|
2334
|
+
if (!outcome.reported) options.onDiagnostic?.(backgroundUnresolved(background.path));
|
|
2320
2335
|
return;
|
|
2321
2336
|
}
|
|
2322
2337
|
// PptxGenJS embeds the raster and its relationship; packaging replaces its stretched fill with the fitted native fill.
|
|
@@ -2329,6 +2344,8 @@ async function addBackgroundImage(slide, presentation, background, slideIndex, s
|
|
|
2329
2344
|
if (background.overlay) addImageOverlay(slide, background.overlay, structuredClone(isPlainObject(authored) ? authored.overlay : undefined) ?? {color: background.overlay.color, opacity: background.overlay.opacity}, overlayName, owner, { ...slideContext, slidePath }, effects);
|
|
2330
2345
|
}
|
|
2331
2346
|
|
|
2347
|
+
const backgroundUnresolved = path => ({code: 'unresolved-asset', path, reason: 'unresolved-source', message: 'The background image needs an embedded raster, a declared asset or a host imageResolver; the slide background color was exported instead.'});
|
|
2348
|
+
|
|
2332
2349
|
// design.watermark: one native picture per slide, added after the slide image
|
|
2333
2350
|
// (and its overlay) and before all content, the preview's paint order. The
|
|
2334
2351
|
// slide's own design.watermark replaces the deck's, and false suppresses it.
|
|
@@ -2453,7 +2470,7 @@ function addChartPayload(slide, chart, region, context, options = {}, path = "ch
|
|
|
2453
2470
|
recordPayloadData(context, objectName, 'chart', presentation, path);
|
|
2454
2471
|
context.chartFonts.set(objectName,{heading:context.fonts.heading,body:context.fonts.body});
|
|
2455
2472
|
// FA-09: the chart's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker), applied before the chartex frame copies the head.
|
|
2456
|
-
if (typeof chart.alt === 'string') context.
|
|
2473
|
+
if (typeof chart.alt === 'string') context.frameAlts.set(objectName, chart.alt);
|
|
2457
2474
|
const preferredPalette = CHART_COLORS.map(color=>`#${color}`);
|
|
2458
2475
|
const palette = chartPaletteForFill(panelFill, preferredPalette).map(color=>normalizeHex(color));
|
|
2459
2476
|
// FA-14: chart.highlight. A highlighted chart writes accent and muted colours over the series palette, and its data labels
|
|
@@ -2640,6 +2657,8 @@ function addTablePayload(slide, authoredTable, region, context, options, path, p
|
|
|
2640
2657
|
const objectName = `OPF table ${context.tableHeaders.size + 1}`;
|
|
2641
2658
|
context.tableHeaders.set(objectName, hasHeaders);
|
|
2642
2659
|
recordPayloadData(context, objectName, 'table', presentation, path, layout);
|
|
2660
|
+
// FA-27: the table's text alternative (the frame's descr; the empty string is PowerPoint's decorative marker).
|
|
2661
|
+
if (typeof authoredTable?.alt === 'string') context.frameAlts.set(objectName, authoredTable.alt);
|
|
2643
2662
|
if (layout.rows.some(row => row.cells.some(cell => Object.keys(cell.style ?? {}).length))) context.tableCells.set(objectName, {layout, scale, context, defaultBorder:{color:"accent5",width:1/scale}});
|
|
2644
2663
|
slide.addTable(rows, {
|
|
2645
2664
|
objectName,
|
|
@@ -2942,6 +2961,8 @@ async function addQuotePhoto(slide, presentation, photo, group, context, options
|
|
|
2942
2961
|
const outcome = { box, cover: true };
|
|
2943
2962
|
const resolved = await resolveImage(photo.value, presentation, options, photo.path, outcome);
|
|
2944
2963
|
if (!resolved) {
|
|
2964
|
+
// opf-pptx#210: the headshot placeholder reports its cause like every other picture, so strict export (--fail-on warning) gates it.
|
|
2965
|
+
if (!outcome.reported) options.onDiagnostic?.({code: 'unresolved-asset', path: photo.path, reason: 'unresolved-source', message: 'The quote photo needs an embedded raster, a declared asset or a host imageResolver; the photo exports as the unavailable-image placeholder. Remote images are never fetched.'});
|
|
2945
2966
|
addImagePlaceholder(slide, presentation, photo.value, box, photo.path, context, options);
|
|
2946
2967
|
return;
|
|
2947
2968
|
}
|
|
@@ -3121,6 +3142,35 @@ function sameTypeface(requested, resolved) {
|
|
|
3121
3142
|
// A legacy four-style family such as "Roboto Medium" is the chosen typeface.
|
|
3122
3143
|
return got === want || got.startsWith(`${want} `) && isFaceStyleSuffix(got.slice(want.length + 1));
|
|
3123
3144
|
}
|
|
3145
|
+
// Use each slide's core script profile, including a rich run's own language, for every fitting path.
|
|
3146
|
+
// Latin roles follow that slide's scheme; explicit run families remain under the planner's strict glyph policy.
|
|
3147
|
+
function slideTextMeasurement(presentation, index, options) {
|
|
3148
|
+
const measurement = options.fonts?.textMeasurement;
|
|
3149
|
+
if (!measurement || !options.createScriptFonts) return options.textMeasurement;
|
|
3150
|
+
const core = coreContext(presentation, index, {...options, textMeasurement: undefined});
|
|
3151
|
+
const languages = new Map();
|
|
3152
|
+
const planner = language => {
|
|
3153
|
+
const key = language ?? '';
|
|
3154
|
+
if (!languages.has(key)) {
|
|
3155
|
+
const resolved = resolveScriptFonts(presentation, {slideIndex: index, ...(options.catalogs !== undefined ? {catalogs: options.catalogs} : {}), ...(language ? {language} : {})});
|
|
3156
|
+
const slots = role => {
|
|
3157
|
+
const latin = core.options.fontFamilies[role];
|
|
3158
|
+
const slot = name => ['latin', 'schemeFamily'].includes(resolved.sources?.[name]) ? latin : resolved[role]?.[name] ?? latin;
|
|
3159
|
+
return {latin, eastAsian: slot('eastAsian'), complexScript: slot('complexScript')};
|
|
3160
|
+
};
|
|
3161
|
+
languages.set(key, options.createScriptFonts({...resolved, heading: slots('heading'), body: slots('body'), serif: core.resolved.fontScheme.type === 'serif'}, measurement).textMeasurement);
|
|
3162
|
+
}
|
|
3163
|
+
return languages.get(key);
|
|
3164
|
+
};
|
|
3165
|
+
const base = planner(), pick = style => planner(style?.lang) ?? base;
|
|
3166
|
+
const adapted = {...base, measure: (text, size, style) => pick(style).measure(text, size, style)};
|
|
3167
|
+
// The planner's measurement object spreads its provider; inherited class methods must keep their receiver.
|
|
3168
|
+
if (typeof measurement.resolveStyle === 'function') adapted.resolveStyle = style => measurement.resolveStyle(style);
|
|
3169
|
+
if (typeof measurement.resolveFont === 'function') adapted.resolveFont = style => measurement.resolveFont(style);
|
|
3170
|
+
if (typeof base.outlineBounds === 'function') adapted.outlineBounds = (text, size, style) => pick(style).outlineBounds(text, size, style);
|
|
3171
|
+
return chosenFamilyMeasurement(adapted);
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3124
3174
|
function chosenFamilyMeasurement(measurement) {
|
|
3125
3175
|
if (!measurement || typeof measurement.resolveStyle !== 'function') return measurement;
|
|
3126
3176
|
const resolveFont = typeof measurement.resolveFont === 'function' ? style => measurement.resolveFont(style) : undefined;
|
|
@@ -3412,6 +3462,14 @@ async function resolveImage(asset, presentation, options, path, outcome = {}) {
|
|
|
3412
3462
|
}
|
|
3413
3463
|
return resolveSvgImage(file, options, path, outcome);
|
|
3414
3464
|
}
|
|
3465
|
+
// opf-pptx#210: a local raster PptxGenJS cannot read used to fail the whole export (pptxgen-failed) on every picture path.
|
|
3466
|
+
// It is unresolved like any other source: the placeholder (or the path's own fallback) and one diagnostic.
|
|
3467
|
+
if (resolved?.path && !(await localFileReadable(resolved.path))) {
|
|
3468
|
+
if (options.strictAssets) throw new OPFPptxError("missing-asset", "The local image file could not be read.", { path, src: resolved.path, reason: "file-unreadable" });
|
|
3469
|
+
options.onDiagnostic?.({ code: "unresolved-asset", path, reason: "file-unreadable", message: "The local image file could not be read; the image exports as the unavailable-image placeholder (or the picture's fallback)." });
|
|
3470
|
+
outcome.reported = true;
|
|
3471
|
+
return null;
|
|
3472
|
+
}
|
|
3415
3473
|
if (!resolved?.data) return resolved;
|
|
3416
3474
|
const bytes = dataUriBytes(resolved.data);
|
|
3417
3475
|
const raster = bytes && rasterMetadata(bytes);
|
|
@@ -3457,7 +3515,7 @@ async function resolveSvgImage(source, options, path, outcome) {
|
|
|
3457
3515
|
} catch (error) {
|
|
3458
3516
|
const reason = error?.code === "svg-rasterizer-unavailable" ? "svg-rasterizer-unavailable" : "svg-render-failed";
|
|
3459
3517
|
return unresolved(reason, reason, reason === "svg-rasterizer-unavailable"
|
|
3460
|
-
?
|
|
3518
|
+
? `An SVG image needs a rasterizer for its PNG fallback: install the optional peer @openpresentation/opf-render${typeof error.install === "string" ? ` and its PNG converter (\`${error.install}\`)` : ""}, or pass options.svgRasterizer (or an imageResolver that returns a raster).`
|
|
3461
3519
|
: `The SVG image could not be rasterized for its PNG fallback (${errorMessage(error)}).`);
|
|
3462
3520
|
}
|
|
3463
3521
|
options.svgRasters.set(cacheKey, png);
|
|
@@ -3703,7 +3761,7 @@ async function normalizePptxZip(raw, context) {
|
|
|
3703
3761
|
}
|
|
3704
3762
|
}
|
|
3705
3763
|
applyChartFonts(entries,context.chartFonts,parseRelationships);
|
|
3706
|
-
if (context.
|
|
3764
|
+
if (context.frameAlts.size) for (const [part, bytes] of Object.entries(entries)) if (/^ppt\/slides\/slide\d+\.xml$/.test(part)) { const xml = decodeText(bytes), next = applyFrameAlt(xml, context.frameAlts); if (next !== xml) entries[part] = encodeText(next); }
|
|
3707
3765
|
// Chartex charts: the native cx:chartSpace part, its style parts and the mc:AlternateContent frame (the classic chart above is the fallback).
|
|
3708
3766
|
try {
|
|
3709
3767
|
attachChartexParts(entries, context.chartex, parseRelationships);
|
package/dist/script-fonts.js
CHANGED
|
@@ -465,6 +465,13 @@ export function observeLanguage({slides, theme, themePath, slideThemes, catalogs
|
|
|
465
465
|
export function reconcileLanguage(groups, observed, report) {
|
|
466
466
|
const index = groups.findIndex(item => item.field === "language");
|
|
467
467
|
if (index < 0 || observed.lang === undefined) return groups;
|
|
468
|
+
// RR-59: a language the source left absent stays absent while the runs carry the engine default the export wrote them in;
|
|
469
|
+
// a language set in PowerPoint is imported as observed.
|
|
470
|
+
if (groups[index].ops.some(op => op.remove && op.path?.length === 1 && op.path[0] === "language")) {
|
|
471
|
+
let fallback;
|
|
472
|
+
try { fallback = resolver({}, {}, observed.catalogs).lang; } catch { fallback = undefined; }
|
|
473
|
+
return typeof fallback === "string" && fallback.toLowerCase() === observed.lang.toLowerCase() ? groups : groups.filter((_, position) => position !== index);
|
|
474
|
+
}
|
|
468
475
|
const stored = groups[index].ops.find(op => op.path?.length === 1 && op.path[0] === "language")?.value;
|
|
469
476
|
if (stored === undefined) return groups;
|
|
470
477
|
const resolved = resolver({language: stored}, {}, observed.catalogs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@node-projects/jszip": "4.3.0",
|
|
68
|
-
"@openpresentation/opf": "^0.
|
|
68
|
+
"@openpresentation/opf": "^0.16.0",
|
|
69
69
|
"@rgrove/parse-xml": "4.2.3",
|
|
70
70
|
"fast-xml-parser": "^5.8.0",
|
|
71
71
|
"fflate": "^0.8.3",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"sharp": "0.35.5"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
|
-
"@openpresentation/opf-render": "^0.
|
|
76
|
+
"@openpresentation/opf-render": "^0.16.0"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"@openpresentation/opf-render": {
|
|
@@ -81,8 +81,19 @@
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
+
"@expo-google-fonts/arimo": "0.4.3",
|
|
85
|
+
"@expo-google-fonts/caladea": "0.4.2",
|
|
84
86
|
"@expo-google-fonts/carlito": "0.4.1",
|
|
85
|
-
"@
|
|
87
|
+
"@expo-google-fonts/cousine": "0.4.3",
|
|
88
|
+
"@expo-google-fonts/gelasio": "0.4.1",
|
|
89
|
+
"@expo-google-fonts/noto-naskh-arabic": "0.4.5",
|
|
90
|
+
"@expo-google-fonts/noto-sans": "0.4.2",
|
|
91
|
+
"@expo-google-fonts/noto-sans-arabic": "0.4.3",
|
|
92
|
+
"@expo-google-fonts/roboto": "0.4.3",
|
|
93
|
+
"@expo-google-fonts/roboto-mono": "0.4.2",
|
|
94
|
+
"@expo-google-fonts/tinos": "0.4.2",
|
|
95
|
+
"@openpresentation/opf-render": "0.16.0",
|
|
96
|
+
"@resvg/resvg-js": "2.6.2",
|
|
86
97
|
"esbuild": "0.28.2",
|
|
87
98
|
"playwright": "1.63.0"
|
|
88
99
|
},
|