@openpresentation/opf-pptx 0.11.8 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # OPF PPTX
2
2
 
3
- 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).
3
+ Version 0.12.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored) and exports what that core composes, plus the export and import changes listed in the changelog; use it with opf-render 0.12.0 (optional peer ^0.12.0) and opf-editor 0.11.0 so preview and export resolve one core. 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
+
5
+ Chart options (RR-35, unreleased): `axisTitles`, `legend` and `dataLabels` on a chart export as native `c:title`, `c:legendPos` and `c:dLbls` (and `cx:title`, `cx:legend`, `cx:dataLabels` on the Office 2016 constructs) and import back; the preview draws the same options. See core `docs/chart-options.md` for the per-type support table and the `chart-option-adapted` diagnostic.
4
6
 
5
7
  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
8
 
@@ -30,9 +32,9 @@ Version 0.8.0 requires core 0.10.0 and uses renderer 0.8.0 for coordinated previ
30
32
  - Repository: `OpenPresentation/opf-pptx`
31
33
  - License: MIT
32
34
  - Compatibility target: `@openpresentation/opf`
33
- - Renderer relationship: may use `@openpresentation/opf-render` for chart rasterization and visual verification
35
+ - Renderer relationship: may use `@openpresentation/opf-render` for chart rasterization, the PNG fallback of SVG pictures and visual verification
34
36
  - Public export API: `toPptx(opf, opts)`
35
- - Public import API: `fromPptx(buffer, opts)`
37
+ - Public import API: `fromPptx(buffer, opts)` (`opts.signals` adds raw per-shape signals; see [Import signals](#import-signals))
36
38
 
37
39
  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
40
 
@@ -55,7 +57,15 @@ await fs.promises.writeFile("quarterly-review.pptx", bytes);
55
57
 
56
58
  `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
59
 
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).
60
+ 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`.
61
+
62
+ 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.
63
+
64
+ 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.
65
+
66
+ 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.
67
+
68
+ `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
69
 
60
70
  `fromPptx` parses an existing `.pptx` buffer locally and returns an OPF document that validates with `@openpresentation/opf`:
61
71
 
@@ -128,13 +138,45 @@ The first importer is mechanical and schema-compatible:
128
138
  - Native table text preserves run/field/break order, significant whitespace, and blank paragraphs. Cells return canonical `{value, style}` objects; `value` retains scalar text or supported rich runs. Covered merge positions are `null`. Explicit normal headers override OPF’s bold header default. Numeric/boolean/null source types cannot be reconstructed from native display text.
129
139
  - Run `lang` maps back to the presentation `language` (a catalog id when it round-trips, else the tag); see [Languages, right-to-left text and script fonts](#languages-right-to-left-text-and-script-fonts).
130
140
  - Imported runs retain bold, italic, underline, strike, point sizes, Latin font families, solid colors/alpha, external hyperlink URLs, and superscript/subscript direction. List-level and paragraph defaults apply before run overrides; supported theme fonts/colors resolve from the archive. Field values become their cached text, and underline/strike variants and baseline magnitudes reduce to OPF booleans.
141
+ - Citations, footnotes and captions (core RR-34 fields): a marker exports as a native superscript run (`baseline="30000"`) after its run; the slide's footnote area is a line shape plus one tagged text box per listed line (`OPF_FOOTNOTES_V1`), a caption one tagged text box per line (`OPF_CAPTION_V1`) naming its media shape, both at core's geometry; `references` travel in the document record. Import re-attaches tagged captions to their media block, rebuilds `references`, `cite` and `footnote` from the footnote tags and the marker runs (removing the marker runs; an edited note keeps its edited text), and guesses nothing without tags: a superscript number stays a superscript run and a text box stays a text block.
131
142
  - Conditional table styles, merged-cell geometry, cell fills/borders/alignment, unsupported text fills/colors, and internal hyperlink actions are not fully reconstructed. `onDiagnostic` reports unsupported table style references, merges, fonts/colors/fills and links with native frame/cell paths. Table paths use the native graphic-frame and row indexes, including a header row. The shared core 0.6.0 layout sizes rows from their content and reports `text-overflow` when text cannot fit at the minimum size. These checks establish native XML conversion, not visual parity with PowerPoint.
132
143
  - Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
133
144
  - Catalog references (`design.theme`, `colorScheme`, `fontScheme`, `dimensions`, `background`, slide `layout`), slide ids and authoring metadata (`narrative`, `tone`, `audience`, `purpose`, `language`, `organization`, `speaker`, ...) are stored at export in `OPF_DOCUMENT_V1` / `OPF_SLIDE_V1` customer-data tags. Import restores a reference while the theme colors, theme fonts, slide size, background or slide arrangement it produced are unchanged. After an edit, the observed native values stay and `design-reference-changed` / `layout-reference-changed` name the reference. `toPptx` option `provenance: 'references-only' | false` limits or disables these invisible tags. Slide layout intent (layout id, type, composition, composition hints and the inline layout record) lives in each `OPF_SLIDE_V1` record, so a slide keeps its layout even without the document tag, for example when it is pasted into another deck (FF-29). See [document round trips](docs/document-roundtrip.md) for exactly what is embedded.
134
145
  - 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
146
  - 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
147
 
137
- There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
148
+ 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).
149
+
150
+ ## Import signals
151
+
152
+ 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.
153
+
154
+ ```js
155
+ import { fromPptx } from "@openpresentation/opf-pptx";
156
+
157
+ // Default: the OPF document, exactly as before.
158
+ const document = await fromPptx(bytes);
159
+
160
+ // Opt in: the same document, and a signals value beside it.
161
+ const { document: same, signals } = await fromPptx(bytes, { signals: true });
162
+ // or with limits: fromPptx(bytes, { signals: { maxSlides: 100, maxShapesPerSlide: 150 } })
163
+ ```
164
+
165
+ 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`.
166
+
167
+ **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:
168
+
169
+ - `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`.
170
+ - `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.
171
+ - `parent`, `children` and `depth` give the group nesting; `id` is `s` plus the z-order index.
172
+ - `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.
173
+ - `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.
174
+ - `picture` (part, media type, bytes, natural size, crop), `table` (rows, columns, widths, header flags, cell text), `chart` (chart types, series and point counts, title).
175
+ - `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).
176
+
177
+ **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.
178
+
179
+ **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
180
 
139
181
  ## Chartex charts (FF-22b)
140
182
 
@@ -162,7 +204,7 @@ The exporter reads the presentation `language` through core `resolveScriptFonts(
162
204
  - Theme major/minor `a:ea`/`a:cs` are written only for a slot where a script font is actually selected (FF-49): an explicit `eastAsian`/`complexScript` on the design font scheme, the scheme's own script family, or the language's script font (a language that uses the East Asian slot, such as Japanese, writes `ea`; one that uses the complex-script slot, such as Arabic, Hebrew, Indic or Thai, writes `cs`). Every other slot keeps the vendored empty typeface, exactly as Office's own themes leave it, so PowerPoint picks its per-language default for script text typed later and the package names nothing the author did not select (owner font policy). Latin, Cyrillic, Greek, Armenian, Georgian and Ethiopic decks write neither; the other slot of a CJK or complex-script deck is empty too. The language never changes the theme's latin fonts (Model C): `design.fontScheme` alone sets them. The preview resolves the same slots (opf-render's script profile: the theme's family where it names one, the latin family where it is empty), and `test/theme-script-slots.mjs` checks preview against export for every catalog font scheme.
163
205
  - The theme's per-script entry for the language's own script (for example `Jpan`, `Hang`, `Arab` or `Deva`) names the resolver's supplement. The rest of the vendored Office per-script list is unchanged; that list is FF-08's call.
164
206
  - Run `a:ea`/`a:cs` name the resolved slot when the language or the font scheme supplies a script-specific font, for example Meiryo in `a:ea` for Japanese or Arabic Typesetting in `a:cs` for Arabic. Headings take the heading font and other text the body font. Otherwise the slots keep repeating the run's latin face, so Latin, Cyrillic and Greek decks keep their run bytes.
165
- - In a right-to-left deck, each slide and notes paragraph takes its direction from core `paragraphDirection(text, direction)`, the same rule the renderer uses: `rtl="1"` when its first strong character is right-to-left or it has none (digits, punctuation, empty), else an explicit `rtl="0"` (an English quote, a code line). The master, layout and presentation default paragraph levels start right-to-left only in a right-to-left deck. Left-to-right decks write no paragraph direction. Alignment is unchanged: `algn` stays the composed absolute alignment (left stays `l`), so native line placement keeps matching the renderer's geometry. Right-aligning RTL text by default is a composition decision in core; until then, set `contentAlignment` or `titleAlignment` to `right`.
207
+ - In a right-to-left deck, each slide and notes paragraph takes its direction from core `paragraphDirection(text, direction)`, the same rule the renderer uses: `rtl="1"` when its first strong character is right-to-left or it has none (digits, punctuation, empty), else an explicit `rtl="0"` (an English quote, a code line). The master, layout and presentation default paragraph levels start right-to-left only in a right-to-left deck. Left-to-right decks write no paragraph direction. Alignment is logical (RR-05, needs the core release that composes right-to-left decks): `left` is the start edge, so a right-to-left paragraph writes `rtl="1"` with `algn="r"` (PowerPoint's `algn` is physical, and `marL`/`indent` are the start-side margin and hanging indent, so a bullet hangs at the right), and every wrapped line shares its paragraph's direction and alignment (core computes the direction once per paragraph, not per line). The composition is mirrored through core: the `left` region and the first column are drawn at the right, a right-to-left table writes `a:tblPr rtl="1"` with its columns in logical order, column, line and area charts write `c:catAx` orientation `maxMin` (first category and value axis at the right), cover logos and header/footer zones swap sides, the master, layout and notes default levels start right-aligned, and notes paragraphs start at the right. Each Latin phrase inside an Arabic or Hebrew run (letters, joining spaces and word punctuation, digits) is written as its own `en-US` run, so PowerPoint orders `v2.0` and `PowerPoint 365` left to right instead of splitting them into items; digits that touch Arabic words stay in the Arabic run. Re-import reads right alignment of a right-to-left table cell as the logical start. Left-to-right decks are written exactly as before.
166
208
  - The PPTX names the chosen fonts only and never embeds font programs. Licensed fonts are never embedded; only open fonts could be, through the explicit FF-13 embed path. Catalog names such as Meiryo must be installed where the deck is opened.
167
209
 
168
210
  Charts keep their `c:lang` and left-to-right label paragraphs. The embedded chart workbook is untouched.
@@ -179,6 +221,20 @@ Exports report `language-unresolved` when the document's language cannot be reso
179
221
 
180
222
  **Core without the resolver.** Core `@openpresentation/opf` 0.11.0 and earlier have no `resolveScriptFonts` (this release requires ^0.11.3, so this only applies to a forced older core). Export with it is byte-identical to the output before FF-07 (`lang="en-US"`, empty theme `ea`/`cs`, no `rtl`), and a document that names a language gets a `language-export-unavailable` diagnostic. A core with the resolver but without `paragraphDirection` marks no paragraph direction and reports `paragraph-direction-unavailable` for a right-to-left deck. Import then matches run tags against the installed catalog's `bcp47` and primary language. `npm run test:packed` exercises this path against the registry release. CI links core at a pinned commit that has the resolver.
181
223
 
224
+ ## Numbered lists (RR-33)
225
+
226
+ A list with `numbering` (`{ "items": ["Define", "Build"], "numbering": ["arabic", { "style": "alpha-lower", "suffix": "paren" }] }`) exports as native PowerPoint auto-numbers, not typed digits. Each marker line is the text box it already was, now with `a:buAutoNum` (`arabicPeriod`, `arabicParenR`, `arabicParenBoth`, `romanUc`/`romanLc`..., `alphaUc`/`alphaLc`...) whose `startAt` is the number core counted (written only when it is not 1), the measured marker size, colour and family (`a:buClr`, `a:buSzPts`, `a:buFont`) and `marL`/`indent` from core's hanging indent, so the number and the text sit where the preview draws them. A numbered list is not renumbered by PowerPoint when an entry is inserted (each line is its own box; this is how lists export today), the numbers are core's. `fromPptx` maps `a:buAutoNum` back: the scheme to a style and suffix, `startAt` to `start`, per level to an array, and a list whose numbers are not the plain count to per-entry `start` values; native counting follows PowerPoint (consecutive paragraphs of one scheme and start count up, a shallower or unnumbered paragraph ends the deeper sequences). The authored spelling comes back in its shortest form (`"arabic"` for `{ "style": "arabic" }`). A scheme OPF has no equivalent for imports as `arabic` with `numbering-scheme-adapted`, a list that mixes numbered and bullet paragraphs imports as bullets with `numbering-mixed`, and different schemes at one level report `numbering-style-adapted`. A numbered list exported with a core that does not compose `numbering` reports `numbering-unsupported-core` and exports bullets. Lists without `numbering` export unchanged. See core's [numbered lists](https://github.com/OpenPresentation/opf/blob/main/docs/numbered-lists.md).
227
+
228
+ ## Templates and variables (RR-32)
229
+
230
+ A deck that declares content variables, or is marked `"template": true`, is resolved by core `resolveVariables` before it is exported, so the PPTX holds exactly the text, numbers, dates and images the preview shows. Pass the values as `variables`:
231
+
232
+ ```js
233
+ const pptx = await toPptx(template, { variables: { client: 'Globex', revenue: 1250000 } });
234
+ ```
235
+
236
+ A template exports with each unfilled variable's `example` and reports `variable-example-used` through `onDiagnostic`; a normal deck with an unfilled required variable throws `OPFPptxError` with code `unfilled-variables`, and a value of the wrong kind throws `invalid-variables`. A template plus values exports the same bytes as the equivalent hand-written deck (`test/template-variables.mjs`). The package stores the resolved deck, not the template form: `fromPptx` returns the filled deck. Decks without content variables are untouched. Needs the core release that ships `resolveVariables` (read from the namespace, so an older core still loads and ignores the option). See [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md).
237
+
182
238
  ## Runtime Policy
183
239
 
184
240
  The package runtime must stay local and deterministic:
@@ -295,7 +351,9 @@ Node 20/24 tests and the 126-deck / 805-slide structural corpus pass. This prove
295
351
 
296
352
  ### Pattern and picture backgrounds (FF-25)
297
353
 
298
- Pattern backgrounds export as a native `<a:pattFill>` with explicit foreground and background colors. OPF presets named by DrawingML's 54 `ST_PresetPatternVal` values (for example `pct5`, `ltHorz`, `openDmnd`, `wave`) are written unchanged. As in the SVG preview, a missing foreground uses the slide text color and a missing background uses white; text contrast follows the pattern's background color. The preview's engine id `diagStripe` has no DrawingML name. It is still accepted and is written as the closest preset, `wdUpDiag`; new documents should use `wdUpDiag` so that re-import keeps the name. Any other engine-defined id keeps only its background color, like the preview, and reports `unsupported-pattern`. Opacity becomes alpha on both colors. Under the FF-24 theme-color rule, a pattern color authored as a scheme slot or role becomes `a:schemeClr`, with the background opacity as alpha, when the deck theme holds exactly the color the preview draws. Like the preview, pattern colors resolve only hex, so any other name is drawn as the default color and stays literal RGB. Import resolves theme pattern colors to RGB.
354
+ Pattern backgrounds export as a native `<a:pattFill>` with explicit foreground and background colors. OPF presets named by DrawingML's 54 `ST_PresetPatternVal` values (for example `pct5`, `ltHorz`, `openDmnd`, `wave`) are written unchanged. As in the SVG preview, a missing foreground uses the slide text color and a missing background uses white; text contrast follows the pattern's background color. The preview's engine id `diagStripe` has no DrawingML name. It is still accepted and is written as the closest preset, `wdUpDiag`; new documents should use `wdUpDiag` so that re-import keeps the name. Any other engine-defined id keeps only its background color, like the preview, and reports `unsupported-pattern`. Opacity becomes alpha on both colors. Under the FF-24 theme-color rule, a pattern color authored as a scheme slot or role becomes `a:schemeClr`, with the background opacity as alpha, when the deck theme holds exactly the color the preview draws. Like the preview, pattern colors resolve only hex, so any other name is drawn as the default color and stays literal RGB. Import resolves theme pattern colors to RGB. The SVG preview draws every one of the 54 presets from core's 8 x 8 bitmaps (RR-07), so the preview and this `a:pattFill` show the same preset in the same colours (`test/rr-07-preview-polish.mjs` pins it for all 54); the tiles are the pixels measured from desktop PowerPoint (Office 365, Windows, 2026-10-01), not defined by ECMA-376.
355
+
356
+ `code.language` and `metric.trend` export natively from the same core tables the preview reads (RR-07). Code keeps one text box per source line; a language adds coloured runs (theme-derived colours kept at 4.5:1 or more on the #111827 panel) whose text concatenates to the exact line, and an unknown language is exported plain. A trend adds one arrow shape (`upArrow`, `downArrow` or `rightArrow`, alt text "Trend: up") beside the trend word and colours the delta and trend text green, red or neutral; the arrow is part of the metric's provenance group, so the metric re-imports as the one authored payload. `scripts/rr07-native-set.mjs <dir>` builds the native-check decks, preview PNGs and a manifest.
299
357
 
300
358
  Image backgrounds export as a native `<a:blipFill>` for the embedded raster: `data:` sources, declared `asset:` ids and `imageResolver` results. `cover` (the default) crops the centered source to the slide aspect with `a:srcRect`. `contain` letterboxes the source with `a:fillRect` insets. `tile` repeats square cells that are min(width, height)/4 in size, from the top-left, as the preview does. The deck `imageFill` decides whether each cell is covered or contained; contained cells use negative (transparent) source insets. With `dpi="0"`, a native tile is sized from the raster's own resolution: PNG `pHYs` in metres; JPEG JFIF density in inches or centimetres, otherwise EXIF `XResolution`/`YResolution`; 96 dpi when absent. The tile scale compensates on each axis, so a cell matches the preview's CSS pixels. WebP sources become PNG parts as for pictures. Opacity becomes `a:alphaModFix`. An unresolved image keeps the background color and reports `unresolved-asset` (`strictAssets` throws). A JPEG EXIF orientation cannot rotate a background fill, so it reports `unsupported-background-image-orientation`.
301
359
 
@@ -371,10 +429,12 @@ Version 0.5.0 requires core 0.7.0 and renderer 0.5.0 for coordinated previews. `
371
429
 
372
430
  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
431
 
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.
432
+ 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
433
 
376
434
  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
435
 
378
436
  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
437
 
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.
438
+ 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.
439
+
440
+ 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).
@@ -0,0 +1,83 @@
1
+ // Native export of captions and footnote areas (RR-34). Core composition owns the geometry
2
+ // (`item.caption`, `geometry.footnotes`); this module writes one editable native text box per
3
+ // fitted line at those boxes, like every other measured text shape, and records the shape tags
4
+ // (OPF_CAPTION_V1, OPF_FOOTNOTES_V1) that annotation-provenance.js attaches at packaging and reads
5
+ // back at import. Citation markers need nothing here (a marker fragment carries `nominalSize`, the marked run size, which is what is written with baseline 30000; PowerPoint draws it at 2/3, measured 2026-10-01): they are `kind: "marker"` fragments of the
6
+ // rich-text fits and export through the ordinary rich-run path as superscript runs.
7
+
8
+ export const CAPTION_TAG = 'OPF_CAPTION_V1';
9
+ export const FOOTNOTES_TAG = 'OPF_FOOTNOTES_V1';
10
+
11
+ export const captionShapeName = path => `OPF caption ${path}`;
12
+ export const footnotesRuleName = slideIndex => `OPF footnotes ${slideIndex} rule`;
13
+ export const footnoteEntryName = (slideIndex, entry) => `OPF footnotes ${slideIndex} entry ${entry}`;
14
+
15
+ const INVALID_XML = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uD800-\uDFFF￾￿]/u;
16
+
17
+ /**
18
+ * Write a core text fit (plain `fit.lines` with optional `sourceLines`/`placement`, or a rich fit
19
+ * with `richLines`) as one native text box per line at `box`, named `${objectName} line N` and
20
+ * recorded in `tags` with `record` plus the line's position and source boundary. `helpers` are the
21
+ * exporter's private text helpers (textBoxOptions, nativeFontOptions, exportColor, nativeColor).
22
+ */
23
+ export function addFitLines(slide, value, fit, box, context, helpers, {objectName, record, tags, align = 'left', color, fontFamily, error}) {
24
+ const text = typeof value === 'string' ? value : value.map(run => typeof run === 'string' ? run : run.text).join('');
25
+ const invalid = INVALID_XML.exec(text);
26
+ if (invalid) throw error('invalid-text', `Text contains U+${invalid[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')} at UTF-16 offset ${invalid.index}, which DrawingML XML cannot represent.`);
27
+ const factor = align === 'right' ? 1 : align === 'center' ? .5 : 0;
28
+ const lineColor = (color ?? context.textColor).replace(/^#/, '');
29
+ // `provenance: false` writes no tags at all (like every other OPF record): the lines stay named, untagged text boxes.
30
+ const tagged = context.provenanceMode === false ? new Map() : tags;
31
+ tags = tagged;
32
+ if (fit.richLines) {
33
+ const count = fit.richLines.length;
34
+ fit.richLines.forEach((line, index) => {
35
+ const runs = line.fragments.map(fragment => {
36
+ const runColor = helpers.exportColor(fragment.run.color, context, lineColor), native = helpers.nativeColor(fragment.run.color, runColor, context, lineColor);
37
+ return {text: fragment.text, options: {...helpers.nativeFontOptions(fragment.style), fontSize: (fragment.nominalSize ?? fragment.fontSize) * .75, color: native,
38
+ underline: fragment.run.underline ? {style: 'sng', color: native} : undefined, strike: fragment.run.strikethrough ? 'sngStrike' : undefined,
39
+ baseline: fragment.baselineShift ? -fragment.baselineShift / (fragment.nominalSize ?? fragment.fontSize) * 2000 : undefined,
40
+ hyperlink: fragment.kind !== 'marker' && fragment.run.link && /^(https?:|mailto:)/i.test(fragment.run.link) ? {url: fragment.run.link} : undefined}};
41
+ });
42
+ if (!runs.length) runs.push({text: '', options: {}});
43
+ const placed = fit.placement?.lines[index];
44
+ const area = placed ? {x: (placed.x + line.width * factor - box.width * factor) / 96, y: placed.y / 96, w: box.width / 96, h: placed.height / 96}
45
+ : {x: box.x / 96, y: (box.y + line.y) / 96, w: box.width / 96, h: line.height / 96};
46
+ const name = `${objectName} line ${index}`;
47
+ tags.set(name, {...record, line: index, count, boundary: index === count - 1 ? 'end' : 'soft', separator: '', rich: true});
48
+ slide.addText(runs, {...helpers.textBoxOptions(area, context, fit.fontSize * .75), ...(fontFamily ? {fontFace: fontFamily} : {}), color: lineColor, align, fit: 'none', wrap: false, lineSpacingMultiple: 1, objectName: name});
49
+ });
50
+ return;
51
+ }
52
+ const count = fit.lines.length;
53
+ fit.lines.forEach((line, index) => {
54
+ const placed = fit.placement?.lines[index], sourceLine = fit.sourceLines?.[index];
55
+ const boundary = sourceLine ? {boundary: sourceLine.boundary, separator: text.slice(sourceLine.end, sourceLine.nextStart)} : {boundary: index === count - 1 ? 'end' : 'soft', separator: ''};
56
+ const area = placed ? {x: (placed.x + placed.width * factor - box.width * factor) / 96, y: (placed.baseline - fit.fontSize) / 96, w: box.width / 96, h: placed.height / 96}
57
+ : {x: box.x / 96, y: (box.y + index * fit.lineHeight) / 96, w: box.width / 96, h: fit.lineHeight / 96};
58
+ const name = `${objectName} line ${index}`;
59
+ tags.set(name, {...record, line: index, count, ...boundary});
60
+ slide.addText(line, {...helpers.textBoxOptions(area, context, fit.fontSize * .75), ...(fontFamily ? {fontFace: fontFamily} : {}), color: lineColor, align, fit: 'none', wrap: false, lineSpacingMultiple: 1,
61
+ tabStops: sourceLine?.segments.filter(segment => segment.kind === 'tab').map(segment => ({position: (segment.x + segment.width) / 96, alignment: 'l'})), objectName: name});
62
+ });
63
+ }
64
+
65
+ /** The caption band of a captioned item: one tagged text box per line, linked to the media shape by name. */
66
+ export function addCaption(slide, item, mediaName, context, helpers, error) {
67
+ const caption = item.caption;
68
+ if (!caption) return;
69
+ const record = {v: 1, role: 'caption', path: item.path, field: caption.path, media: mediaName ?? null, position: caption.position, align: caption.alignment};
70
+ addFitLines(slide, caption.text, caption.fit, caption.box, context, helpers, {objectName: captionShapeName(item.path), record, tags: context.captionTags, align: caption.alignment, color: context.mutedColor, fontFamily: helpers.nativeFontOptions(caption.textStyle).fontFace, error});
71
+ }
72
+
73
+ /** The footnote area: a rule shape and one tagged text box per listed line, at core's geometry above the footer band. */
74
+ export function addFootnotes(slide, footnotes, slideIndex, context, helpers, error) {
75
+ if (!footnotes) return;
76
+ const {rule} = footnotes, ruleName = footnotesRuleName(slideIndex);
77
+ if (context.provenanceMode !== false) context.footnoteTags.set(ruleName, {v: 1, role: 'rule', slide: slideIndex});
78
+ slide.addShape('line', {x: rule.x / 96, y: (rule.y + rule.thickness / 2) / 96, w: rule.width / 96, h: 0, line: {color: context.colors.border, width: rule.thickness * .75}, objectName: ruleName});
79
+ footnotes.entries.forEach((entry, index) => {
80
+ const record = {v: 1, role: 'entry', slide: slideIndex, entry: index, number: entry.number, kind: entry.kind, ...(entry.id !== undefined ? {id: entry.id} : {}), source: entry.sourcePath, ...(entry.url !== undefined ? {url: entry.url} : {})};
81
+ addFitLines(slide, entry.value, entry.fit, entry.box, context, helpers, {objectName: footnoteEntryName(slideIndex, index), record, tags: context.footnoteTags, align: 'left', color: context.mutedColor, fontFamily: helpers.nativeFontOptions(entry.textStyle).fontFace, error});
82
+ });
83
+ }
@@ -0,0 +1,246 @@
1
+ // Round trip of captions, citations and footnotes (RR-34).
2
+ //
3
+ // Export writes captions and footnote lines as tagged text boxes (annotation-export.js):
4
+ // OPF_CAPTION_V1 {v:1, role:'caption', path, field, media, position, align, line, count, boundary, separator, rich?}
5
+ // OPF_FOOTNOTES_V1 {v:1, role:'rule', slide} | {v:1, role:'entry', slide, entry, number, kind, id?, source, url?, line, count, boundary, separator, rich?}
6
+ // and the deck's `references` travel in the document record (document-provenance.js, metadata
7
+ // supplement). Citation markers are ordinary superscript runs (`baseline="30000"`) with no tag.
8
+ //
9
+ // Import consumes the tagged shapes: caption lines re-attach to the block whose native media shape
10
+ // the tag names (current native text wins, so an edited caption keeps its edit); footnote lines
11
+ // rebuild each slide's notes (number, kind, reference id, current text); then every superscript
12
+ // run whose text is a marker (`1`, `1,2`) directly after another run, and whose numbers all resolve
13
+ // to this slide's notes, is removed and the run before it gets `cite` (reference ids) and/or
14
+ // `footnote` (the note text). The references list is the stored list with the current text of every
15
+ // note that was edited natively, plus any reference the stored list does not hold. Without tags
16
+ // nothing is guessed: a superscript number stays a superscript run and a text box stays a text block.
17
+ // Tags are untrusted input: every field is checked before use and the result still validates.
18
+ import {XMLParser} from 'fast-xml-parser';
19
+ import {attachTextTags, decodeTextTag} from './code-provenance.js';
20
+ import {sourceLineParagraphs} from './text-provenance.js';
21
+ import {CAPTION_TAG, FOOTNOTES_TAG} from './annotation-export.js';
22
+
23
+ const REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
24
+ const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
25
+ const decoder = new TextDecoder('utf-8', {fatal: true});
26
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
27
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ const MAX_VALUE_CHARS = 64 * 1024;
29
+ const MARKER = /^\d+(?:,\d+)*$/;
30
+ const ENGINE_STYLE = new Set(['fontSize', 'fontFamily', 'color']);
31
+ // Internal reader markers (`_opfScheme`: the scheme slot a colour resolved from) are not authored style either.
32
+ const engineKey = key => ENGINE_STYLE.has(key) || key.startsWith('_opf');
33
+ const name = shape => shape?.['p:nvSpPr']?.['p:cNvPr']?.name ?? shape?.['p:nvPicPr']?.['p:cNvPr']?.name ?? shape?.['p:nvGraphicFramePr']?.['p:cNvPr']?.name;
34
+
35
+ export function attachAnnotationTags(entries, context) {
36
+ attachTextTags(entries, context.captionTags, CAPTION_TAG, 'opfCaption', 'caption');
37
+ attachTextTags(entries, context.footnoteTags, FOOTNOTES_TAG, 'opfFootnotes', 'footnotes');
38
+ }
39
+
40
+ function readTags(shape, relationships, entries) {
41
+ const tags = [];
42
+ let unreadable = false;
43
+ for (const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
44
+ const rel = relationships.get(link['r:id']);
45
+ if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) { unreadable = true; continue; }
46
+ try { tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag'])); } catch { unreadable = true; }
47
+ }
48
+ return {tags, unreadable};
49
+ }
50
+
51
+ function readRecord(shape, relationships, entries, tagName) {
52
+ const {tags, unreadable} = readTags(shape, relationships, entries);
53
+ const own = tags.filter(tag => String(tag.name ?? '').toUpperCase() === tagName);
54
+ if (!own.length) return undefined;
55
+ if (unreadable || own.length !== 1 || tags.some(tag => /^OPF_/i.test(tag.name ?? '') && String(tag.name).toUpperCase() !== tagName)) return null;
56
+ try {
57
+ if (typeof own[0].val !== 'string' || own[0].val.length > MAX_VALUE_CHARS * 2) return null;
58
+ const record = decodeTextTag(own[0].val);
59
+ if (!object(record) || record.v !== 1) return null;
60
+ if (record.line !== undefined && (!Number.isSafeInteger(record.line) || record.line < 0 || !Number.isSafeInteger(record.count) || record.count < 1 || record.count > 1000 || record.line >= record.count)) return null;
61
+ if (record.boundary !== undefined && !['hard', 'soft', 'end'].includes(record.boundary)) return null;
62
+ if (record.separator !== undefined && (typeof record.separator !== 'string' || !/^(?:\r\n|\r|\n)?$/.test(record.separator))) return null;
63
+ return record;
64
+ } catch { return null; }
65
+ }
66
+
67
+ // Current native text of a group of line shapes, in line order: rich runs when the reader yields
68
+ // them, else the plain paragraph text. Soft wraps insert nothing; hard boundaries keep their separator.
69
+ function groupText(members, paragraphs, readBody) {
70
+ const ordered = members.sort((a, b) => a.record.line - b.record.line);
71
+ const rich = ordered.map(member => readBody?.(member.index));
72
+ if (rich.every(body => Array.isArray(body) && body.length)) {
73
+ const runs = [];
74
+ ordered.forEach((member, position) => {
75
+ const body = rich[position];
76
+ body.forEach((paragraph, paragraphIndex) => {
77
+ if (paragraphIndex) runs.push('\n');
78
+ // Caption and note lines are drawn in the engine's caption/footnote size, family and muted
79
+ // colour: those are not authored run styles, so only the authored emphasis comes back.
80
+ runs.push(...(Array.isArray(paragraph.richText) ? paragraph.richText.map(run => typeof run === 'string' ? run : Object.fromEntries(Object.entries(run).filter(([key]) => !engineKey(key)))) : [paragraph.richText ?? paragraph.text ?? '']));
81
+ });
82
+ const separator = member.record.boundary === 'hard' ? (member.record.separator || '\n') : '';
83
+ if (separator && position < ordered.length - 1) runs.push(separator);
84
+ });
85
+ return normalizeRuns(runs);
86
+ }
87
+ try {
88
+ return sourceLineParagraphs(ordered.map(member => ({index: member.index, data: {boundary: member.record.boundary ?? (member.record.line === member.record.count - 1 ? 'end' : 'soft'), separator: member.record.separator ?? (member.record.boundary === 'hard' ? '\n' : '')}})), paragraphs)[0].text;
89
+ } catch {
90
+ return ordered.map(member => (paragraphs[member.index] ?? []).map(paragraph => paragraph.text).join('\n')).join('');
91
+ }
92
+ }
93
+
94
+ // Merge adjacent plain strings; a run list with no styled run is a plain string.
95
+ function normalizeRuns(runs) {
96
+ const merged = [];
97
+ for (const run of runs) {
98
+ const last = merged[merged.length - 1];
99
+ if (typeof run === 'string') { if (!run) continue; if (typeof last === 'string') merged[merged.length - 1] = last + run; else merged.push(run); }
100
+ else if (object(run) && typeof run.text === 'string') {
101
+ // A reader may leave keys with undefined values (no native value); they are not authored style.
102
+ const style = Object.fromEntries(Object.entries(run).filter(([key, value]) => key !== 'text' && value !== undefined));
103
+ if (!Object.keys(style).length) { if (typeof last === 'string') merged[merged.length - 1] = last + run.text; else merged.push(run.text); }
104
+ else merged.push({text: run.text, ...style});
105
+ }
106
+ }
107
+ return merged.every(run => typeof run === 'string') ? merged.join('') : merged;
108
+ }
109
+
110
+ const plain = value => typeof value === 'string' ? value : Array.isArray(value) ? value.map(run => typeof run === 'string' ? run : run.text).join('') : '';
111
+
112
+ // Strip the `<n> ` prefix a listed note starts with.
113
+ function withoutNumber(value, number) {
114
+ const prefix = `${number} `;
115
+ if (typeof value === 'string') return value.startsWith(prefix) ? value.slice(prefix.length) : value;
116
+ if (!Array.isArray(value) || !value.length) return value;
117
+ const [first, ...rest] = value, text = typeof first === 'string' ? first : first.text;
118
+ if (!text.startsWith(prefix)) return value;
119
+ const remainder = text.slice(prefix.length);
120
+ const head = typeof first === 'string' ? (remainder ? [remainder] : []) : remainder ? [{...first, text: remainder}] : [];
121
+ return normalizeRuns([...head, ...rest]);
122
+ }
123
+
124
+ /**
125
+ * Consume the tagged caption and footnote shapes of one slide. Returns the shapes to skip, the
126
+ * captions keyed by the media shape name they belong to, and the slide's notes in number order.
127
+ */
128
+ export function importAnnotations({shapes, paragraphs, relationships, entries, slideIndex, readBody, report}) {
129
+ const consumed = new Set(), captionGroups = new Map(), entryGroups = new Map();
130
+ for (const [index, shape] of shapes.entries()) {
131
+ const caption = readRecord(shape, relationships, entries, CAPTION_TAG);
132
+ if (caption !== undefined) {
133
+ consumed.add(shape);
134
+ if (!caption || caption.role !== 'caption' || typeof caption.path !== 'string' || typeof caption.field !== 'string' || !['below', 'above'].includes(caption.position) || !['left', 'center', 'right'].includes(caption.align) || caption.line === undefined) { report({code: 'invalid-caption-provenance', path: `slides.${slideIndex}`, message: 'A caption tag is unreadable or invalid; its text stays an ordinary text block.'}); consumed.delete(shape); continue; }
135
+ const key = caption.path;
136
+ if (!captionGroups.has(key)) captionGroups.set(key, []);
137
+ captionGroups.get(key).push({index, shape, record: caption});
138
+ continue;
139
+ }
140
+ const footnote = readRecord(shape, relationships, entries, FOOTNOTES_TAG);
141
+ if (footnote === undefined) continue;
142
+ consumed.add(shape);
143
+ if (!footnote || !['rule', 'entry'].includes(footnote.role)) { report({code: 'invalid-footnote-provenance', path: `slides.${slideIndex}`, message: 'A footnote tag is unreadable or invalid; its text stays an ordinary text block.'}); consumed.delete(shape); continue; }
144
+ if (footnote.role === 'rule') continue;
145
+ if (!Number.isSafeInteger(footnote.entry) || !Number.isSafeInteger(footnote.number) || footnote.number < 1 || !['reference', 'footnote'].includes(footnote.kind) || (footnote.kind === 'reference' && typeof footnote.id !== 'string') || footnote.line === undefined) { report({code: 'invalid-footnote-provenance', path: `slides.${slideIndex}`, message: 'A footnote entry tag is invalid; its text stays an ordinary text block.'}); consumed.delete(shape); continue; }
146
+ if (!entryGroups.has(footnote.entry)) entryGroups.set(footnote.entry, []);
147
+ entryGroups.get(footnote.entry).push({index, shape, record: footnote});
148
+ }
149
+ const complete = members => members.length === members[0].record.count && new Set(members.map(member => member.record.line)).size === members.length;
150
+ const captions = new Map();
151
+ for (const [path, members] of captionGroups) {
152
+ if (!complete(members)) { report({code: 'invalid-caption-provenance', path, message: 'Caption lines were removed or duplicated; the remaining text stays an ordinary text block.'}); for (const member of members) consumed.delete(member.shape); continue; }
153
+ const {record} = members[0];
154
+ const value = groupText(members, paragraphs, readBody);
155
+ captions.set(typeof record.media === 'string' ? record.media : `path:${path}`, {value, path, field: record.field, position: record.position, align: record.align});
156
+ }
157
+ const notes = [];
158
+ for (const members of entryGroups.values()) {
159
+ if (!complete(members)) { report({code: 'invalid-footnote-provenance', path: `slides.${slideIndex}`, message: 'Footnote lines were removed or duplicated; the remaining text stays an ordinary text block.'}); for (const member of members) consumed.delete(member.shape); continue; }
160
+ const {record} = members[0];
161
+ notes.push({number: record.number, kind: record.kind, ...(record.kind === 'reference' ? {id: record.id} : {}), ...(typeof record.url === 'string' ? {url: record.url} : {}), text: withoutNumber(groupText(members, paragraphs, readBody), record.number)});
162
+ }
163
+ notes.sort((a, b) => a.number - b.number);
164
+ return {consumed, captions, notes};
165
+ }
166
+
167
+ /** The caption value to write into a block: a string or TextRun[] with the default position and alignment, else the object form. */
168
+ export function captionValue(caption) {
169
+ return caption.position === 'below' && caption.align === 'left' ? caption.value : {text: caption.value, position: caption.position, align: caption.align};
170
+ }
171
+
172
+ // Every run array of a slide payload that may carry markers, with a setter.
173
+ function runArrays(slide) {
174
+ const found = [];
175
+ const visit = (host, path) => {
176
+ if (!object(host)) return;
177
+ if (Array.isArray(host.blocks)) { host.blocks.forEach((block, index) => visit(block, `${path}.blocks.${index}`)); return; }
178
+ if (Array.isArray(host.text)) found.push({path: `${path}.text`, get: () => host.text, set: value => { host.text = value; }});
179
+ for (const field of ['bullets', 'items']) if (Array.isArray(host[field])) host[field].forEach((item, index) => {
180
+ if (Array.isArray(item)) found.push({path: `${path}.${field}.${index}`, get: () => host[field][index], set: value => { host[field][index] = value; }});
181
+ else if (object(item)) {
182
+ if (Array.isArray(item.text)) found.push({path: `${path}.${field}.${index}.text`, get: () => item.text, set: value => { item.text = value; }});
183
+ if (Array.isArray(item.description)) found.push({path: `${path}.${field}.${index}.description`, get: () => item.description, set: value => { item.description = value; }});
184
+ }
185
+ });
186
+ };
187
+ const keys = Object.keys(slide).filter(key => /^(top|middle|bottom|left|center|right)([+:]|$)/.test(key) && object(slide[key]));
188
+ if (keys.length) for (const key of keys.sort()) visit(slide[key], key); else visit(slide, '');
189
+ return found;
190
+ }
191
+
192
+ const isMarkerRun = run => object(run) && run.superscript === true && typeof run.text === 'string' && MARKER.test(run.text);
193
+
194
+ /**
195
+ * Replace the marker runs of `slide` by `cite`/`footnote` on the runs before them, using the slide's
196
+ * notes. A marker whose numbers do not all resolve, or that starts a run array, is left as it is.
197
+ * Returns the reference notes the slide used.
198
+ */
199
+ export function restoreSlideCitations(slide, notes) {
200
+ const byNumber = new Map(notes.map(note => [note.number, note]));
201
+ for (const target of runArrays(slide)) {
202
+ const runs = target.get();
203
+ if (!runs.some(isMarkerRun)) continue;
204
+ const result = [];
205
+ for (const run of runs) {
206
+ const previous = result[result.length - 1];
207
+ if (!isMarkerRun(run) || previous === undefined || typeof previous === 'object' && previous.superscript) { result.push(run); continue; }
208
+ const resolved = run.text.split(',').map(Number).map(number => byNumber.get(number));
209
+ if (resolved.some(note => !note)) { result.push(run); continue; }
210
+ const marked = typeof previous === 'string' ? {text: previous} : {...previous};
211
+ const ids = [...new Set(resolved.filter(note => note.kind === 'reference').map(note => note.id))];
212
+ const footnotes = resolved.filter(note => note.kind === 'footnote');
213
+ if (ids.length) marked.cite = ids.length === 1 ? ids[0] : ids;
214
+ if (footnotes.length) marked.footnote = footnotes[0].text;
215
+ result[result.length - 1] = marked;
216
+ }
217
+ target.set(result);
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Apply the notes of every slide to `imported` (markers become cite/footnote) and rebuild
223
+ * `references`: the stored list (document provenance) with the current native text of each note
224
+ * that was edited, plus any cited reference the stored list does not hold, in number order.
225
+ */
226
+ export function restoreCitations(imported, slideNotes, report) {
227
+ const used = new Map();
228
+ imported.slides.forEach((slide, index) => {
229
+ const notes = slideNotes[index] ?? [];
230
+ if (!notes.length) return;
231
+ restoreSlideCitations(slide, notes);
232
+ for (const note of notes) if (note.kind === 'reference' && !used.has(note.id)) used.set(note.id, note);
233
+ });
234
+ const stored = Array.isArray(imported.references) ? imported.references.filter(object) : [];
235
+ if (!stored.length && !used.size) return;
236
+ const references = stored.map(reference => {
237
+ const note = used.get(reference.id);
238
+ if (!note) return reference;
239
+ const current = note.text;
240
+ if (plain(current) === plain(reference.text)) return reference;
241
+ report({code: 'reference-text-changed', path: `references.${stored.indexOf(reference)}`, message: `Reference '${reference.id}' shows edited text in a footnote area; the imported reference keeps the edited text.`});
242
+ return {...reference, text: current};
243
+ });
244
+ for (const note of used.values()) if (!references.some(reference => reference.id === note.id)) references.push({id: note.id, text: note.text, ...(note.url !== undefined ? {url: note.url} : {})});
245
+ imported.references = references;
246
+ }
@@ -11,6 +11,7 @@ const text = tree => (tree ?? []).map(node => node['#text'] ?? '').join('');
11
11
  // Match nativeTextShapes/nativeShapeParagraphs indexing; positional ordering is
12
12
  // still performed by the existing slide importer, not by this reader.
13
13
  const shapes = tree => [...nodes(tree, 'p:sp').map(node => node['p:sp']), ...nodes(tree, 'p:grpSp').flatMap(node => shapes(node['p:grpSp']))];
14
+ const LATIN_ONLY = /^[\p{Script=Latin}\p{N}\p{P}\p{S}\p{Z}]*$/u;
14
15
  const value = runs => runs.some(run => typeof run !== 'string') ? runs : runs.join('');
15
16
  export const joinNativeParagraphs = paragraphs => value(paragraphs.flatMap((paragraph, index) => [
16
17
  ...(index ? ['\n'] : []), ...(Array.isArray(paragraph.richText) ? paragraph.richText : [paragraph.richText ?? paragraph.text])
@@ -77,7 +78,9 @@ export function nativeBodyReader(slidePath, archive, relationships, report) {
77
78
  const path = `shapes.${index}.paragraphs.${paragraphIndex}.runs.${runIndex++}`;
78
79
  const current = tag === 'a:br' ? '\n' : text(child(node[tag], 'a:t'));
79
80
  const properties = mergeNativeRunProperties(defaults, drawingObject(node[tag])['a:rPr']);
80
- const style = bodyRunStyle(properties, context, relationships, (code, message) => report({code,message,path}));
81
+ // RR-05: a Latin phrase of a right-to-left paragraph is its own en-US run; its East Asian/complex-script slots can render nothing in it, so the face loss is not reported.
82
+ const phrase = nodes(content, 'a:pPr')[0]?.[':@']?.rtl === '1' && properties.lang === 'en-US' && LATIN_ONLY.test(current);
83
+ const style = bodyRunStyle(properties, context, relationships, (code, message) => { if (!(phrase && code === 'unsupported-body-font')) report({code,message,path}); });
81
84
  if (current) runs.push(Object.keys(style).length ? {text:current,...style} : current);
82
85
  }
83
86
  return {text:runs.map(run => typeof run === 'string' ? run : run.text).join(''), richText:value(runs)};