@openpresentation/opf-pptx 0.11.9 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -3
- package/dist/annotation-export.js +83 -0
- package/dist/annotation-provenance.js +246 -0
- package/dist/body-text-import.js +4 -1
- package/dist/chart-options.js +276 -0
- package/dist/chartex.js +42 -11
- package/dist/code-provenance.js +34 -10
- package/dist/content-topology.js +259 -52
- package/dist/document-provenance.js +77 -5
- package/dist/index.d.ts +17 -1
- package/dist/index.js +304 -67
- package/dist/logo-provenance.js +4 -3
- package/dist/master-themes.js +49 -0
- package/dist/metric-provenance.js +6 -3
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/package-fonts.js +6 -4
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +150 -17
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# OPF PPTX
|
|
2
2
|
|
|
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).
|
|
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
|
|
|
@@ -136,6 +138,7 @@ The first importer is mechanical and schema-compatible:
|
|
|
136
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.
|
|
137
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).
|
|
138
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.
|
|
139
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.
|
|
140
143
|
- Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
|
|
141
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.
|
|
@@ -201,7 +204,7 @@ The exporter reads the presentation `language` through core `resolveScriptFonts(
|
|
|
201
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.
|
|
202
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.
|
|
203
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.
|
|
204
|
-
- 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
|
|
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.
|
|
205
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.
|
|
206
209
|
|
|
207
210
|
Charts keep their `c:lang` and left-to-right label paragraphs. The embedded chart workbook is untouched.
|
|
@@ -218,6 +221,20 @@ Exports report `language-unresolved` when the document's language cannot be reso
|
|
|
218
221
|
|
|
219
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.
|
|
220
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
|
+
|
|
221
238
|
## Runtime Policy
|
|
222
239
|
|
|
223
240
|
The package runtime must stay local and deterministic:
|
|
@@ -334,7 +351,9 @@ Node 20/24 tests and the 126-deck / 805-slide structural corpus pass. This prove
|
|
|
334
351
|
|
|
335
352
|
### Pattern and picture backgrounds (FF-25)
|
|
336
353
|
|
|
337
|
-
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.
|
|
338
357
|
|
|
339
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`.
|
|
340
359
|
|
|
@@ -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
|
+
}
|
package/dist/body-text-import.js
CHANGED
|
@@ -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
|
-
|
|
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)};
|