@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 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 must be a PNG, JPEG, GIF or WebP. An SVG (or bytes that are no readable image) has no raster for PowerPoint, so, like an unresolved asset, it exports what the preview shows: the "Image unavailable" placeholder (nothing for a watermark, the background colour for a background image) and one `unresolved-asset` diagnostic with `reason: "unsupported-format"`; `strictAssets` throws `unsupported-image-dimensions`. To embed the artwork, return a raster from `imageResolver`; the exporter has no rasterizer of its own, and opf-render's `svgToPng(svg)` is the usual one: have `imageResolver` return `{data: await svgToPng(svgText), mediaType: "image/png"}` for an SVG source (the SVG text is yours to read from the data URI or file).
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. Native Office Header/Footer objects (`p:hf` / notes master) remain deferred [core issue87](https://github.com/OpenPresentation/opf/issues/87) work. Dedicated `OPF_FURNITURE_V1` tags describe roles, line boundaries, inactive flags and global/local scope. A slide manifest represents empty or disabled definitions without adding a visible shape. Tags contain no original text, image bytes or alt text. Reimport reads those values from current native shapes; complete groups recover literal dates, section/organization metadata and page-number intent when the current number matches the current slide position.
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).
@@ -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?} // box = [x, y, w, h] reference px, 1 decimal
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)) boxes.set(item.path, 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
- return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1)} : {field};
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 validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box);
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
- const result = rebuildContent(record.content, imported.slides[index]?.blocks ?? [], slides[index]?.contentBounds ?? []);
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
- const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
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
+ }