@openpresentation/opf-pptx 0.17.0 → 0.18.1
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 +35 -10
- package/dist/chart-order.js +76 -0
- package/dist/content-topology.js +25 -0
- package/dist/data-provenance.js +71 -4
- package/dist/document-provenance.js +34 -16
- package/dist/furniture-provenance.js +179 -69
- package/dist/image-fallback-node.js +2 -2
- package/dist/image-provenance.js +54 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +204 -56
- package/dist/logo-provenance.js +8 -8
- package/dist/master-furniture.js +326 -0
- package/dist/media-dedupe.js +2 -0
- package/dist/native-furniture.js +6 -2
- package/dist/run-colors.js +28 -2
- package/package.json +9 -4
package/README.md
CHANGED
|
@@ -63,17 +63,17 @@ 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)`. 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.
|
|
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. The "Image unavailable" placeholder (a dashed panel `OPF image placeholder N` with its label lines, or a cross when no label fits) is one native group `OPF image placeholder N group` (opf-pptx#221) that carries the accessible name (`Image unavailable: <alt>`) as its alt text, so PowerPoint moves or deletes it as one object while each member stays an editable shape; a logo and a header or footer image keep their ungrouped panel (generated furniture is never grouped). In `full` provenance the panel of an image block carries an `OPF_IMAGE_PLACEHOLDER_V1` tag (the slide, the block path, the authored image value and the block's own fields), and `fromPptx` turns it back into that image block; without the tag (`references-only`, `provenance: false`) or with a damaged one (`invalid-image-provenance`) the shapes import as ordinary content.
|
|
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 `
|
|
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 `toPng` (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
|
|
|
76
|
-
`fromPptx` reads a picture that carries `asvg:svgBlip` back as the SVG (a `data:image/svg+xml;base64,` source), not its PNG fallback, from this exporter's files and from PowerPoint's (a damaged or hostile SVG part imports its PNG fallback and reports `invalid-svg-image`, or imports sanitized with `svg-sanitized`). Design fields and blocks with SVG sources (`
|
|
76
|
+
`fromPptx` reads a picture that carries `asvg:svgBlip` back as the SVG (a `data:image/svg+xml;base64,` source), not its PNG fallback, from this exporter's files and from PowerPoint's (a damaged or hostile SVG part imports its PNG fallback and reports `invalid-svg-image`, or imports sanitized with `svg-sanitized`). Design fields, organization logos and blocks with SVG sources (`organization.logo`, `design.watermark`, image blocks with their treatments) round-trip as SVG.
|
|
77
77
|
|
|
78
78
|
`fromPptx` parses an existing `.pptx` buffer locally and returns an OPF document that validates with `@openpresentation/opf`:
|
|
79
79
|
|
|
@@ -100,7 +100,7 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
|
|
|
100
100
|
|
|
101
101
|
- `Slide.title`, `Slide.subtitle`, and `Slide.tag` become editable text boxes, not PowerPoint master placeholders.
|
|
102
102
|
- 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`).
|
|
103
|
-
- Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping. The chartex types treemap, histogram, pareto, box & whisker, waterfall and funnel are written as native Office 2016 chartex parts (confirmed in desktop PowerPoint on 2026-09-30; see [Chartex charts](#chartex-charts-ff-22b)); the map (`world`) stays a clustered column chart by default and reports `chart-data-adapted` (`chartex-fallback`) until PowerPoint accepts its regionMap part (`chartex: "native"` writes it anyway, `chartex: "fallback"` writes clustered columns for every chartex id). A pie or doughnut chart, or a one-series chartex construct, plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more.
|
|
103
|
+
- Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping. The chartex types treemap, histogram, pareto, box & whisker, waterfall and funnel are written as native Office 2016 chartex parts (confirmed in desktop PowerPoint on 2026-09-30; see [Chartex charts](#chartex-charts-ff-22b)); the map (`world`) stays a clustered column chart by default and reports `chart-data-adapted` (`chartex-fallback`) until PowerPoint accepts its regionMap part (`chartex: "native"` writes it anyway, `chartex: "fallback"` writes clustered columns for every chartex id). A pie or doughnut chart, or a one-series chartex construct, plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more. Every classic chart part follows the `dml-chart.xsd` child sequences (opf-pptx#175): each series is written in its `CT_*Ser` order (a line series has `c:marker` before `c:dLbls`), a radar series carries no `c:invertIfNegative`, and the scatter X `c:valAx` carries no category-axis elements.
|
|
104
104
|
- A timeline event's `status` (`done`, `current`, `planned`) is drawn from the deck's colors with core's shared shapes (the same as the preview): a filled `ellipse` marker for `done` and for no status, a `current` marker inside a ring (a second `ellipse`, tagged with its marker) with a bold label, and a `planned` marker that is an `ellipse` with the background fill and an accent outline, with muted text kept at 4.5:1 or more. Import restores `status` from the marker tags, not from the shapes' look, and drops the timeline provenance (keeping plain text) when a current event's ring is missing or duplicated.
|
|
105
105
|
- A chart whose data is one column of values (a histogram or dot plot) has no category column. A histogram or Pareto chart bins the values itself (PowerPoint's histogram with an explicit automatic bin count, no diagnostic) and re-imports as authored; any other chart type plots the values against their row numbers and reports `chart-data-adapted` (`row-numbers`). With `chartex: "fallback"` a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts (`histogram-binned`; the binned counts do not round-trip). Cells parse as in every chart (core `chartNumber`: numbers and plain decimal strings; "12%", "$5" and "1,234" are not numbers) and cells that hold no number are skipped, not plotted as 0. Chart data that cannot be plotted (no numbers, no rows, or an external data source), an empty table and unsupported content keep a placeholder frame with a plain-language description (never a dump of the source data or URLs) and report `chart-data-unplottable` or `content-placeholder` with a `reason`; content is never dropped without a diagnostic.
|
|
106
106
|
- Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path. A picture's native description is the authored `alt` (never a stand-in such as `preencoded.png` or the file path), and a distinct asset `title` is the picture's native `title`; `fromPptx` reads both back.
|
|
@@ -143,7 +143,7 @@ The first importer is mechanical and schema-compatible:
|
|
|
143
143
|
- The first slide master's theme `clrScheme` maps to `design.colorScheme`: the bare id of a colour scheme in the host default catalog (the first of `fromPptx`'s `catalogs`) on an exact twelve-slot match, otherwise inline slots. A theme named like a theme of that catalog maps to `design.theme` when its colors or heading/body fonts corroborate it. With no catalog registered the colour scheme is inline and no theme id is recovered (see [Theme color scheme](#theme-color-scheme)).
|
|
144
144
|
- Rich headline text (FA-10): a `TextRun[]` `title`, `subtitle` or `tag` exports as one tagged native text box per laid-out line with its runs (color, weight, italic, links, super/subscript, citation markers), and a `TextRun[]` `quote.text` the same way under the quote tags. Import returns `TextRun[]` where a heading's native runs are formatted differently from one another and a plain string where they are uniform: a tagged export records the heading's own weight, color, size and family (`base`), so only authored run formatting comes back, and a native title placeholder is read like body text, dropping what every run shares. A weight that the export writes as bold on every run of a title (and on a quote body) is the default, so only a `bold: false` word in a title is kept; `bold: true` in a quote body is indistinguishable from the body weight and is not restored. Marker runs become `cite`/`footnote` again, and heading and quote runs take part in the run-color reference slots.
|
|
145
145
|
- 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.
|
|
146
|
-
- Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position.
|
|
146
|
+
- Remaining text boxes map to `blocks[]` as text or list payloads, sorted by OOXML position. A measured list writes its size, family, weight and colour on every native run; in `full` provenance the slide record (`OPF_SLIDE_V1.listRuns`, opf-pptx#212) keeps that look per list (entry and description lines, never text), and import removes a run value still equal to it, so an untouched list round trip returns its items as authored instead of baking those values into `TextRun`s. A value edited in PowerPoint differs and comes back as observed; a run colour reference that resolves to the list colour (the `text` role) still returns through the run colour record, but an authored literal colour, size or family equal to the list's own cannot be told from it and returns absent. Without the record (`references-only`, `provenance: false`, a plain PPTX) runs are read as observed.
|
|
147
147
|
- PowerPoint tables map to OPF table blocks, embedded images map to data URI image blocks, and cached chart series map to OPF chart blocks whose `type` is the core chart type id for the native construct (for example a `percentStacked` column chart imports as `100pct-stacked-column`, a filled radar as `filled-radar`; a scatter chart imports as `[Point, X, ...series]`).
|
|
148
148
|
- 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.
|
|
149
149
|
- 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.
|
|
@@ -154,7 +154,7 @@ The first importer is mechanical and schema-compatible:
|
|
|
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
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
|
-
- 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.
|
|
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: a logo reference or `false`), the organizations with their logos 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 (a footer's `{{slide.section}}` is only the token, so no footer text names the section), except that the stored label stands while the list still names it. The native PowerPoint check of the sections pane and save/reopen is a separate gate (`scratchpad/spec-gaps-native/`).
|
|
159
159
|
|
|
160
160
|
There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns. A host that wants to rebuild structure (code, metrics, quotes, timelines, regions) in a deck that did not come from OPF can ask for the raw shape facts such a step needs; see [Import signals](#import-signals).
|
|
@@ -212,6 +212,29 @@ The seven kept chart types with no ECMA-376 construct are written as Office 2016
|
|
|
212
212
|
|
|
213
213
|
The chart area, label colour, label font (`latin`/`ea`/`cs` in the chart's body font) and series colours follow the classic charts. Re-import (`fromPptx`) reads the chartex part first: the layoutIds name the OPF id (an owned `paretoLine` is `pareto`), `cx:strDim type="cat"` restores the categories and each `cx:numDim` a series, under the same 100,000-point and 1,000,000-cell bounds as classic chart caches; a lone value column comes back as authored. A chartex part with no OPF construct (sunburst) falls back to the `mc:Fallback` chart, or to a text placeholder when PowerPoint's own text fallback is all there is. Native PowerPoint rendering of each construct is confirmed through the program's bounded native sample, not by this package's tests.
|
|
214
214
|
|
|
215
|
+
## Pass `fonts`, otherwise the estimate
|
|
216
|
+
|
|
217
|
+
Text measurement comes from one place: the `fonts` handle that opf-render's `loadFonts()` returns (`@openpresentation/opf-render/fonts-node`, or `/fonts-browser`). Pass the same handle to preview, validation, pagination and `toPptx`, so that the exported text boxes break lines where the preview does:
|
|
218
|
+
|
|
219
|
+
```js
|
|
220
|
+
import { loadFonts } from '@openpresentation/opf-render/fonts-node';
|
|
221
|
+
import { toSvg } from '@openpresentation/opf-render';
|
|
222
|
+
import { validate } from '@openpresentation/opf';
|
|
223
|
+
import { paginate } from '@openpresentation/opf/pagination';
|
|
224
|
+
import { toPptx } from '@openpresentation/opf-pptx';
|
|
225
|
+
|
|
226
|
+
const fonts = await loadFonts({ pack: 'office' });
|
|
227
|
+
const report = validate(deck, { fonts });
|
|
228
|
+
const { presentation } = paginate(deck, { fonts });
|
|
229
|
+
const svgs = toSvg(presentation, { fonts }); // preview
|
|
230
|
+
const pptx = await toPptx(presentation, { fonts }); // export: the same lines, as editable text
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
One handle carries the measurement for core's `validate`, `paginate` and slide context, for opf-render's `toSvg`, `toPng` and `toPdf`, and for `toPptx`. The same handle gives identical text geometry across engines: every engine reads core's one composition, so it is the measurement that decides the lines. This package's `test/layout-parity.mjs` checks that the exported PowerPoint paragraphs agree with the SVG preview and with core's composed items, with and without `fonts`.
|
|
234
|
+
|
|
235
|
+
Without `fonts`, `toPptx` uses core's built-in estimate: 0.54 em per character, 0.62 em for capitals and digits, 0.32 em for a space, 1 em for CJK characters and zero for combining marks. That is deterministic and needs no font files and no renderer, but it is too narrow for some scripts (opf#566 tracks improving it), and the line breaks it chooses can differ from the faces PowerPoint draws. The PPTX still names the font family the document chose (`fonts` changes where lines break, not which font is named). Core's Node `convert` and the `opf` CLI prepare an office-pack handle for you; a library call to `toPptx` never loads fonts on its own, so pass the handle you want. See also [opf#364](https://github.com/OpenPresentation/opf/issues/364) and core's [measured fonts](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#pass-fonts-otherwise-the-estimate).
|
|
236
|
+
|
|
237
|
+
|
|
215
238
|
## Languages, right-to-left text and script fonts
|
|
216
239
|
|
|
217
240
|
The exporter reads the presentation `language` through core `resolveScriptFonts()` (FF-07; the model is core `docs/programs/font-fidelity-everywhere/script-font-model.md`):
|
|
@@ -335,10 +358,10 @@ A deck or slide watermark (`design.watermark`) exports as one native picture nam
|
|
|
335
358
|
|
|
336
359
|
### Brand assets, picture bullets and the accent font (spec-gap closure A)
|
|
337
360
|
|
|
338
|
-
These design fields need the core that composes them (`resolveLogo`, `geometry.logo`, `item.bulletImage`, `fontScheme.accent
|
|
361
|
+
These design fields need the core that composes them (`resolveLogo`, `geometry.logo`, `item.bulletImage`, `fontScheme.accent`, the organization logo shapes; core 0.18 or the coordinated source). On core 0.11.3 the export carries none of them and is byte-identical to the previous output. Each one is drawn at the box core composes, which the preview draws too (see core `docs/design-resolution.md`, "Brand assets and layout hints").
|
|
339
362
|
|
|
340
|
-
- **Cover and section logo** (`
|
|
341
|
-
- **Header and footer `
|
|
363
|
+
- **Cover and section logo** (the primary organization's `logo`, in its `full` shape; `design.logo` on the deck or a slide names another organization or shape, `var:organization.beta.logo`, `var:organization.logo.wordmark`, or is `false` for none). One native picture named `OPF logo`, after the watermark and before the content, fitted without cropping into core's `geometry.logo` box (56 px high at 720 px, up to four times as wide), anchored at the box's left edge and vertically centered, exactly the preview's `xMinYMid meet`. Its alt text is the asset's `alt`, otherwise `Logo`. A shape given as `{onLight, onDark}` picks its tone from the slide background (a dark background takes `onDark`); the exporter's dark test is the one that already chooses the text colour. An unresolved or unreadable logo draws the preview's "Image unavailable" panel in the box and reports `unresolved-asset` at the organization's logo path (`organization.logo.full.onDark`; `strictAssets` throws). Content slides never get one. The picture carries an `OPF_LOGO_V1` tag (slide, source path, tone, the exact picture properties): an unchanged export consumes the picture on import (it is not content; the organization, with every shape and tone, and `design.logo` return from the document and slide records), and an edited, ambiguous or damaged one stays an ordinary picture and reports `invalid-logo-provenance`. When nothing else restored the organization's logo (an export with `provenance: false`, or with `references-only`, which stores no sources), the consumed picture's own image becomes the logo of the primary organization (a new organization named after the deck when there is none), so the logo is never lost; it is the one drawn image for every shape. A PPTX with no OPF tags imports the picture as an ordinary image block and invents no organization.
|
|
364
|
+
- **Header and footer logos** (`image: "var:organization.logo.icon"`, or `var:organization.logo`, `.stacked`, `.wordmark`, or a partner's `var:organization.beta.logo`). Core lays the parts of a zone out in one row (image, text, socials, date, each at its natural width, vertically centered, `FURNITURE_GAP` apart) and resolves the reference for each slide's background, so an `{onLight, onDark}` shape draws a different asset on a dark slide. The exporter draws every part at `part.box`; a logo is an ordinary furniture image part (the `OPF_FURNITURE_V1` picture tag) whose slide manifest also records the authored reference and the content key of the bytes it embedded (`images`: `{reference, media}` per `kind.zone`, beside `parts` and `definitions`, which every released importer validates strictly). It re-imports as the reference while the picture still embeds those bytes and the stored organization still has a logo for it; a replaced picture (Change Picture), a foreign picture, `provenance: false` and `references-only` (no stored organization) import it as an ordinary `image`. A reference whose organization has no logo reports `unresolved-content` at `<zone>.image`, draws nothing and still returns as the reference (`drawn: false`). An asset that cannot be drawn reports `unresolved-asset` at the organization's logo path.
|
|
342
365
|
- **Picture bullets** (`design.listBullet: image`, resolved to the icon logo by core as `item.bulletImage`). Every entry's first line carries `<a:buSzPct val="100000"/><a:buBlip><a:blip r:embed=…/></a:buBlip>` in place of the character bullet, so the marker is the text size, like the preview's square marker. PptxGenJS embeds the icon once per slide (a picture named `OPF bullet image`); packaging removes that picture and keeps the media part and its relationship for the `a:buBlip` elements. Marker indent and text positions are those of the character bullets. An icon that cannot be embedded keeps the character bullets and reports `unresolved-asset` once per slide at the logo's path. Import recognizes `a:buBlip` paragraphs as list paragraphs; `design.listBullet` returns from the stored design hint (a PPTX without tags has no hint). PowerPoint shows a picture bullet at the picture's own aspect ratio; use a square icon for the preview's square marker. The released 0.11.6 importer does not know `a:buBlip` and imports such a list as plain text lines.
|
|
343
366
|
- **`fontScheme.accent`.** Core resolves the accent role for the slide tag (eyebrow) and the quote body, so their runs carry the accent typeface in `a:latin`, `a:ea` and `a:cs` with the catalog's pitch family when the family is known; theme major/minor fonts, every other run and the theme are unchanged. The accent family appears in `docProps/app.xml` "Fonts Used" like any other used font; `checkTypefaces` callers list it in `options.fonts`. `design.fontScheme` (with `accent`) returns from the document tag.
|
|
344
367
|
|
|
@@ -461,4 +484,6 @@ When a supported current-date field wraps across lines, full provenance records
|
|
|
461
484
|
|
|
462
485
|
Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Social profiles (`socials: true`) belong to the organization the manifest names by id, which only the stored document record can supply, so a file without it (`provenance: false`, another tool) imports the profile lines as ordinary text with an `invalid-furniture-provenance` diagnostic. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. A moved slide keeps its live slide-number field (it imports as `{{slide.number}}` and renumbers in PowerPoint). 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 (0.9.1 to 0.11.7) does not establish native Header/Footer support.
|
|
463
486
|
|
|
464
|
-
Native header and footer placeholders (RR-11): the first footer text becomes a `ftr` placeholder, the first footer date a `dt` placeholder (a live `datetime1`-`datetime7` field for `date: true`) and the first footer slide number an `sldNum` placeholder, each at core's exact box with its furniture tag; the slide master and layout carry the three placeholders and `p:hf` flags, and the notes master records that notes pages carry a page number only. A zone `text` with a `{{slide.number}}` is the `sldNum` placeholder (its other words are fixed runs); any other zone `text` is the `ftr` placeholder. Header parts, socials, images, second text/date/number parts and multi-line text stay tagged shapes (PowerPoint has no object for them). `fromPptx` reads the placeholders back with or without provenance, and a deck changed through PowerPoint's Insert > Header & Footer dialog imports the change. Every deck, with or without a footer, carries the three master and layout placeholders (flags off when unused), so Insert > Header & Footer works on a deck with no footer. The mapping table, the dialog-edit table and the vetoable decisions are in [docs/native-header-footer.md](docs/native-header-footer.md).
|
|
487
|
+
Native header and footer placeholders (RR-11): the first footer text becomes a `ftr` placeholder, the first footer date a `dt` placeholder (a live `datetime1`-`datetime7` field for `date: true`) and the first footer slide number an `sldNum` placeholder, each at core's exact box with its furniture tag; the slide master and layout carry the three placeholders and `p:hf` flags, and the notes master records that notes pages carry a page number only. A zone `text` with a `{{slide.number}}` is the `sldNum` placeholder (its other words are fixed runs); any other zone `text` is the `ftr` placeholder. Header parts, socials, images and logos, second text/date/number parts and multi-line text stay tagged shapes (PowerPoint has no object for them). A footer text that shares its zone with a logo is only as wide as its words (core's row layout), so its slide placeholder has that box while the master and layout placeholder spans the zone. `fromPptx` reads the placeholders back with or without provenance, and a deck changed through PowerPoint's Insert > Header & Footer dialog imports the change. Every deck, with or without a footer, carries the three master and layout placeholders (flags off when unused), so Insert > Header & Footer works on a deck with no footer. The mapping table, the dialog-edit table and the vetoable decisions are in [docs/native-header-footer.md](docs/native-header-footer.md).
|
|
488
|
+
|
|
489
|
+
Master furniture (RR-72): a part with no placeholder (a logo or other zone image, socials, header text, a second footer text) that is drawn the same on two or more slides is written once. It goes on the slide master when every slide that shows such furniture draws it, else on a layout shared by the slides that do (one layout per distinct set, so a logo's `onLight` and `onDark` assets get a layout per background tone); a slide that hides it (`design.footer: false`) uses a layout with `showMasterSp="0"`. A part on one slide only, a part with a live field and a part that slide content covers stay on the slide (the last with `furniture-on-slide`, because PowerPoint draws master shapes beneath slide content). The lifted shapes keep their furniture tags (with the slot in place of the slide part), and each slide manifest lists them under `shared`, so `fromPptx` reads them from the layout or master: a master logo returns as its `var:organization.logo.*` reference on every slide. Importers before 0.18 ignore `shared`; shapes on a master or layout without OPF tags (a third-party template) are not imported. See [Master furniture](docs/native-header-footer.md#master-furniture-rr-72).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// opf-pptx#175: the classic chart series in ECMA-376 schema order (Part 1, dml-chart.xsd).
|
|
2
|
+
//
|
|
3
|
+
// pptxgenjs-plus 4.3.4 writes a line series' c:dLbls before its c:marker, and gives radar series the bar-only
|
|
4
|
+
// c:invertIfNegative; the scatter X axis (a c:valAx) carries the category-axis-only c:auto, c:lblAlgn and c:noMultiLvlLbl.
|
|
5
|
+
// PowerPoint opens these parts, but stricter consumers (the Open XML SDK validator, LibreOffice, Keynote) may not. This runs
|
|
6
|
+
// last on each generated chart part, after every other chart rewrite: a series whose children are already in sequence keeps
|
|
7
|
+
// its bytes, and only a series that needs it is rebuilt (stable, so repeated c:dPt keep their order).
|
|
8
|
+
|
|
9
|
+
const SHARED = ['idx', 'order', 'tx', 'spPr'];
|
|
10
|
+
const BAR = [...SHARED, 'invertIfNegative', 'pictureOptions', 'dPt', 'dLbls', 'trendline', 'errBars', 'cat', 'val', 'shape', 'extLst'];
|
|
11
|
+
const LINE = [...SHARED, 'marker', 'dPt', 'dLbls', 'trendline', 'errBars', 'cat', 'val', 'smooth', 'extLst'];
|
|
12
|
+
const AREA = [...SHARED, 'pictureOptions', 'dPt', 'dLbls', 'trendline', 'errBars', 'cat', 'val', 'extLst'];
|
|
13
|
+
const RADAR = [...SHARED, 'marker', 'dPt', 'dLbls', 'cat', 'val', 'extLst'];
|
|
14
|
+
const SCATTER = [...SHARED, 'marker', 'dPt', 'dLbls', 'trendline', 'errBars', 'xVal', 'yVal', 'smooth', 'extLst'];
|
|
15
|
+
const PIE = [...SHARED, 'explosion', 'dPt', 'dLbls', 'cat', 'val', 'extLst'];
|
|
16
|
+
|
|
17
|
+
/** The CT_*Ser child sequence of each classic chart group. */
|
|
18
|
+
export const SERIES_SEQUENCES = Object.freeze({
|
|
19
|
+
barChart: BAR, bar3DChart: BAR,
|
|
20
|
+
lineChart: LINE, line3DChart: LINE,
|
|
21
|
+
areaChart: AREA, area3DChart: AREA,
|
|
22
|
+
radarChart: RADAR,
|
|
23
|
+
scatterChart: SCATTER,
|
|
24
|
+
pieChart: PIE, pie3DChart: PIE, doughnutChart: PIE, ofPieChart: PIE,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// The top-level children of an element body, each with the whitespace before it: [{name, text}], or null when the body is
|
|
28
|
+
// not a plain run of elements (text content, comments or an unbalanced tag), which is then left alone.
|
|
29
|
+
function children(body) {
|
|
30
|
+
const out = [];
|
|
31
|
+
const tag = /<(\/?)([A-Za-z_][\w.:-]*)\b[^>]*?(\/?)>/g;
|
|
32
|
+
let depth = 0, start = 0, name = null, match;
|
|
33
|
+
while ((match = tag.exec(body))) {
|
|
34
|
+
const [, close, tagName, selfClose] = match;
|
|
35
|
+
if (depth === 0) {
|
|
36
|
+
if (close || body.slice(start, match.index).trim()) return null;
|
|
37
|
+
name = tagName;
|
|
38
|
+
}
|
|
39
|
+
if (close) depth--;
|
|
40
|
+
else if (!selfClose) depth++;
|
|
41
|
+
if (depth < 0) return null;
|
|
42
|
+
if (depth === 0) {
|
|
43
|
+
out.push({name, text: body.slice(start, tag.lastIndex)});
|
|
44
|
+
start = tag.lastIndex;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (depth !== 0 || body.slice(start).trim()) return null;
|
|
48
|
+
return {items: out, tail: body.slice(start)};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function orderSeries(ser, sequence) {
|
|
52
|
+
const body = ser.slice('<c:ser>'.length, -'</c:ser>'.length);
|
|
53
|
+
const parsed = children(body);
|
|
54
|
+
if (!parsed || parsed.items.some(item => !item.name.startsWith('c:'))) return ser;
|
|
55
|
+
const rank = item => sequence.indexOf(item.name.slice(2));
|
|
56
|
+
// A child the series type does not define (c:invertIfNegative in a radar series) is dropped; the rest sort stably by rank.
|
|
57
|
+
const kept = parsed.items.filter(item => rank(item) >= 0);
|
|
58
|
+
const ordered = kept.map((item, position) => ({item, position, rank: rank(item)}))
|
|
59
|
+
.sort((a, b) => a.rank - b.rank || a.position - b.position)
|
|
60
|
+
.map(entry => entry.item);
|
|
61
|
+
if (kept.length === parsed.items.length && ordered.every((item, index) => item === parsed.items[index])) return ser;
|
|
62
|
+
return `<c:ser>${ordered.map(item => item.text).join('')}${parsed.tail}</c:ser>`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// CT_ValAx has no c:auto, c:lblAlgn or c:noMultiLvlLbl (they belong to CT_CatAx); the generator writes them on the scatter X axis.
|
|
66
|
+
const CATEGORY_AXIS_ONLY = /\s*<c:(?:auto|lblAlgn|noMultiLvlLbl)\b[^>]*\/>/g;
|
|
67
|
+
|
|
68
|
+
/** Every classic chart series of a chart part in its CT_*Ser sequence, and every c:valAx without category-axis children. */
|
|
69
|
+
export function orderChartSeries(xml) {
|
|
70
|
+
let out = xml;
|
|
71
|
+
for (const [group, sequence] of Object.entries(SERIES_SEQUENCES)) {
|
|
72
|
+
if (!out.includes(`<c:${group}>`)) continue;
|
|
73
|
+
out = out.replace(new RegExp(`<c:${group}>[\\s\\S]*?</c:${group}>`, 'g'), block => block.replace(/<c:ser>[\s\S]*?<\/c:ser>/g, ser => orderSeries(ser, sequence)));
|
|
74
|
+
}
|
|
75
|
+
return out.replace(/<c:valAx>[\s\S]*?<\/c:valAx>/g, axis => axis.replace(CATEGORY_AXIS_ONLY, ''));
|
|
76
|
+
}
|
package/dist/content-topology.js
CHANGED
|
@@ -373,6 +373,31 @@ export function validateListBreaks(value) {
|
|
|
373
373
|
return value;
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
// opf-pptx#212: `listRuns` = [[list path, {text: Look, description?: Look}]], the look the exporter writes on every run of a
|
|
377
|
+
// list's entry lines (`text`) and description lines (`description`) where the source states none: Look = {bold, color,
|
|
378
|
+
// fontSize, fontFamily} (color '#RRGGBB', fontSize in points). Import removes a run value still equal to it, so inherited
|
|
379
|
+
// formatting returns absent; a value edited in PowerPoint differs and is imported as observed. Never text.
|
|
380
|
+
const LOOK_KEYS = Object.freeze({
|
|
381
|
+
bold: value => typeof value === 'boolean',
|
|
382
|
+
color: value => typeof value === 'string' && /^#[0-9A-Fa-f]{6}$/.test(value),
|
|
383
|
+
fontSize: value => Number.isFinite(value) && value > 0 && value < 10000,
|
|
384
|
+
fontFamily: value => typeof value === 'string' && value.length > 0 && value.length <= 200,
|
|
385
|
+
});
|
|
386
|
+
const validLook = value => object(value) && Object.keys(value).length > 0 && Object.entries(value).every(([key, item]) => LOOK_KEYS[key]?.(item) === true);
|
|
387
|
+
|
|
388
|
+
/** Throws when `value` is not a well-formed `listRuns` record (untrusted input). Returns it otherwise. */
|
|
389
|
+
export function validateListRuns(value) {
|
|
390
|
+
if (!Array.isArray(value) || !value.length || value.length > MAX_LIST_PATHS) throw Error('Invalid list runs record.');
|
|
391
|
+
const paths = new Set();
|
|
392
|
+
for (const entry of value) {
|
|
393
|
+
if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || !LIST_PATH.test(entry[0]) || paths.has(entry[0]) || !object(entry[1])
|
|
394
|
+
|| Object.keys(entry[1]).some(key => key !== 'text' && key !== 'description') || !validLook(entry[1].text)
|
|
395
|
+
|| (entry[1].description !== undefined && !validLook(entry[1].description))) throw Error('Invalid list runs entry.');
|
|
396
|
+
paths.add(entry[0]);
|
|
397
|
+
}
|
|
398
|
+
return value;
|
|
399
|
+
}
|
|
400
|
+
|
|
376
401
|
// ---------------------------------------------------------------------------
|
|
377
402
|
// Import: validation
|
|
378
403
|
|
package/dist/data-provenance.js
CHANGED
|
@@ -116,7 +116,7 @@ export function chartEvidence(xml) {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/** Native cell text, formatting and table geometry, canonicalized independently of XML attribute order. */
|
|
119
|
-
export function tableCellEvidence(table, relationships = new Map()) {
|
|
119
|
+
export function tableCellEvidence(table, relationships = new Map(), {form = 'current'} = {}) {
|
|
120
120
|
const related = value => {
|
|
121
121
|
const ids = new Set(), stack = [value];
|
|
122
122
|
while (stack.length) {
|
|
@@ -132,8 +132,72 @@ export function tableCellEvidence(table, relationships = new Map()) {
|
|
|
132
132
|
return [id, link ? {type: link.type, target: link.target, targetMode: link.targetMode} : null];
|
|
133
133
|
}))} : {};
|
|
134
134
|
};
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
// 'raw' hashes the XML as written (records before opf-pptx#241); 'saved' rebuilds the form this exporter wrote from a
|
|
136
|
+
// PowerPoint-saved copy of it, so such a record still matches a cell the save left alone.
|
|
137
|
+
const read = form === 'raw' ? value => value : form === 'saved' ? exporterForm : nativeDefaults;
|
|
138
|
+
const properties = {properties: table?.['a:tblPr'], grid: read(table?.['a:tblGrid'])};
|
|
139
|
+
return array(table?.['a:tr']).map(row => array(row?.['a:tc']).map(cell => hash(canonical({properties, row: read(Object.fromEntries(Object.entries(row).filter(([key]) => key !== 'a:tc'))), cell: read(cell), ...related({properties, cell})}))));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// opf-pptx#241: a PowerPoint save drops attributes that hold their schema default and adds identity extensions, so the same
|
|
143
|
+
// table would hash differently. The evidence reads the native table with those defaults applied: an absent value and its
|
|
144
|
+
// default are one value (on a:tcPr: anchor t, margins 0.1 / 0.05 inch, anchorCtr 0, horzOverflow clip, vert horz; on a:pPr:
|
|
145
|
+
// marL 0, indent 0, algn l), whitespace between child elements, and the a16:colId / a16:rowId extensions PowerPoint adds to a:gridCol and a:tr are not content.
|
|
146
|
+
// Attribute order never mattered (canonical sorts keys). Records written before this fix hash the raw XML (legacy), and the
|
|
147
|
+
// importer still accepts that form for a file nobody changed.
|
|
148
|
+
// What this exporter wrote on every cell before the evidence read defaults: anchor on a:tcPr, and the whitespace PptxGenJS leaves
|
|
149
|
+
// after a:tcPr's children and after a:tcPr. A PowerPoint save removes all three; putting them back reproduces the recorded hash.
|
|
150
|
+
function exporterForm(value) {
|
|
151
|
+
const cleaned = nativeDefaults(value, false);
|
|
152
|
+
const rebuild = item => {
|
|
153
|
+
if (Array.isArray(item)) return item.map(rebuild);
|
|
154
|
+
if (!object(item)) return item;
|
|
155
|
+
const out = {};
|
|
156
|
+
for (const [key, child] of Object.entries(item)) out[key] = rebuild(child);
|
|
157
|
+
if (Object.hasOwn(out, 'a:txBody') && Object.hasOwn(out, 'a:tcPr') && !Object.hasOwn(out, '#text')) {
|
|
158
|
+
const properties = object(out['a:tcPr']) ? {...out['a:tcPr']} : {};
|
|
159
|
+
if (!Object.hasOwn(properties, 'anchor')) properties.anchor = 't';
|
|
160
|
+
if (!Object.hasOwn(properties, '#text') && Object.keys(properties).some(name => name.startsWith('a:'))) properties['#text'] = ' ';
|
|
161
|
+
out['a:tcPr'] = properties;
|
|
162
|
+
out['#text'] = ' ';
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
};
|
|
166
|
+
return rebuild(cleaned);
|
|
167
|
+
}
|
|
168
|
+
const PROPERTY_DEFAULTS = {
|
|
169
|
+
'a:tcPr': {marL: '91440', marR: '91440', marT: '45720', marB: '45720', anchor: 't', anchorCtr: '0', horzOverflow: 'clip', vert: 'horz'},
|
|
170
|
+
'a:pPr': {marL: '0', indent: '0', algn: 'l'}
|
|
171
|
+
};
|
|
172
|
+
const IDENTITY_EXTENSIONS = new Set(['a16:colId', 'a16:rowId']);
|
|
173
|
+
const attributeValue = (name, value) => {
|
|
174
|
+
const text = String(value).trim();
|
|
175
|
+
return name === 'anchorCtr' ? ({false: '0', off: '0', true: '1', on: '1'}[text.toLowerCase()] ?? text) : text;
|
|
176
|
+
};
|
|
177
|
+
function nativeDefaults(value, normalize = true) {
|
|
178
|
+
if (Array.isArray(value)) return value.map(item => nativeDefaults(item, normalize));
|
|
179
|
+
if (!object(value)) return value;
|
|
180
|
+
const out = {};
|
|
181
|
+
// Whitespace between child elements is not content (the exporter leaves some inside a:tcPr and a:tc; a save removes it).
|
|
182
|
+
const hasChildren = Object.keys(value).some(name => /^[a-z][w-]*:/i.test(name) && !/^xml(ns)?:/i.test(name));
|
|
183
|
+
for (const [key, item] of Object.entries(value)) {
|
|
184
|
+
if (key === '#text' && normalize && hasChildren && typeof item === 'string' && !item.trim()) continue;
|
|
185
|
+
if (key === 'a:extLst') {
|
|
186
|
+
const kept = array(item?.['a:ext']).filter(ext => !(object(ext) && Object.keys(ext).filter(name => name !== 'uri').every(name => IDENTITY_EXTENSIONS.has(name))));
|
|
187
|
+
if (kept.length) out[key] = {...item, 'a:ext': nativeDefaults(kept.length === 1 ? kept[0] : kept, normalize)};
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
out[key] = nativeDefaults(item, normalize);
|
|
191
|
+
}
|
|
192
|
+
if (!normalize) return out;
|
|
193
|
+
for (const [tag, defaults] of Object.entries(PROPERTY_DEFAULTS)) {
|
|
194
|
+
if (!Object.hasOwn(out, tag)) continue;
|
|
195
|
+
const properties = object(out[tag]) ? {...out[tag]} : {};
|
|
196
|
+
for (const [name, fallback] of Object.entries(defaults)) if (Object.hasOwn(properties, name) && attributeValue(name, properties[name]) === fallback) delete properties[name];
|
|
197
|
+
// No attributes and no children is the same as an empty element (the parser reads `<a:tcPr/>` as '').
|
|
198
|
+
out[tag] = Object.keys(properties).length ? properties : '';
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
137
201
|
}
|
|
138
202
|
|
|
139
203
|
/** Theme and table-style edits can change effective cell formatting without changing the cell XML. */
|
|
@@ -372,8 +436,11 @@ export function restoreChartData(chart, record, evidence, {datasets, report}) {
|
|
|
372
436
|
}
|
|
373
437
|
|
|
374
438
|
/** The authored table data where the native evidence is unchanged; edited native cells otherwise. */
|
|
375
|
-
export function restoreTableData(table, record, {datasets, report, evidence, environment}) {
|
|
439
|
+
export function restoreTableData(table, record, {datasets, report, evidence: currentEvidence, olderEvidence = [], environment}) {
|
|
376
440
|
if (!object(table)) return table;
|
|
441
|
+
// opf-pptx#241: a record written before the evidence read schema defaults hashes the raw XML (or, for a PowerPoint-saved copy, the
|
|
442
|
+
// form rebuilt from it); a cell still matching one of those is unchanged.
|
|
443
|
+
const evidence = currentEvidence?.map((row, r) => row.map((hash, c) => olderEvidence.map(older => older?.[r]?.[c]).find(older => older !== undefined && older === record.evidence?.[r]?.[c]) ?? hash));
|
|
377
444
|
const columns = Array.isArray(table.columns) ? table.columns : undefined;
|
|
378
445
|
const rows = Array.isArray(table.rows) ? table.rows : [];
|
|
379
446
|
const sameCell = (row, column) => record.environment === environment && record.evidence?.[row]?.[column] === evidence?.[row]?.[column];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {XMLParser} from 'fast-xml-parser';
|
|
2
2
|
import {decodeTextTag, encodeTextTag} from './code-provenance.js';
|
|
3
|
-
import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks} from './content-topology.js';
|
|
3
|
+
import {contentTopology, rebuildContent, validateTopology, listLineBreaks, validateListBreaks, validateListRuns, MAX_LIST_PATHS} from './content-topology.js';
|
|
4
4
|
import {runColorRecord, validateRunColors} from './run-colors.js';
|
|
5
5
|
import {validateCatalogRecord} from '@openpresentation/opf';
|
|
6
6
|
import {parseReference, resolveReference, resolveSocialProfile} from '@openpresentation/opf/composition';
|
|
@@ -108,10 +108,12 @@ export function splitAuthors(creator) {
|
|
|
108
108
|
export const DEFAULT_AUTHOR = 'OpenPresentation';
|
|
109
109
|
// Stored only when the native creator cannot carry the authored form by itself (or would read as the default).
|
|
110
110
|
const authorNeedsRecord = author => Array.isArray(author) || (typeof author === 'string' && (author === DEFAULT_AUTHOR || Array.isArray(splitAuthors(author))));
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// consumes the drawn logo
|
|
111
|
+
// `design.logo` (RR-71: a logo reference such as `var:organization.beta.logo`, or `false`) has no native gate: it returns
|
|
112
|
+
// from the stored value. The logo image itself lives on the organization (metadata `organization`); the cover picture
|
|
113
|
+
// (`OPF_LOGO_V1`) only consumes the drawn logo.
|
|
114
114
|
export const BRAND_ASSETS = Object.freeze(['logo']);
|
|
115
|
+
// A logo reference names an organization's logo, which 'references-only' mode does not store: the reference stays out with it.
|
|
116
|
+
const storesOnlyWithOrganization = (key, value) => key === 'logo' && typeof value === 'string';
|
|
115
117
|
const STYLE_REFERENCES = ['theme', 'colorScheme', 'fontScheme'];
|
|
116
118
|
const SLIDE_STRUCTURE = ['layout', 'type', 'composition'];
|
|
117
119
|
// Slide fields with no native counterpart, restored from the stored value.
|
|
@@ -471,7 +473,7 @@ export function documentProvenance(presentation, {mode = 'full', layoutOf = () =
|
|
|
471
473
|
const design = {}, metadata = {};
|
|
472
474
|
for (const key of DESIGN_FIELDS) {
|
|
473
475
|
if (!own(presentation.design, key)) continue;
|
|
474
|
-
if (referencesOnly && carriesSource(presentation.design[key])) continue;
|
|
476
|
+
if (referencesOnly && (carriesSource(presentation.design[key]) || storesOnlyWithOrganization(key, presentation.design[key]))) continue;
|
|
475
477
|
design[key] = clone(presentation.design[key]);
|
|
476
478
|
}
|
|
477
479
|
for (const key of METADATA) {
|
|
@@ -499,7 +501,7 @@ export function documentProvenance(presentation, {mode = 'full', layoutOf = () =
|
|
|
499
501
|
}
|
|
500
502
|
const slideDesign = {};
|
|
501
503
|
for (const key of SLIDE_DESIGN_FIELDS) {
|
|
502
|
-
if (!own(slide.design, key) || (referencesOnly && carriesSource(slide.design[key]))) continue;
|
|
504
|
+
if (!own(slide.design, key) || (referencesOnly && (carriesSource(slide.design[key]) || storesOnlyWithOrganization(key, slide.design[key])))) continue;
|
|
503
505
|
slideDesign[key] = clone(slide.design[key]);
|
|
504
506
|
}
|
|
505
507
|
if (Object.keys(slideDesign).length) record.design = slideDesign;
|
|
@@ -546,6 +548,17 @@ export function recordContentTopology(provenance, slide, index, items) {
|
|
|
546
548
|
if (omittedLists.length) provenance.slides[index].omittedLists = omittedLists;
|
|
547
549
|
}
|
|
548
550
|
|
|
551
|
+
/**
|
|
552
|
+
* opf-pptx#212: record the look the exporter writes on every run of list `path` on slide `index` where the source states none
|
|
553
|
+
* ({text, description?}, see validateListRuns), `full` mode only. Import removes a run value still equal to it.
|
|
554
|
+
*/
|
|
555
|
+
export function recordListRuns(provenance, index, path, look) {
|
|
556
|
+
if (!look || !provenance || provenance.mode !== 'full' || !provenance.slides[index]) return;
|
|
557
|
+
const record = provenance.slides[index];
|
|
558
|
+
record.listRuns ??= [];
|
|
559
|
+
if (record.listRuns.length < MAX_LIST_PATHS && !record.listRuns.some(([list]) => list === path)) record.listRuns.push([path, look]);
|
|
560
|
+
}
|
|
561
|
+
|
|
549
562
|
function base64ToBytes(value) {
|
|
550
563
|
const binary = atob(value.replace(/\s+/g, ''));
|
|
551
564
|
const bytes = new Uint8Array(binary.length);
|
|
@@ -696,7 +709,7 @@ function storable(entries, provenance) {
|
|
|
696
709
|
const result = {v: 1, slide: index};
|
|
697
710
|
if (record.omittedContent) omit(`slides.${index}.content`, record.omittedContent);
|
|
698
711
|
for (const [path, reason] of record.omittedLists ?? []) omit(path, reason);
|
|
699
|
-
for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists']) {
|
|
712
|
+
for (const key of ['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns']) {
|
|
700
713
|
if (record[key] === undefined) continue;
|
|
701
714
|
const value = prepare(`slides.${index}.${key}`, record[key]);
|
|
702
715
|
if (value !== undefined) result[key] = value;
|
|
@@ -733,7 +746,7 @@ function storable(entries, provenance) {
|
|
|
733
746
|
}
|
|
734
747
|
for (const [index, record] of slides.entries()) {
|
|
735
748
|
while (tagChars(record) > budget) {
|
|
736
|
-
const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
|
|
749
|
+
const candidates = [...['id', 'beat', ...SLIDE_STRUCTURE, ...SLIDE_METADATA, 'content', 'colors', 'lists', 'listRuns', 'layoutRecord'].filter(key => record[key] !== undefined).map(key => ({key, size: sizeOf(record[key])})),
|
|
737
750
|
...Object.keys(record.design ?? {}).map(key => ({key, design: true, size: sizeOf(record.design[key])}))];
|
|
738
751
|
if (!candidates.length) break;
|
|
739
752
|
const largest = candidates.sort((a, b) => b.size - a.size)[0];
|
|
@@ -872,6 +885,7 @@ function validateSlide(stored) {
|
|
|
872
885
|
if (value.content !== undefined) validateTopology(value.content);
|
|
873
886
|
if (value.colors !== undefined) validateRunColors(value.colors);
|
|
874
887
|
if (value.lists !== undefined) validateListBreaks(value.lists);
|
|
888
|
+
if (value.listRuns !== undefined) validateListRuns(value.listRuns);
|
|
875
889
|
validateOmitted(value.omitted);
|
|
876
890
|
return value;
|
|
877
891
|
}
|
|
@@ -912,19 +926,23 @@ function pruneDangling(value, dangling, path, removed) {
|
|
|
912
926
|
}
|
|
913
927
|
|
|
914
928
|
/**
|
|
915
|
-
* The
|
|
916
|
-
*
|
|
917
|
-
*
|
|
929
|
+
* The list records of one slide, read before its shapes are merged into list blocks:
|
|
930
|
+
* - `breaks`: the hard line breaks, Map(list path -> Map(line number -> gap)), or null when the slide carries none, its record is
|
|
931
|
+
* unreadable, or its arrangement changed since export (the lines are then no longer the exported ones);
|
|
932
|
+
* - `runs` (opf-pptx#212): the look export wrote on every run of each list, Map(list path -> {text, description?}), or null. It
|
|
933
|
+
* does not depend on the arrangement: import compares each run value with it, so an edited value is still observed.
|
|
934
|
+
* Any damage is reported later by restoreDocumentProvenance.
|
|
918
935
|
*/
|
|
919
|
-
export function
|
|
936
|
+
export function slideListRecord(entries, path, root, rels) {
|
|
920
937
|
try {
|
|
921
938
|
const found = readTag(entries, root?.['p:cSld']?.['p:custDataLst'], rels, SLIDE_TAG);
|
|
922
|
-
if (found.missing) return null;
|
|
939
|
+
if (found.missing) return {breaks: null, runs: null};
|
|
923
940
|
const record = validateSlide(found.value);
|
|
924
|
-
|
|
925
|
-
|
|
941
|
+
const runs = record.listRuns === undefined ? null : new Map(record.listRuns);
|
|
942
|
+
const breaks = record.lists === undefined || record.native.structure !== nativeSlide(entries, path).structure ? null : new Map(record.lists.map(([list, lines]) => [list, new Map(lines)]));
|
|
943
|
+
return {breaks, runs};
|
|
926
944
|
} catch {
|
|
927
|
-
return null;
|
|
945
|
+
return {breaks: null, runs: null};
|
|
928
946
|
}
|
|
929
947
|
}
|
|
930
948
|
|