@openpresentation/opf-pptx 0.11.7 → 0.11.9
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 +48 -7
- package/dist/content-topology.js +104 -7
- package/dist/document-provenance.js +6 -5
- package/dist/furniture-provenance.js +67 -3
- package/dist/image-fallback-browser.js +12 -0
- package/dist/image-fallback-node.js +22 -0
- package/dist/import-signals.js +809 -0
- package/dist/index.d.ts +286 -4
- package/dist/index.js +198 -36
- package/dist/logo-provenance.js +4 -3
- package/dist/native-furniture.js +297 -0
- package/dist/slide-image-provenance.js +6 -4
- package/dist/svg-image.js +354 -0
- package/dist/watermark-provenance.js +4 -3
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
3
|
-
Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
|
|
3
|
+
Version 0.11.9 exports an SVG image as a native SVG picture over a PNG fallback (rasterized in Node by the optional `@openpresentation/opf-render` peer or `options.svgRasterizer`; without one it stays the placeholder), writes a deck footer's first text, date and slide number as native PowerPoint Header & Footer placeholders (output change for decks with a footer; every export now carries the footer placeholders on its master, layout and notes master; see [native header and footer](docs/native-header-footer.md)) and adds the opt-in `fromPptx(bytes, {signals: true})`, which also returns the raw per-shape signals of every slide (see Import signals; the option leaves the default import unchanged; core floor `^0.11.4` unchanged, no API removed). Version 0.11.8 re-imports rich text that wraps over several native lines as the one authored payload instead of reporting `content-structure-changed` (the export records the line count; decks exported by earlier versions import as before; core floor `^0.11.4` unchanged, no API change). Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
|
|
4
4
|
|
|
5
5
|
Version 0.9.1 kept core `^0.11.0` and raised the optional `@openpresentation/opf-render` peer to `^0.9.0` so it coexists with editor 0.8.0. ColorRef / `variables` on content colors still hex-resolve through core `resolveColorRef()` before PptxGenJS `srgbClr` export. Unrecognized run colors such as `color:'invalid'` still validate and fall back to the theme text color. Native DrawingML `schemeClr` and theme `clrScheme` writes, and native `p:hf` headers/footers, are not in this release. Import still flattens theme colors to hex. Metric, quote and timeline layout placeholders and the corrected text-bullet contract from 0.8.1 are retained.
|
|
6
6
|
|
|
@@ -30,9 +30,9 @@ Version 0.8.0 requires core 0.10.0 and uses renderer 0.8.0 for coordinated previ
|
|
|
30
30
|
- Repository: `OpenPresentation/opf-pptx`
|
|
31
31
|
- License: MIT
|
|
32
32
|
- Compatibility target: `@openpresentation/opf`
|
|
33
|
-
- Renderer relationship: may use `@openpresentation/opf-render` for chart rasterization and visual verification
|
|
33
|
+
- Renderer relationship: may use `@openpresentation/opf-render` for chart rasterization, the PNG fallback of SVG pictures and visual verification
|
|
34
34
|
- Public export API: `toPptx(opf, opts)`
|
|
35
|
-
- Public import API: `fromPptx(buffer, opts)`
|
|
35
|
+
- Public import API: `fromPptx(buffer, opts)` (`opts.signals` adds raw per-shape signals; see [Import signals](#import-signals))
|
|
36
36
|
|
|
37
37
|
The export path validates OPF with `@openpresentation/opf`, maps slide titles and common content payloads to editable PowerPoint objects through `pptxgenjs`, then normalizes the generated ZIP for stable entry ordering, fixed timestamps, and reproducible bytes.
|
|
38
38
|
|
|
@@ -55,7 +55,15 @@ await fs.promises.writeFile("quarterly-review.pptx", bytes);
|
|
|
55
55
|
|
|
56
56
|
`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.
|
|
57
57
|
|
|
58
|
-
Embedded picture bytes
|
|
58
|
+
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`.
|
|
59
|
+
|
|
60
|
+
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 content images, the slide image (`design.slideImage`), the watermark, header and footer images and logos, and cover and section logos. The picture takes the same frame and fit (contain, `design.imageFill: "crop"`) 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 (a background picture fill carries no SVG), and a slide image 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.
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
`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 with SVG sources (`design.logo`, `design.watermark`, `design.slideImage`) round-trip as SVG.
|
|
59
67
|
|
|
60
68
|
`fromPptx` parses an existing `.pptx` buffer locally and returns an OPF document that validates with `@openpresentation/opf`:
|
|
61
69
|
|
|
@@ -134,7 +142,38 @@ The first importer is mechanical and schema-compatible:
|
|
|
134
142
|
- 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.
|
|
135
143
|
- 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/`).
|
|
136
144
|
|
|
137
|
-
There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
|
|
145
|
+
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).
|
|
146
|
+
|
|
147
|
+
## Import signals
|
|
148
|
+
|
|
149
|
+
A deck exported by this package carries OPF provenance tags, so its structure returns exactly. A deck from anywhere else does not: a code panel is a text box with a monospace font, a metric is a large number over a small label, a quote is italic text over an attribution, and `fromPptx` imports each as plain text, because telling them apart takes judgement. That judgement is not made here. `fromPptx` only reports what is in the package, so a host can run its own classifier (a model, a rules engine, a person) over it. The runtime policy is unchanged: no network, no models, no clock, no randomness, nothing fabricated.
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
import { fromPptx } from "@openpresentation/opf-pptx";
|
|
153
|
+
|
|
154
|
+
// Default: the OPF document, exactly as before.
|
|
155
|
+
const document = await fromPptx(bytes);
|
|
156
|
+
|
|
157
|
+
// Opt in: the same document, and a signals value beside it.
|
|
158
|
+
const { document: same, signals } = await fromPptx(bytes, { signals: true });
|
|
159
|
+
// or with limits: fromPptx(bytes, { signals: { maxSlides: 100, maxShapesPerSlide: 150 } })
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Without `signals` (or with `false`) the return value and the document are byte for byte what they were; with it the document is identical and the result is `{document, signals}`. Invalid limits throw `OPFPptxError` with code `invalid-signals-option` before any work. `signals` is plain JSON (`JSON.stringify` and back loses nothing), the same bytes in always give the same signals, and the types are `PptxSignals` in `index.d.ts`.
|
|
163
|
+
|
|
164
|
+
**What it reports.** `signals.deck` has the slide size (EMU and reference px, 96 per inch), the theme (colour and font scheme names, major and minor font, the twelve colour slots) and whether the deck carries OPF tags. `signals.slides[i]` has the part, layout (`name`, `type`, part), master (part, name, theme name), whether the slide is hidden, whether it carries an OPF slide tag, and `shapes`: every shape in z-order (document order, back to front; `zOrder` 0 is the back), each with:
|
|
165
|
+
|
|
166
|
+
- `kind`: `text`, `shape` (an autoshape with no text), `placeholder` (an empty placeholder), `picture`, `media` (video or audio), `table`, `chart`, `smartart`, `ole`, `group`, `connector`, `line` or `unknown` (with `graphicType`). `placeholder` carries `type`, `idx` and `size`.
|
|
167
|
+
- `box`: `px` (reference px) and `emu`, in slide coordinates with group transforms (offset and scale) applied, so a shape inside a scaled group has its real position. A placeholder with no box of its own gets the layout's, then the master's (`box.source`). `rotation`, `flipH` and `flipV` describe the shape; its box is the frame before rotation, and `box.approximate` marks a box inside a rotated or flipped group.
|
|
168
|
+
- `parent`, `children` and `depth` give the group nesting; `id` is `s` plus the z-order index.
|
|
169
|
+
- `text`: paragraphs with `level`, `align`, the effective `bullet` (inherited from the list style when the paragraph states none), indents and spacing, and runs with `font`, `size`, `bold`, `italic`, `weight`, `underline`, `strike`, `baseline`, `caps`, `color` (`#RRGGBB`) with its theme `colorRef`, `monospace`, `link`, `field` and `lineBreak`. Values are the effective ones PowerPoint draws: the run, its paragraph, the shape's list style, the layout and master placeholder, the master text styles, the presentation defaults and the theme (font tokens such as `+mj-lt` and scheme colours are resolved). A value no part of that chain states is omitted, and defaults are omitted (`bold`, `italic`, `monospace` absent means false, `weight` absent 400, `level` absent 0, `align` absent left). `monospace` is true for a known fixed-width family (Consolas, Courier New, Menlo, JetBrains Mono, ...); `text.monospaceShare`, `dominantFont`, `dominantSize`, `maxFontSize`, `anchor` and `autofit` summarise the shape.
|
|
170
|
+
- `fill`, `outline` (kind, colour, width, dash, arrowheads) and `geometry` (`rect`, `roundRect`, `ellipse`, ...). Fills and lines that only name a theme style are reported as `kind: "style"` with the style index and colour.
|
|
171
|
+
- `picture` (part, media type, bytes, natural size, crop), `table` (rows, columns, widths, header flags, cell text), `chart` (chart types, series and point counts, title).
|
|
172
|
+
- `opf`: where the importer put the shape in the document it returned. `{role: "block", path: "slides.2.blocks.1", blockType: "text"}`, `{role: "title", path: "slides.0.title"}`, or `null` when the importer made nothing of it (a connector, a table inside a group). For an OPF export the path follows the rebuilt structure (`slides.0.blocks.0.blocks.1`, `slides.1.left`, `slides.2.text`). A shape the importer consumed without a block of its own has a `role` only (`furniture`, `logo`, `watermark`, `slide-image`, or the member of a tagged group). A group links where all its members do. Several shapes can link to one block (the lines of a list, the parts of a quote).
|
|
173
|
+
|
|
174
|
+
**Bounds.** The output is bounded by `signals` limits, reported in `signals.limits` (defaults, with the largest value each accepts): `maxSlides` 300 (5000), `maxShapesPerSlide` 300 (5000), `maxTotalShapes` 5000 (100000), `maxParagraphsPerShape` 200 (5000), `maxTextCharsPerShape` 8000 (200000), `maxTextCharsTotal` 400000 (5000000), `maxTableCells` 400 (10000), `maxTableCellChars` 200 (5000), `maxGroupDepth` 12 (32). What a bound drops is counted: `signals.truncated` (slides not reported, text budget exhausted), `slide.truncated` (shapes, depth, paragraphs, text, table) and `paragraph.truncated`. `text.chars` and `text.paragraphCount` are the true counts. Images are never embedded, only described, so the size follows the text: about 0.7 KB of JSON per shape.
|
|
175
|
+
|
|
176
|
+
**Not claims.** Signals are facts, not conclusions: a `monospace` run is not a code block, and `opf: null` is not an error. They are read from the package, not rendered: the effective values follow the OOXML inheritance rules, not PowerPoint's layout engine (text wrapping and autofit are not computed, a group's rotation is not applied to its members' boxes, and an unresolvable colour or font is left out). They describe the upload, which can be hostile: the text is the deck's text, and a host that passes it on to a model should treat it as untrusted data, never as instructions.
|
|
138
177
|
|
|
139
178
|
## Chartex charts (FF-22b)
|
|
140
179
|
|
|
@@ -371,10 +410,12 @@ Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `
|
|
|
371
410
|
|
|
372
411
|
Version 0.8.0 exports editable native lines with explicit four-space tab stops. Boundary tags contain no original source words, so native edits and deletions remain authoritative. XML-unrepresentable scalar controls reject with an actionable path rather than silently losing characters. Twenty wide/portrait measured/estimated export cases, current-text mutation controls and damaged-tag fallbacks pass; native Office acceptance remains separate. The coordinated core browser workflow also verifies editing, undo and exact reimport.
|
|
373
412
|
|
|
374
|
-
Version 0.9.1 exports core's accepted header/footer text and picture geometry as editable tagged slide shapes.
|
|
413
|
+
Version 0.9.1 exports core's accepted header/footer text and picture geometry as editable tagged slide shapes. Since RR-11 the footer's first text, date and slide number are native PowerPoint Header & Footer placeholders ([core issue87](https://github.com/OpenPresentation/opf/issues/87), [native header and footer](docs/native-header-footer.md)); releases up to 0.11.7 wrote every part as a tagged shape. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates, section/organization metadata and page-number intent when the current number matches the current slide position.
|
|
375
414
|
|
|
376
415
|
Slide numbers inside those shapes are native PowerPoint slide-number fields (`<a:fld type="slidenum">`), so PowerPoint renumbers them when slides move. A `slideNumberFormat` such as `"A-{current}"` or `"{current} / {total}"` keeps its literal text and `{total}` (the exported slide count) as fixed runs around the field; PowerPoint has no slide-count field. A current date (`date: true`) needs the host's `date` option (ISO `YYYY-MM-DD`); it becomes a native `datetime1`–`datetime7` field when its `dateFormat` matches that en-US field type, and otherwise fixed text with a `furniture-date-fixed` diagnostic. An eligible date or number range that spans accepted text lines stays static and reports `furniture-field-fixed` at its source path; no native field is created by merging shapes. A string date with `dateFormat` is fixed text. The slide manifest records `slideNumberFormat`/`dateFormat` settings, never their rendered words: reimport keeps a format only while the current native text still matches it (a slide number at its current position, a fixed date that parses back to the same ISO date), and a live date keeps the pattern of its current field type. Otherwise the current words import as literal text. Hiding furniture on a title slide is a slide-level `design.footer: false`/`design.header: false`, which round-trips as before.
|
|
377
416
|
|
|
378
417
|
When a supported current-date field wraps across lines, full provenance records a versioned static-date reason, its format and exact line fingerprints, without cached date words. Complete unchanged soft-wrapped lines can then recover `date: true` and the authored format on reimport, so a later OPF export can use a new host date. The original PPTX date remains static: this does not establish native refresh or Office compatibility. Actual current native date fields take precedence. Edited or cleared text, damaged evidence, older unmarked exports, `references-only` and `provenance: false` remain literal/current-content fallbacks. Fingerprints detect changed text; writable tags are not authentication. Native formatting and geometry are not reconstructed.
|
|
379
418
|
|
|
380
|
-
Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; published tagged-shape furniture does not establish native Header/Footer support.
|
|
419
|
+
Missing, duplicated or inconsistent furniture tags fall back to current native content with an `invalid-furniture-provenance` diagnostic. Conflicting organization or section values also fall back. Inherited definitions become global only when every slide has a valid definition/override and all inherited values agree; otherwise recovered definitions stay local. Reordering slides with unchanged visible page numbers preserves those numbers as ordinary text instead of silently renumbering them. Native typography, positioning, crop and unrelated metadata are not reconstructed; `furniture-import-reflow` requests review. `npm run test:furniture` exercises XML conversion and mutation controls. Native Office, visual corpus and clean installed-package acceptance remain separate gates; published tagged-shape furniture (0.9.1 to 0.11.7) does not establish native Header/Footer support.
|
|
420
|
+
|
|
421
|
+
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. Header parts, organization, section, 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).
|
package/dist/content-topology.js
CHANGED
|
@@ -19,8 +19,14 @@
|
|
|
19
19
|
// | {form: 'blocks', blocks: Node[]}
|
|
20
20
|
// | {form: 'regions', regions: {[key]: Node}} // key = promoted region key
|
|
21
21
|
// Node = {t: 'group', id?, ext?, comp?, typed?, blocks: Node[]}
|
|
22
|
-
// | {t: 'leaf', k, id?, ext?, typed?, box?}
|
|
22
|
+
// | {t: 'leaf', k, id?, ext?, typed?, box?, lines?} // box = [x, y, w, h] reference px, 1 decimal
|
|
23
23
|
// typed = true when the authored block spelled out its `type`
|
|
24
|
+
// lines = N (>= 2) when a rich-text `text` payload exported as N native line
|
|
25
|
+
// shapes that are soft wraps of one paragraph (no hard break between
|
|
26
|
+
// them); import joins those N imported lines back into the one payload
|
|
27
|
+
//
|
|
28
|
+
// Native line shapes of rich text carry no names or tags, so the soft wraps are
|
|
29
|
+
// recorded here as a count; a wrapped payload with any hard break is not marked.
|
|
24
30
|
//
|
|
25
31
|
// Tags are untrusted input: every field is type-, count- and depth-checked
|
|
26
32
|
// before use, and the rebuilt slide still has to validate with the document.
|
|
@@ -48,6 +54,37 @@ export const BOX_TOLERANCE = 3;
|
|
|
48
54
|
const kindOfField = field => field === 'items' || field === 'bullets' ? 'list' : field;
|
|
49
55
|
const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
|
|
50
56
|
const round1 = value => Math.round(value * 10) / 10;
|
|
57
|
+
export const MAX_WRAPPED_LINES = 1000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The number of native line shapes a rich-text `text` item exported as, when
|
|
61
|
+
* those lines are consecutive soft wraps of the authored runs: the line
|
|
62
|
+
* fragments, concatenated in order, are exactly the concatenated run text, so
|
|
63
|
+
* no line boundary stands for a hard break (a newline, a blank line, collapsed
|
|
64
|
+
* or dropped characters). Undefined for a single line or when that cannot be
|
|
65
|
+
* shown: nothing then marks the leaf and import keeps the separate blocks.
|
|
66
|
+
*/
|
|
67
|
+
export function softWrappedLines(item) {
|
|
68
|
+
const lines = item?.text?.richLines;
|
|
69
|
+
if (item?.field !== 'text' || !Array.isArray(lines) || lines.length < 2 || lines.length > MAX_WRAPPED_LINES) return undefined;
|
|
70
|
+
const runs = Array.isArray(item.value) ? item.value : typeof item.value === 'string' ? [item.value] : null;
|
|
71
|
+
if (!runs) return undefined;
|
|
72
|
+
let source = '';
|
|
73
|
+
for (const run of runs) {
|
|
74
|
+
const text = typeof run === 'string' ? run : object(run) ? run.text : undefined;
|
|
75
|
+
if (typeof text !== 'string') return undefined;
|
|
76
|
+
source += text;
|
|
77
|
+
}
|
|
78
|
+
let position = 0;
|
|
79
|
+
for (const line of lines) {
|
|
80
|
+
if (!Array.isArray(line?.fragments) || !line.fragments.length) return undefined;
|
|
81
|
+
for (const fragment of line.fragments) {
|
|
82
|
+
if (typeof fragment?.text !== 'string' || !source.startsWith(fragment.text, position)) return undefined;
|
|
83
|
+
position += fragment.text.length;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return position === source.length ? lines.length : undefined;
|
|
87
|
+
}
|
|
51
88
|
|
|
52
89
|
function blockKind(block) {
|
|
53
90
|
if (typeof block.type === 'string' && block.type !== 'group') return CONTENT_KINDS.includes(block.type) ? block.type : null;
|
|
@@ -67,8 +104,12 @@ function blockKind(block) {
|
|
|
67
104
|
*/
|
|
68
105
|
export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
69
106
|
if (!object(slide)) return undefined;
|
|
70
|
-
const boxes = new Map();
|
|
71
|
-
for (const item of items ?? []) if (typeof item?.path === 'string' && object(item.box))
|
|
107
|
+
const boxes = new Map(), wrapped = new Map();
|
|
108
|
+
for (const item of items ?? []) if (typeof item?.path === 'string' && object(item.box)) {
|
|
109
|
+
boxes.set(item.path, item.box);
|
|
110
|
+
const lines = softWrappedLines(item);
|
|
111
|
+
if (lines) wrapped.set(item.path, lines);
|
|
112
|
+
}
|
|
72
113
|
const base = `slides.${slideIndex}`;
|
|
73
114
|
let nodes = 0;
|
|
74
115
|
const fail = reason => { throw new TopologyError(reason); };
|
|
@@ -103,6 +144,7 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
103
144
|
const leaf = identity(block, {t: 'leaf', k: kind}, path);
|
|
104
145
|
const box = leafBox(path);
|
|
105
146
|
if (box) leaf.box = box;
|
|
147
|
+
if (kind === 'text' && wrapped.has(`${path}.text`)) leaf.lines = wrapped.get(`${path}.text`);
|
|
106
148
|
return leaf;
|
|
107
149
|
};
|
|
108
150
|
try {
|
|
@@ -117,7 +159,8 @@ export function contentTopology(slide, items, slideIndex, report = () => {}) {
|
|
|
117
159
|
// on one slide compose as one item each (`slides.N.<field>`).
|
|
118
160
|
const fields = ROOT_PAYLOAD_FIELDS.filter(name => slide[name] !== undefined).map(field => {
|
|
119
161
|
const box = boxes.get(`${base}.${field}`);
|
|
120
|
-
|
|
162
|
+
const lines = field === 'text' ? wrapped.get(`${base}.text`) : undefined;
|
|
163
|
+
return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1), ...(lines ? {lines} : {})} : {field};
|
|
121
164
|
});
|
|
122
165
|
if (!fields.length) return undefined;
|
|
123
166
|
if (fields.length === 1) return {form: 'root', ...fields[0]};
|
|
@@ -136,9 +179,10 @@ class TopologyError extends Error {}
|
|
|
136
179
|
|
|
137
180
|
const validBox = value => value === undefined || (Array.isArray(value) && value.length === 4 && value.every(number => typeof number === 'number' && Number.isFinite(number) && Math.abs(number) < 1e7));
|
|
138
181
|
const validId = value => value === undefined || (typeof value === 'string' && value.length <= MAX_ID_LENGTH);
|
|
139
|
-
const
|
|
182
|
+
const validLines = value => value === undefined || (Number.isSafeInteger(value) && value >= 2 && value <= MAX_WRAPPED_LINES);
|
|
183
|
+
const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box) && validLines(value.lines) && (value.lines === undefined || value.field === 'text');
|
|
140
184
|
/** The root payload leaves of a root-form record: [{field, box?}]. */
|
|
141
|
-
export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box}];
|
|
185
|
+
export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box, lines: topology.lines}];
|
|
142
186
|
|
|
143
187
|
/** Throws when `value` is not a well-formed topology record. Returns it otherwise. */
|
|
144
188
|
export function validateTopology(value) {
|
|
@@ -161,6 +205,7 @@ export function validateTopology(value) {
|
|
|
161
205
|
if (!CONTENT_KINDS.includes(item.k)) throw Error('Unknown content kind.');
|
|
162
206
|
if (item.comp !== undefined) throw Error('A content leaf has no composition.');
|
|
163
207
|
if (!validBox(item.box)) throw Error('Invalid content box.');
|
|
208
|
+
if (!validLines(item.lines) || (item.lines !== undefined && item.k !== 'text')) throw Error('Invalid wrapped line count.');
|
|
164
209
|
};
|
|
165
210
|
switch (value.form) {
|
|
166
211
|
case 'root': {
|
|
@@ -172,6 +217,7 @@ export function validateTopology(value) {
|
|
|
172
217
|
}
|
|
173
218
|
if (!ROOT_PAYLOAD_FIELDS.includes(value.field)) throw Error('Unknown root payload field.');
|
|
174
219
|
if (!validBox(value.box)) throw Error('Invalid content box.');
|
|
220
|
+
if (!validLines(value.lines) || (value.lines !== undefined && value.field !== 'text')) throw Error('Invalid wrapped line count.');
|
|
175
221
|
return value;
|
|
176
222
|
}
|
|
177
223
|
case 'blocks':
|
|
@@ -202,6 +248,25 @@ const containsOrigin = (box, bounds) => bounds.x >= box[0] - BOX_TOLERANCE && bo
|
|
|
202
248
|
const contains = (box, bounds) => containsOrigin(box, bounds)
|
|
203
249
|
&& bounds.x + bounds.width <= box[0] + box[2] + BOX_TOLERANCE && bounds.y + bounds.height <= box[1] + box[3] + BOX_TOLERANCE;
|
|
204
250
|
|
|
251
|
+
const textRuns = value => typeof value === 'string' || (Array.isArray(value) && value.length > 0 && value.every(run => object(run) && typeof run.text === 'string'));
|
|
252
|
+
const sameRunStyle = (a, b) => JSON.stringify({...a, text: ''}) === JSON.stringify({...b, text: ''});
|
|
253
|
+
// The lines' text joined in order. Runs are never invented or dropped; at a seam the
|
|
254
|
+
// last run of one line and the first of the next merge when they differ only in text
|
|
255
|
+
// (a run the wrap cut in two), the same way a rejoined list does.
|
|
256
|
+
function joinWrappedText(parts) {
|
|
257
|
+
if (parts.every(part => typeof part === 'string')) return parts.join('');
|
|
258
|
+
const joined = [];
|
|
259
|
+
for (const part of parts) {
|
|
260
|
+
const runs = typeof part === 'string' ? [{text: part}] : part;
|
|
261
|
+
runs.forEach((run, position) => {
|
|
262
|
+
const last = joined.at(-1);
|
|
263
|
+
if (position === 0 && last && sameRunStyle(last, run)) last.text += run.text;
|
|
264
|
+
else joined.push({...run});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return joined;
|
|
268
|
+
}
|
|
269
|
+
|
|
205
270
|
/**
|
|
206
271
|
* Rebuild the authored content form from the imported flat `blocks` and their
|
|
207
272
|
* native `bounds` (reference px, one entry per block, null when unknown).
|
|
@@ -210,7 +275,7 @@ const contains = (box, bounds) => containsOrigin(box, bounds)
|
|
|
210
275
|
* {reason} when a block matches no leaf or several blocks land on one leaf.
|
|
211
276
|
* Leaves with no block are dropped: empty payloads export nothing.
|
|
212
277
|
*/
|
|
213
|
-
export function rebuildContent(topology, blocks, bounds) {
|
|
278
|
+
export function rebuildContent(topology, blocks, bounds, placed) {
|
|
214
279
|
if (!Array.isArray(blocks) || !Array.isArray(bounds) || blocks.length !== bounds.length) return {reason: 'the imported blocks carry no native bounds'};
|
|
215
280
|
const leaves = [];
|
|
216
281
|
const collect = item => {
|
|
@@ -235,6 +300,8 @@ export function rebuildContent(topology, blocks, bounds) {
|
|
|
235
300
|
}
|
|
236
301
|
for (const leaf of leaves) {
|
|
237
302
|
if (leaf.matches.length <= 1) continue;
|
|
303
|
+
// Every flat block that rejoins here lands at this leaf (reported to `placed`), not only the first.
|
|
304
|
+
leaf.rejoined = leaf.matches;
|
|
238
305
|
// A list's native lines can arrive as several bullet blocks when another
|
|
239
306
|
// object interleaves with them in reading order; they rejoin their list.
|
|
240
307
|
if (leaf.kind === 'list' && leaf.matches.every(index => blocks[index]?.type === 'list' && Array.isArray(blocks[index].items))) {
|
|
@@ -243,6 +310,18 @@ export function rebuildContent(topology, blocks, bounds) {
|
|
|
243
310
|
leaf.payload = merged;
|
|
244
311
|
continue;
|
|
245
312
|
}
|
|
313
|
+
// Rich text that wrapped exports one native line shape per soft wrap and
|
|
314
|
+
// imports as one text block per line. The record counted those lines at
|
|
315
|
+
// export (see softWrappedLines) and the slide's shapes are unchanged since
|
|
316
|
+
// (the structure hash was checked before this rebuild), so exactly that many
|
|
317
|
+
// text blocks in the leaf are its consecutive lines: they rejoin in reading
|
|
318
|
+
// order, with no separator, because a soft wrap deletes no characters.
|
|
319
|
+
if (leaf.kind === 'text' && leaf.node.lines === leaf.matches.length && leaf.matches.every(index => blocks[index]?.type === 'text' && textRuns(blocks[index].text))) {
|
|
320
|
+
const ordered = leaf.matches.map((index, position) => ({index, y: bounds[index].y, position})).sort((a, b) => a.y - b.y || a.position - b.position).map(entry => entry.index);
|
|
321
|
+
leaf.payload = {...blocks[ordered[0]], text: joinWrappedText(ordered.map(index => blocks[index].text))};
|
|
322
|
+
leaf.matches = [ordered[0]];
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
246
325
|
return {reason: `${leaf.matches.length} blocks lie in one stored content box`};
|
|
247
326
|
}
|
|
248
327
|
const ids = [];
|
|
@@ -269,6 +348,21 @@ export function rebuildContent(topology, blocks, bounds) {
|
|
|
269
348
|
group.blocks = children;
|
|
270
349
|
return group;
|
|
271
350
|
};
|
|
351
|
+
// Where each imported flat block lands under the slide (reported to `placed`, for import signals): the path of its leaf in the rebuilt form.
|
|
352
|
+
const paths = new Map();
|
|
353
|
+
const built = item => item.t !== 'group' ? Boolean(leaves.find(entry => entry.node === item)?.matches.length) : item.blocks.some(built);
|
|
354
|
+
const place = (item, prefix) => {
|
|
355
|
+
if (item.t !== 'group') {
|
|
356
|
+
const leaf = leaves.find(entry => entry.node === item);
|
|
357
|
+
if (leaf) for (const index of leaf.rejoined ?? leaf.matches) paths.set(index, prefix);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
let position = 0;
|
|
361
|
+
for (const child of item.blocks) if (built(child)) place(child, `${prefix}.blocks.${position++}`);
|
|
362
|
+
};
|
|
363
|
+
if (topology.form === 'root') { for (const leaf of leaves) for (const index of leaf.rejoined ?? leaf.matches) paths.set(index, leaf.field); }
|
|
364
|
+
else if (topology.form === 'blocks') { let position = 0; for (const item of topology.blocks) if (built(item)) place(item, `blocks.${position++}`); }
|
|
365
|
+
else for (const [key, item] of Object.entries(topology.regions)) if (built(item)) place(item, key);
|
|
272
366
|
const fields = {blocks: null};
|
|
273
367
|
if (topology.form === 'root') {
|
|
274
368
|
for (const leaf of leaves) {
|
|
@@ -280,16 +374,19 @@ export function rebuildContent(topology, blocks, bounds) {
|
|
|
280
374
|
if (leaf.field === 'bullets' && rest.items !== undefined && rest.bullets === undefined) { rest.bullets = rest.items; delete rest.items; }
|
|
281
375
|
Object.assign(fields, rest);
|
|
282
376
|
}
|
|
377
|
+
placed?.(paths);
|
|
283
378
|
return {fields, ids};
|
|
284
379
|
}
|
|
285
380
|
if (topology.form === 'blocks') {
|
|
286
381
|
const rebuilt = topology.blocks.map(build).filter(Boolean);
|
|
287
382
|
if (rebuilt.length) fields.blocks = rebuilt;
|
|
383
|
+
placed?.(paths);
|
|
288
384
|
return {fields, ids};
|
|
289
385
|
}
|
|
290
386
|
for (const [key, item] of Object.entries(topology.regions)) {
|
|
291
387
|
const host = build(item);
|
|
292
388
|
if (host !== undefined) fields[key] = host;
|
|
293
389
|
}
|
|
390
|
+
placed?.(paths);
|
|
294
391
|
return {fields, ids};
|
|
295
392
|
}
|
|
@@ -831,7 +831,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
831
831
|
}
|
|
832
832
|
});
|
|
833
833
|
const groups = [];
|
|
834
|
-
const group = (field, ops) => groups.push({field, ops});
|
|
834
|
+
const group = (field, ops, contentPaths) => groups.push({field, ops, ...(contentPaths ? {contentPaths} : {})});
|
|
835
835
|
const set = (path, value) => ({path, value});
|
|
836
836
|
const remove = path => ({path, remove: true});
|
|
837
837
|
const intent = layoutIntent(document ? layoutRecords(document.catalogs) : [], Boolean(document), slideRecords, entries, group, report, rejectedLayoutIds);
|
|
@@ -852,7 +852,8 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
852
852
|
if (record.type !== undefined) report({code: 'slide-reference-changed', path: `slides.${index}.type`, message: `The slide's objects were moved, resized, added or removed since export, so slides.${index}.type ${JSON.stringify(record.type)} was not restored; the imported slide keeps its observed arrangement.`});
|
|
853
853
|
return false;
|
|
854
854
|
}
|
|
855
|
-
|
|
855
|
+
let placedPaths;
|
|
856
|
+
const result = rebuildContent(record.content, imported.slides[index]?.blocks ?? [], slides[index]?.contentBounds ?? [], paths => { placedPaths = paths; });
|
|
856
857
|
if (result.reason) {
|
|
857
858
|
report({code: 'content-structure-changed', path: `slides.${index}`, message: `The slide's content no longer matches its stored structure (${result.reason}), so the authored groups, regions and block ids of slides.${index} were not restored; the imported slide keeps its flat blocks.${typeNote}`});
|
|
858
859
|
return false;
|
|
@@ -873,7 +874,7 @@ export function restoreDocumentProvenance(imported, {entries, presentationRoot,
|
|
|
873
874
|
ops.push(set(['slides', index, key], value));
|
|
874
875
|
}
|
|
875
876
|
if (record.type !== undefined) ops.push(set(['slides', index, 'type'], clone(record.type)));
|
|
876
|
-
group(`slides.${index}.content`, ops);
|
|
877
|
+
group(`slides.${index}.content`, ops, {slide: index, paths: placedPaths});
|
|
877
878
|
return true;
|
|
878
879
|
};
|
|
879
880
|
|
|
@@ -1199,10 +1200,10 @@ export function applyDocumentProvenance(imported, {groups, finalize}, validate,
|
|
|
1199
1200
|
return tidy(doc);
|
|
1200
1201
|
};
|
|
1201
1202
|
const all = finalize(build(groups));
|
|
1202
|
-
if (validate(all).valid) return all;
|
|
1203
|
+
if (validate(all).valid) { for (const item of groups) item.applied = true; return all; }
|
|
1203
1204
|
const accepted = [];
|
|
1204
1205
|
for (const item of groups) {
|
|
1205
|
-
if (validate(build([...accepted, item])).valid) accepted.push(item);
|
|
1206
|
+
if (validate(build([...accepted, item])).valid) { accepted.push(item); item.applied = true; }
|
|
1206
1207
|
else report({code: 'invalid-document-provenance', path: item.field, message: `The stored ${item.field} does not form a valid document with the imported content, so it was not restored; the imported document keeps the values observed in the PPTX.`});
|
|
1207
1208
|
}
|
|
1208
1209
|
const result = finalize(build(accepted));
|
|
@@ -4,6 +4,7 @@ import {sourceLineParagraphs} from './text-provenance.js';
|
|
|
4
4
|
import {DEFAULT_DATE_FORMAT, NATIVE_DATE_FIELDS, formatSlideNumber, parseDate} from './furniture-fields.js';
|
|
5
5
|
import {schemas} from '@openpresentation/opf';
|
|
6
6
|
import {LOGO_TAG} from './logo-provenance.js';
|
|
7
|
+
import {NATIVE_PLACEHOLDERS, footerValue, isNativePlaceholderType, readNativePlaceholders} from './native-furniture.js';
|
|
7
8
|
|
|
8
9
|
const TAG = 'OPF_FURNITURE_V1';
|
|
9
10
|
// A slide has one tag list; it also carries the document's OPF_SLIDE_V1 record.
|
|
@@ -26,7 +27,9 @@ const socialLines = part => part.links.map(link => ({platform: link.platform,
|
|
|
26
27
|
scheme: link.href && !/^[a-z][a-z0-9+.-]*:/i.test(link.text) ? 'https://' : ''}));
|
|
27
28
|
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
28
29
|
const key = part => `${part.kind}.${part.zone}.${part.field}`;
|
|
29
|
-
|
|
30
|
+
// Key order is not meaning: a value merged from native placeholders equals the same value read from a manifest.
|
|
31
|
+
const canonical = value => JSON.stringify(value, (key, item) => object(item) ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : item);
|
|
32
|
+
const same = (a, b) => canonical(a) === canonical(b);
|
|
30
33
|
const check = (condition, message) => { if (!condition) throw Error(message); };
|
|
31
34
|
|
|
32
35
|
// Change detection, not authentication: editable tags store no cached date words.
|
|
@@ -73,7 +76,7 @@ function unchangedStaticDate(marker, format, ordered, paragraphs) {
|
|
|
73
76
|
// settings only. Current native shapes supply all words, image bytes and alt
|
|
74
77
|
// text, even after edits; a recorded format is kept only while the current text
|
|
75
78
|
// still matches it exactly.
|
|
76
|
-
export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map()) {
|
|
79
|
+
export function furnitureManifest(presentation, slide, layout, slideIndex, staticDates = new Map(), natives = new Map()) {
|
|
77
80
|
const definitions = {}, formats = {};
|
|
78
81
|
for (const kind of kinds) {
|
|
79
82
|
const local = slide.design?.[kind] !== undefined;
|
|
@@ -109,7 +112,9 @@ export function furnitureManifest(presentation, slide, layout, slideIndex, stati
|
|
|
109
112
|
parts: layout.parts.flatMap((part, index) => part.field === 'logo' ? [] : [{kind: part.kind, zone: part.zone, field: part.field,
|
|
110
113
|
type: part.type, count: part.type === 'image' ? 1 : part.fit.lines.length,
|
|
111
114
|
...(part.field === 'socials' ? {socials: socialLines(part)} : {}),
|
|
112
|
-
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {})
|
|
115
|
+
...(staticDates.has(index) ? {staticDate: staticDates.get(index)} : {}),
|
|
116
|
+
// RR-11: this part is a native PowerPoint placeholder (dt, ftr or sldNum); deleting it in PowerPoint's dialog is then intent.
|
|
117
|
+
...(natives.has(index) ? {ph: natives.get(index)} : {})}]),
|
|
113
118
|
...(logos.length ? {logos} : {})};
|
|
114
119
|
}
|
|
115
120
|
|
|
@@ -197,6 +202,9 @@ function validateManifest(manifest) {
|
|
|
197
202
|
for (const part of manifest.parts) {
|
|
198
203
|
check(object(part) && expected.get(key(part)) === part.type, 'Ambiguous furniture part.');
|
|
199
204
|
check(Number.isSafeInteger(part.count) && part.count >= 1 && part.count <= 10000 && (part.type !== 'image' || part.count === 1), 'Invalid furniture line count.');
|
|
205
|
+
// RR-11: a footer part that is a native PowerPoint placeholder (dt, ftr, sldNum), unique per slide.
|
|
206
|
+
if (part.ph !== undefined) check(isNativePlaceholderType(part.ph) && part.kind === 'footer' && part.type === 'text' && part.count === 1 &&
|
|
207
|
+
NATIVE_PLACEHOLDERS[part.ph].field === part.field && manifest.parts.filter(other => other.ph === part.ph).length === 1, 'Invalid native placeholder part.');
|
|
200
208
|
if (part.field === 'socials') check(Array.isArray(part.socials) && part.socials.length >= 1 && part.socials.length <= 100
|
|
201
209
|
&& part.socials.every(line => object(line) && platformId.test(line.platform) && schemes.includes(line.scheme))
|
|
202
210
|
&& new Set(part.socials.map(line => line.platform)).size === part.socials.length, 'Invalid social profile lines.');
|
|
@@ -257,6 +265,7 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
257
265
|
if (!definition) continue;
|
|
258
266
|
try {
|
|
259
267
|
const value = definition.value === false ? false : {};
|
|
268
|
+
let removedNative = false;
|
|
260
269
|
if (value !== false) for (const zone of zones) if (definition.value[zone] !== undefined) {
|
|
261
270
|
value[zone] = Object.fromEntries(Object.entries(definition.value[zone]).filter(([, flag]) => flag === false));
|
|
262
271
|
}
|
|
@@ -271,6 +280,8 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
271
280
|
for (const [partIndex, part] of manifest.parts.entries()) {
|
|
272
281
|
if (part.kind !== kind) continue;
|
|
273
282
|
const group = records.filter(record => record.data.part === partIndex);
|
|
283
|
+
// A native placeholder PowerPoint's Header & Footer dialog removed is a deliberate removal, not damage.
|
|
284
|
+
if (part.ph !== undefined && group.length === 0) { removedNative = true; continue; }
|
|
274
285
|
check(group.length === part.count, 'Incomplete or duplicated furniture part.');
|
|
275
286
|
if (part.type === 'image') {
|
|
276
287
|
const current = readPicture(pictures[group[0].index]);
|
|
@@ -304,6 +315,15 @@ function readSlide(context, entries, slideIndex, slideCount, report, taggedText)
|
|
|
304
315
|
}
|
|
305
316
|
}
|
|
306
317
|
check(new Set(candidate.text).size === candidate.text.length && new Set(candidate.pictures).size === candidate.pictures.length, 'Repeated furniture shape identity.');
|
|
318
|
+
if (removedNative) {
|
|
319
|
+
// The slide no longer follows the deck's definition: it is its own override, and the other slides can still agree.
|
|
320
|
+
candidate.scope = 'local';
|
|
321
|
+
candidate.derived = true;
|
|
322
|
+
if (value !== false) {
|
|
323
|
+
for (const zone of Object.keys(value)) if (!Object.keys(value[zone]).length) delete value[zone];
|
|
324
|
+
if (!Object.keys(value).length) candidate.value = false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
307
327
|
candidates[kind] = candidate;
|
|
308
328
|
} catch (error) { report(slideIndex, `${kind}: ${error.message}`); }
|
|
309
329
|
}
|
|
@@ -363,6 +383,28 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
363
383
|
delete slide[kind]; report(index, `${kind}: ${owner ? 'Current social profile metadata disagrees across repeated fields.' : 'Social profiles need a repeated organization name to rebuild organization metadata.'}`);
|
|
364
384
|
}
|
|
365
385
|
}
|
|
386
|
+
// RR-11: PowerPoint's own date, footer and slide-number placeholders that no OPF tag claims (a deck authored in
|
|
387
|
+
// PowerPoint, a placeholder the Header & Footer dialog added, a tag-stripped export) are footer furniture.
|
|
388
|
+
contexts.forEach((context, index) => {
|
|
389
|
+
const slide = candidates[index];
|
|
390
|
+
const claimed = new Set([...taggedText[index], ...Object.values(slide).flatMap(candidate => candidate.text)]);
|
|
391
|
+
const fields = readNativePlaceholders(context, claimed, context.relationshipsOf, context.readPart);
|
|
392
|
+
if (!fields.length) return;
|
|
393
|
+
const existing = slide.footer, accepted = [];
|
|
394
|
+
for (const item of fields) {
|
|
395
|
+
const current = existing && existing.value !== false ? existing.value[item.zone]?.[item.field] : undefined;
|
|
396
|
+
if (current !== undefined && current !== false) report(index, `footer.${item.zone}.${item.field}: A native PowerPoint placeholder duplicates a furniture part already read; it stays ordinary current text.`);
|
|
397
|
+
else accepted.push(item);
|
|
398
|
+
}
|
|
399
|
+
if (!accepted.length) return;
|
|
400
|
+
const merged = footerValue(accepted), indexes = accepted.map(item => item.index);
|
|
401
|
+
if (!existing) slide.footer = {scope: 'native', value: merged, text: indexes, pictures: [], organizations: [], socials: [], sections: []};
|
|
402
|
+
else {
|
|
403
|
+
if (existing.value === false) existing.value = {};
|
|
404
|
+
for (const zone of Object.keys(merged)) existing.value[zone] = {...existing.value[zone], ...merged[zone]};
|
|
405
|
+
existing.text.push(...indexes);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
366
408
|
const result = {design: {}, slides: candidates.map((slide, index) => {
|
|
367
409
|
const values = Object.values(slide), sections = values.flatMap(candidate => candidate.sections);
|
|
368
410
|
return {design: Object.fromEntries(Object.entries(slide).map(([kind, candidate]) => [kind, candidate.value])),
|
|
@@ -380,6 +422,28 @@ export function importFurniture(contexts, entries, onDiagnostic) {
|
|
|
380
422
|
if (inherited.length && candidates.every(slide => slide[kind]) && inherited.every(candidate => same(candidate.value, inherited[0].value))) {
|
|
381
423
|
result.design[kind] = inherited[0].value;
|
|
382
424
|
candidates.forEach((slide, index) => { if (slide[kind].scope === 'global') delete result.slides[index].design[kind]; });
|
|
425
|
+
} else if (!inherited.length && candidates.every(slide => slide[kind]?.derived && same(slide[kind].value, candidates[0][kind].value))) {
|
|
426
|
+
// Every slide's inherited definition lost the same native placeholder (the dialog's Apply to All): the deck's footer changed.
|
|
427
|
+
result.design[kind] = candidates[0][kind].value;
|
|
428
|
+
candidates.forEach((slide, index) => delete result.slides[index].design[kind]);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// A deck with no OPF footer provenance (PowerPoint's Header & Footer dialog, another tool): the footer most slides share is the
|
|
432
|
+
// deck's footer, a slide without one hides it, and any other slide keeps its own. One slide alone stays that slide's footer.
|
|
433
|
+
const nativeFooters = candidates.map(slide => slide.footer);
|
|
434
|
+
if (nativeFooters.some(Boolean) && nativeFooters.every(candidate => !candidate || candidate.scope === 'native')) {
|
|
435
|
+
const counts = new Map();
|
|
436
|
+
for (const candidate of nativeFooters) if (candidate && candidate.value !== false) {
|
|
437
|
+
const found = counts.get(canonical(candidate.value)) ?? {value: candidate.value, count: 0};
|
|
438
|
+
counts.set(canonical(candidate.value), {...found, count: found.count + 1});
|
|
439
|
+
}
|
|
440
|
+
const best = [...counts.entries()].sort((a, b) => b[1].count - a[1].count)[0];
|
|
441
|
+
if (best && (best[1].count >= 2 || contexts.length === 1)) {
|
|
442
|
+
result.design.footer = best[1].value;
|
|
443
|
+
nativeFooters.forEach((candidate, index) => {
|
|
444
|
+
if (!candidate) result.slides[index].design.footer = false;
|
|
445
|
+
else if (canonical(candidate.value) === best[0]) delete result.slides[index].design.footer;
|
|
446
|
+
});
|
|
383
447
|
}
|
|
384
448
|
}
|
|
385
449
|
candidates.forEach((slide, index) => { if (Object.keys(slide).length) onDiagnostic?.({code: 'furniture-import-reflow', path: `slides.${index}.design`, message: 'Complete furniture roles retain current text, source line boundaries, image content and unambiguous metadata. Native formatting, positioning, crop and other presentation metadata are not reconstructed; review the reflowed furniture.'}); });
|
|
@@ -31,3 +31,15 @@ export async function webpToPng(bytes) {
|
|
|
31
31
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
|
+
|
|
35
|
+
// A browser has no deterministic SVG rasterizer here: pass options.svgRasterizer (for example one that draws the SVG
|
|
36
|
+
// on a canvas) to export SVG pictures. Without it the SVG picture exports as the "Image unavailable" placeholder.
|
|
37
|
+
export async function svgToPng() {
|
|
38
|
+
const unavailable = new Error('Browsers need options.svgRasterizer to export SVG pictures.');
|
|
39
|
+
unavailable.code = 'svg-rasterizer-unavailable';
|
|
40
|
+
throw unavailable;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function readLocalFile() {
|
|
44
|
+
throw new Error('A browser cannot read a local file: pass the SVG as a data URI or through imageResolver.');
|
|
45
|
+
}
|
|
@@ -6,3 +6,25 @@ export async function webpToPng(bytes) {
|
|
|
6
6
|
.autoOrient().toColourspace('srgb').ensureAlpha()
|
|
7
7
|
.png({compressionLevel: 9, adaptiveFiltering: false}).toBuffer());
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
// The default rasterizer for the PNG fallback of an SVG picture: opf-render's resvg renderer (an optional peer), which
|
|
11
|
+
// is deterministic (no system fonts, no network or file access for the SVG's own references). `text` selects the
|
|
12
|
+
// bundled fonts; an SVG with no text needs none. Rejects with code `svg-rasterizer-unavailable` when opf-render is
|
|
13
|
+
// not installed.
|
|
14
|
+
export async function svgToPng(svg, {scale, text}) {
|
|
15
|
+
let render;
|
|
16
|
+
try {
|
|
17
|
+
({svgToPng: render} = await import('@openpresentation/opf-render'));
|
|
18
|
+
} catch (error) {
|
|
19
|
+
const unavailable = new Error('SVG pictures need the optional peer @openpresentation/opf-render (or an options.svgRasterizer).', {cause: error});
|
|
20
|
+
unavailable.code = 'svg-rasterizer-unavailable';
|
|
21
|
+
throw unavailable;
|
|
22
|
+
}
|
|
23
|
+
return new Uint8Array(await render(svg, {scale, background: 'rgba(0, 0, 0, 0)', useBundledFonts: text === true, loadSystemFonts: false}));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A local SVG file (a path source), read as bytes; PptxGenJS reads raster paths itself.
|
|
27
|
+
export async function readLocalFile(path) {
|
|
28
|
+
const { readFile } = await import('node:fs/promises');
|
|
29
|
+
return new Uint8Array(await readFile(path));
|
|
30
|
+
}
|