@openpresentation/opf-pptx 0.11.6 → 0.11.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # OPF PPTX
2
2
 
3
- Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
3
+ Version 0.11.7 requires `@openpresentation/opf` ^0.11.4 and exports the design fields natively (cover and section logos, header and footer logos, picture bullets, the accent font), writes every chart's text at the size the preview draws, round-trips the slide content structure, sections, extensions, assets and brand images through PPTX provenance (a fresh export of a plain deck re-imports in its authored form, so read a root payload field before `blocks[0]`), writes slide sections as PowerPoint's section list, and degrades an SVG image to the preview's placeholder instead of aborting; install it with renderer 0.11.9 and editor 0.10.6 so export and preview resolve the same core (no API removed). Version 0.11.6 exports the treemap, histogram, pareto, box-and-whisker, waterfall and funnel charts as native chartex parts by default (`toPptx({chartex: 'auto'})`; `world` stays a clustered column with `chart-data-adapted`; pass `chartex: 'fallback'` for the previous output) and gives chartex text the deck's label colour and font (no API removed). Version 0.11.5 writes the slide tag run as a theme color reference (`a:schemeClr accent1`) where the deck theme holds the primary there (no API change; the color is unchanged). Version 0.11.4 re-imports quote payloads (each native quote line carries an `OPF_QUOTE_V1` tag) and slide-image payloads from an unchanged export, writes the theme's `a:ea` and `a:cs` only for slots a script font is selected for, and requires core `^0.11.3` (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and editor 0.10.4 so export and preview resolve the same core; no API removed). Version 0.11.3 adds the opt-in `toPptx({ chartex: 'native' })` export of the seven chartex chart types and always imports chartex charts (no API removed; the default output is unchanged). Version 0.11.2 exports the classic chart types as native chart constructs and writes named and default table colours, text pairs and hyperlink colours as theme references (no API or dependency change; resolved colours are unchanged). Version 0.11.1 exports `design.watermark` as a native picture (it re-imports) and embeds byte-identical media parts once, with no API or dependency change. Version 0.11.0 requires core `^0.11.2` (cover slides are centered between header and footer furniture) and the optional `@openpresentation/opf-render` peer `^0.11.0`; install them together so export and preview resolve the same core. Version 0.10.0 required core `^0.11.1` and the peer `^0.10.0`. It adds native slide-number and date fields, socials, slide-image treatments, theme color schemes, script-slot fonts and RTL, re-import of design and metadata references, native underline and current-body formatting on import, and UTC-canonical `zipDate` (see the changelog for the intentional contract changes).
4
4
 
5
5
  Version 0.9.1 kept core `^0.11.0` and raised the optional `@openpresentation/opf-render` peer to `^0.9.0` so it coexists with editor 0.8.0. ColorRef / `variables` on content colors still hex-resolve through core `resolveColorRef()` before PptxGenJS `srgbClr` export. Unrecognized run colors such as `color:'invalid'` still validate and fall back to the theme text color. Native DrawingML `schemeClr` and theme `clrScheme` writes, and native `p:hf` headers/footers, are not in this release. Import still flattens theme colors to hex. Metric, quote and timeline layout placeholders and the corrected text-bullet contract from 0.8.1 are retained.
6
6
 
@@ -55,6 +55,8 @@ await fs.promises.writeFile("quarterly-review.pptx", bytes);
55
55
 
56
56
  `toPptx` returns a `Uint8Array` containing a PowerPoint-openable `.pptx`. It does not fetch remote assets. Data URI images and local paths can be embedded directly; hosts that need private asset loading should pass `imageResolver(src, context)`. Set `strictAssets: true` to turn unresolved or remote image assets into structured `OPFPptxError` failures instead of editable placeholder boxes.
57
57
 
58
+ Embedded picture bytes must be a PNG, JPEG, GIF or WebP. An SVG (or bytes that are no readable image) has no raster for PowerPoint, so, like an unresolved asset, it exports what the preview shows: the "Image unavailable" placeholder (nothing for a watermark, the background colour for a background image) and one `unresolved-asset` diagnostic with `reason: "unsupported-format"`; `strictAssets` throws `unsupported-image-dimensions`. To embed the artwork, return a raster from `imageResolver`; the exporter has no rasterizer of its own, and opf-render's `svgToPng(svg)` is the usual one: have `imageResolver` return `{data: await svgToPng(svgText), mediaType: "image/png"}` for an SVG source (the SVG text is yours to read from the data URI or file).
59
+
58
60
  `fromPptx` parses an existing `.pptx` buffer locally and returns an OPF document that validates with `@openpresentation/opf`:
59
61
 
60
62
  ```js
@@ -82,7 +84,8 @@ The first exporter keeps the public API stable while using `pptxgenjs` internall
82
84
  - Root payloads, `blocks[]`, and promoted region keys become editable slide objects in deterministic regions. Promoted keys use the OPF 3x3 region vocabulary (`top`, `middle`, `bottom`, `left`, `center`, `right`).
83
85
  - Text, lists, metrics, quotes, timelines, code, tables, and inline-data charts are emitted as editable PowerPoint text, table, and chart objects. Content ColorRef values (hex, scheme slots/roles, and `var:<id>`) resolve through core `resolveColorRef()`. Slot and role names become native `a:schemeClr` references when the exported theme holds that exact color; hex, `var:<id>` and slide-override colors stay `a:srgbClr` (see [Theme color scheme](#theme-color-scheme)). Each classic chart type the core catalog keeps (one per Aspose.Slides `ChartType`: clustered/stacked/100% stacked column and bar, line and stacked line with or without markers, area/stacked/100% area, pie, doughnut, scatter with markers, radar/radar with markers/filled radar) is written as its exact native construct; deprecated core ids export as their replacement; other ids keep the previous best-effort mapping. The chartex types treemap, histogram, pareto, box & whisker, waterfall and funnel are written as native Office 2016 chartex parts (confirmed in desktop PowerPoint on 2026-09-30; see [Chartex charts](#chartex-charts-ff-22b)); the map (`world`) stays a clustered column chart by default and reports `chart-data-adapted` (`chartex-fallback`) until PowerPoint accepts its regionMap part (`chartex: "native"` writes it anyway, `chartex: "fallback"` writes clustered columns for every chartex id). A pie or doughnut chart, or a one-series chartex construct, plots only its first series and reports `chart-data-adapted` (`series-dropped`) when it has more.
84
86
  - A chart whose data is one column of values (a histogram or dot plot) has no category column. A histogram or Pareto chart bins the values itself (PowerPoint's histogram with an explicit automatic bin count, no diagnostic) and re-imports as authored; any other chart type plots the values against their row numbers and reports `chart-data-adapted` (`row-numbers`). With `chartex: "fallback"` a histogram is binned into equal-width bins (Sturges' count, at most 50) and written as a column chart of the counts (`histogram-binned`; the binned counts do not round-trip). Cells parse as in every chart ("12%", "$5" and "1,234" count) and cells that hold no number are skipped, not plotted as 0. Chart data that cannot be plotted (no numbers, no rows, or an external data source), an empty table and unsupported content keep a placeholder frame with a plain-language description (never a dump of the source data or URLs) and report `chart-data-unplottable` or `content-placeholder` with a `reason`; content is never dropped without a diagnostic.
85
- - Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path.
87
+ - Image assets are embedded only when supplied as data URIs, local paths, or host-resolved bytes/paths. Remote asset URLs are never fetched by the runtime path. A picture's native description is the authored `alt` (never a stand-in such as `preencoded.png` or the file path), and a distinct asset `title` is the picture's native `title`; `fromPptx` reads both back.
88
+ - A chart cell that holds no number (`null`, an empty or non-numeric string, a boolean) is a gap in the native chart: its cache has no point at that row (`c:ptCount` keeps the row count), its workbook cell is blank, and it re-imports as `null`. An actual zero stays zero.
86
89
  - ZIP entries, generated chart/workbook part names, core-property timestamps, and nested chart workbook timestamps are normalized for reproducible bytes. [Export determinism](docs/export-determinism.md) lists the tested time zone, locale, clock and host-font controls and each known variance (WebP conversion, font registries, ICU segmentation, timestamps).
87
90
  - The package names only the document's chosen fonts. Chart text (data labels, axes, legend, titles) uses the chart slide's body font in `latin`/`ea`/`cs`; each embedded chart workbook uses the same fonts in its styles and theme; run `pitchFamily` follows the font scheme type (monospace is fixed pitch, serif is roman); and `docProps/app.xml` "Fonts Used" lists the fonts the package actually uses. The theme keeps PptxGenJS's per-script supplements (`THEME_SCRIPT_SUPPLEMENTS`) and empty `ea`/`cs` slots unless a script font was selected for that slot (see [Languages, right-to-left text and script fonts](#languages-right-to-left-text-and-script-fonts)).
88
91
  - `checkPptxTypefaces(bytes, {fonts, monospace, themeScripts})` (optional `themeScripts: {major: {ea, cs}, minor: {ea, cs}}` names the East Asian / complex-script families the author selected; a selected theme slot that is empty or different is reported as `theme-script-slot`, an unselected one may stay empty) inventories every `typeface`, workbook font name and "Fonts Used" entry in every XML part, including nested packages, and reports each font outside that policy. `inventoryPptxTypefaces()` returns the raw inventory.
@@ -128,6 +131,8 @@ The first importer is mechanical and schema-compatible:
128
131
  - 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.
129
132
  - Unknown non-text shapes and unsupported graphic frames become editable text fallback blocks instead of failing the import.
130
133
  - 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
+ - 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
+ - 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/`).
131
136
 
132
137
  There is no AI classification pass in the OSS runtime. Hosts can run optional cleanup or semantic remapping after `fromPptx` returns.
133
138
 
@@ -248,6 +253,15 @@ Slide-image treatments export from core's normalized geometry as native DrawingM
248
253
 
249
254
  A deck or slide watermark (`design.watermark`) exports as one native picture named `OPF watermark` per slide, after the slide image and its overlay, if any, and before all content (the preview's paint order, so a background slide image lies beneath the watermark), at the preview's frame: the centered 40% by 40% box at 30%/30% of the slide, fitted without cropping. Its opacity is `a:alphaModFix` (the object form's `opacity`, otherwise the preview default 0.08), and its alt text is the asset's `alt`, otherwise `Watermark`. `design.watermark = false` on a slide exports none. An unchanged export imports back as `design.watermark` (at deck level when every slide carries the same one); an edited picture stays ordinary content and reports `invalid-watermark-provenance`. An unresolved image reports `unresolved-asset` and an object with no `src` reports `watermark-not-exported`.
250
255
 
256
+ ### Brand assets, picture bullets and the accent font (spec-gap closure A)
257
+
258
+ These design fields need the core that composes them (`resolveLogo`, `geometry.logo`, `item.bulletImage`, `fontScheme.accent`; core 0.11.4 or the coordinated source). On core 0.11.3 the export carries none of them and is byte-identical to the previous output. Each one is drawn at the box core composes, which the preview draws too (see core `docs/design-resolution.md`, "Brand assets and layout hints").
259
+
260
+ - **Cover and section logo** (`design.logo`, a slide's own `design.logo`, else the primary organization's `logo`). One native picture named `OPF logo`, after the watermark and before the content, fitted without cropping into core's `geometry.logo` box (56 px high at 720 px, up to four times as wide), anchored at the box's left edge and vertically centered, exactly the preview's `xMinYMid meet`. Its alt text is the asset's `alt`, otherwise `Logo`. A LogoSet picks its variant from the slide background (a dark background takes the light variants); the exporter's dark test is the one that already chooses the text colour. An unresolved or unreadable logo draws the preview's "Image unavailable" panel in the box and reports `unresolved-asset` at the logo's path (`strictAssets` throws). Content slides never get one. The picture carries an `OPF_LOGO_V1` tag (slide, source path, variant, the exact picture properties): an unchanged export consumes the picture on import (it is not content; `design.logo` itself returns from the document tag), and an edited, ambiguous or damaged one stays an ordinary picture and reports `invalid-logo-provenance`. When nothing else restored a logo (an export with `provenance: false`, or with `references-only`, which stores no sources), the consumed picture's own image becomes `design.logo` (the slide's own for a slide-level logo), so the logo is never lost; it is a single image, not the LogoSet. A PPTX with no OPF tags imports the picture as an ordinary image block and invents no `design.logo`.
261
+ - **Header and footer `logo: true`.** A generated image part in the part's box (the icon variant), exported like an `image` part with the furniture picture tag replaced by an `OPF_LOGO_V1` tag with role `furniture`. The slide's furniture manifest lists it under a separate `logos` key (`{kind, zone, drawn}`), never in `parts` or `definitions`, because every released importer validates those two strictly. It re-imports as `logo: true`, never as an `image` data URI; without a resolvable logo core reports `unresolved-content` at `<zone>.logo`, nothing is drawn and `logo: true` still returns (`drawn: false`).
262
+ - **Picture bullets** (`design.listBullet: image`, resolved to the icon logo by core as `item.bulletImage`). Every entry's first line carries `<a:buSzPct val="100000"/><a:buBlip><a:blip r:embed=…/></a:buBlip>` in place of the character bullet, so the marker is the text size, like the preview's square marker. PptxGenJS embeds the icon once per slide (a picture named `OPF bullet image`); packaging removes that picture and keeps the media part and its relationship for the `a:buBlip` elements. Marker indent and text positions are those of the character bullets. An icon that cannot be embedded keeps the character bullets and reports `unresolved-asset` once per slide at the logo's path. Import recognizes `a:buBlip` paragraphs as list paragraphs; `design.listBullet` returns from the stored design hint (a PPTX without tags has no hint). PowerPoint shows a picture bullet at the picture's own aspect ratio; use a square icon for the preview's square marker. The released 0.11.6 importer does not know `a:buBlip` and imports such a list as plain text lines.
263
+ - **`fontScheme.accent`.** Core resolves the accent role for the slide tag (eyebrow) and the quote body, so their runs carry the accent typeface in `a:latin`, `a:ea` and `a:cs` with the catalog's pitch family when the family is known; theme major/minor fonts, every other run and the theme are unchanged. The accent family appears in `docProps/app.xml` "Fonts Used" like any other used font; `checkPptxTypefaces` callers list it in `options.fonts`. `design.fontScheme` (with `accent`) returns from the document tag.
264
+
251
265
  An unchanged export imports every treatment field back. An edited overlay drops only the overlay and reports `invalid-slide-image-provenance`. An edited picture or effect drops the slide image. See core `docs/image-treatments.md` for the vocabulary and the unsupported effects: blur, shadows, soft edges and background removal. PowerPoint's luminance weights for grayscale and duotone are unverified natively.
252
266
 
253
267
  Tests compare SVG/native fit and crop geometry across nine synthetic raster fixtures and cover all eight JPEG orientations. Keynote 14.4 visually preserves proportions for wide/tall fit/crop and displays all eight orientations correctly. This does not establish Microsoft PowerPoint raster parity or WebP support in every Office version.
@@ -271,7 +285,7 @@ Node conversion lazily loads the pinned open-source Sharp dependency and require
271
285
 
272
286
  ## Native background fills
273
287
 
274
- Fixed solid and linear-gradient backgrounds now export as native slide fills, keeping the background editable without rasterizing slide content. Deck defaults, inline theme overrides and per-slide overrides are resolved before export. Solid opacity, gradient stop colors/positions and combined color/background alpha are preserved. Empty and single-stop gradients follow the SVG preview's transparent/solid behavior; descending stop positions clamp to the preceding stop.
288
+ Fixed solid and linear-gradient backgrounds now export as native slide fills, keeping the background editable without rasterizing slide content. Deck defaults, inline theme overrides and per-slide overrides are resolved before export. Solid opacity, gradient stop colors/positions and combined color/background alpha are preserved. Solid, gradient-stop and pattern colors are ColorRefs, resolved like table fills and run colors: a `var:` variable or a colour-scheme slot or role name (`accent2`, `primary`) paints its colour, and a slot the deck theme holds exactly is written as `a:schemeClr`. The slide's default text contrast follows the resolved colour. Empty and single-stop gradients follow the SVG preview's transparent/solid behavior; descending stop positions clamp to the preceding stop.
275
289
 
276
290
  Diagonal gradients require a coordinate conversion: the preview uses an SVG object-bounding-box gradient, while native unscaled DrawingML angles use slide coordinates. Export converts both the physical gradient direction and stop interval. Tests compare 990 sample positions from serialized SVG/native properties across 33 gradients and three aspect ratios, plus solid opacity, inheritance, native edits and repeated imports/exports. Integer native angles/positions introduce small rounding differences. The mapping follows the [DrawingML linear-gradient angle definition](https://learn.microsoft.com/en-us/dotnet/api/documentformat.openxml.drawing.lineargradientfill?view=openxml-3.0.1).
277
291
 
@@ -33,21 +33,23 @@ export const nativePatternPreset = preset => presetPatterns.has(preset) ? preset
33
33
  // The SVG preview paints the pattern background color and foreground marks,
34
34
  // defaulting to white and the slide text color. Other engine-defined presets
35
35
  // have no DrawingML equivalent; like the preview, only their background color remains.
36
- // `scheme(reference, hex)` names the theme color for an authored slot/role
37
- // reference whose drawn color the deck theme holds exactly (FF-24); pattern
38
- // colors then become a:schemeClr, keeping any background opacity as alpha.
39
- export function nativeBackgroundFill(background, {width, height}, fallback = 'FFFFFF', foreground = '000000', scheme = () => undefined) {
36
+ // `resolve(reference)` turns a ColorRef (a `var:` variable, a colour-scheme slot or
37
+ // role name) into a hex colour, as for table fills and run colours; literals pass
38
+ // through. `scheme(reference, hex)` names the theme color for an authored slot/role
39
+ // reference whose drawn color the deck theme holds exactly (FF-24); solid, gradient
40
+ // and pattern colors then become a:schemeClr, keeping any background opacity as alpha.
41
+ export function nativeBackgroundFill(background, {width, height}, fallback = 'FFFFFF', foreground = '000000', scheme = () => undefined, resolve = value => value) {
40
42
  if (typeof background === 'string' && /^#[\da-f]{3}(?:[\da-f]{3}(?:[\da-f]{2})?)?$/i.test(background)) background = {type: 'solid', color: background};
41
43
  if (!background || typeof background !== 'object') return null;
42
44
  const opacity = background.opacity ?? 1;
43
- if (background.type === 'solid' || background.type === 'theme') return `<a:solidFill>${colorXml(background.type === 'theme' ? fallback : background.color, opacity, fallback)}</a:solidFill>`;
45
+ const paint = (value, base) => {
46
+ const hex = resolve(value), c = color(hex, base), themeValue = c.alpha === 1 ? scheme(value, c.hex) : undefined;
47
+ if (!themeValue) return colorXml(hex, opacity, base);
48
+ const alpha = Math.round(clamp(opacity) * 100000);
49
+ return `<a:schemeClr val="${themeValue}">${alpha === 100000 ? '' : `<a:alpha val="${alpha}"/>`}</a:schemeClr>`;
50
+ };
51
+ if (background.type === 'solid' || background.type === 'theme') return `<a:solidFill>${background.type === 'theme' ? colorXml(fallback, opacity, fallback) : paint(background.color, fallback)}</a:solidFill>`;
44
52
  if (background.type === 'pattern') {
45
- const paint = (value, base) => {
46
- const c = color(value, base), themeValue = c.alpha === 1 ? scheme(value, c.hex) : undefined;
47
- if (!themeValue) return colorXml(value, opacity, base);
48
- const alpha = Math.round(clamp(opacity) * 100000);
49
- return `<a:schemeClr val="${themeValue}">${alpha === 100000 ? '' : `<a:alpha val="${alpha}"/>`}</a:schemeClr>`;
50
- };
51
53
  const pattern = background.pattern ?? {}, back = paint(pattern.backgroundColor, 'FFFFFF');
52
54
  const preset = nativePatternPreset(pattern.preset);
53
55
  if (!preset) return `<a:solidFill>${back}</a:solidFill>`;
@@ -56,7 +58,7 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
56
58
  if (background.type !== 'gradient') return null;
57
59
  const stops = background.gradient?.stops ?? [];
58
60
  if (!stops.length) return '<a:noFill/>';
59
- if (stops.length === 1) return `<a:solidFill>${colorXml(stops[0].color, opacity, fallback)}</a:solidFill>`;
61
+ if (stops.length === 1) return `<a:solidFill>${paint(stops[0].color, fallback)}</a:solidFill>`;
60
62
  const radians = turn(background.gradient?.angle ?? 0) * Math.PI / 180;
61
63
  const c = Math.cos(radians), s = Math.sin(radians), span = Math.abs(c) + Math.abs(s);
62
64
  const angle = Math.round(turn(Math.atan2(s / height, c / width) * 180 / Math.PI) * 60000) % 21600000;
@@ -65,7 +67,7 @@ export function nativeBackgroundFill(background, {width, height}, fallback = 'FF
65
67
  // SVG clamps a descending stop to the preceding position.
66
68
  prior = Math.max(prior, stop.position);
67
69
  const position = Math.round(((prior - .5) / span + .5) * 100000);
68
- return `<a:gs pos="${position}">${colorXml(stop.color, opacity, fallback)}</a:gs>`;
70
+ return `<a:gs pos="${position}">${paint(stop.color, fallback)}</a:gs>`;
69
71
  }).join('');
70
72
  return `<a:gradFill rotWithShape="0"><a:gsLst>${nativeStops}</a:gsLst><a:lin ang="${angle}" scaled="0"/></a:gradFill>`;
71
73
  }
package/dist/chartex.js CHANGED
@@ -121,9 +121,9 @@ function layoutProperties(spec, series, hasCategories) {
121
121
  // the style's tx1 grey on a dark theme), so every axis, data label set and
122
122
  // legend carries the deck's label colour and font explicitly, like the classic
123
123
  // chart path writes into every c:txPr.
124
- function textProperties(labelColor, font) {
124
+ function textProperties(labelColor, font, size) {
125
125
  const face = escapeXml(font);
126
- return `<cx:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="900">${solidFill(labelColor)}<a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/></a:defRPr></a:pPr><a:endParaRPr lang="en-US"/></a:p></cx:txPr>`;
126
+ return `<cx:txPr><a:bodyPr/><a:lstStyle/><a:p><a:pPr><a:defRPr sz="${size}">${solidFill(labelColor)}<a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/></a:defRPr></a:pPr><a:endParaRPr lang="en-US"/></a:p></cx:txPr>`;
127
127
  }
128
128
 
129
129
  function dataLabels(spec, text) {
@@ -159,7 +159,7 @@ function axes(spec, gridColor, text) {
159
159
  * layout is PptxGenJS's: Sheet1, headings in row 1, categories in column A and
160
160
  * one series per following column.
161
161
  */
162
- export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette}) {
162
+ export function chartexPartXml({spec, series, hasCategories, number, workbookRelId, fill, labelColor, gridColor, font, palette, textSize}) {
163
163
  const rows = series[0].labels.length;
164
164
  const range = (letter) => `Sheet1!$${letter}$2:$${letter}$${rows + 1}`;
165
165
  const categories = hasCategories
@@ -169,7 +169,7 @@ export function chartexPartXml({spec, series, hasCategories, number, workbookRel
169
169
  const points = entry.values.map((value, row) => value === null || !Number.isFinite(value) ? '' : `<cx:pt idx="${row}">${numberText(value)}</cx:pt>`).join('');
170
170
  return `<cx:data id="${index}">${categories}<cx:numDim type="${spec.dimension}"><cx:f>${range(columnLetters(index + 2))}</cx:f><cx:lvl ptCount="${rows}" formatCode="General">${points}</cx:lvl></cx:numDim></cx:data>`;
171
171
  }).join('');
172
- const text = textProperties(labelColor, font);
172
+ const text = textProperties(labelColor, font, textSize);
173
173
  const plotted = series.map((entry, index) => {
174
174
  const color = palette[index % palette.length];
175
175
  const letter = columnLetters(index + 2);
@@ -214,7 +214,7 @@ const STYLE_ENTRIES = [
214
214
  'dataPointMarkerLayout', 'dataPointWireframe', 'dataTable', 'downBar', 'dropLine', 'errorBar', 'floor', 'gridlineMajor', 'gridlineMinor', 'hiLoLine',
215
215
  'leaderLine', 'legend', 'plotArea', 'plotArea3D', 'seriesAxis', 'seriesLine', 'title', 'trendline', 'trendlineLabel', 'upBar', 'valueAxis', 'wall',
216
216
  ];
217
- export function chartStyleXml({labelColor = '000000', gridColor = '000000', font = 'Aptos'} = {}) {
217
+ export function chartStyleXml({labelColor = '000000', gridColor = '000000', font = 'Aptos', textSize} = {}) {
218
218
  // Text and chrome colours are the deck's label and border colours (what the classic chart path writes), not the
219
219
  // theme's tx1: PowerPoint applies the style part to chartex labels that carry no cx:txPr of their own.
220
220
  const textColor = `<a:srgbClr val="${labelColor}"/>`;
@@ -226,9 +226,9 @@ export function chartStyleXml({labelColor = '000000', gridColor = '000000', font
226
226
  switch (name) {
227
227
  case 'dataPointMarkerLayout': return '<cs:dataPointMarkerLayout symbol="circle" size="5"/>';
228
228
  case 'axisTitle': return entry(name, {fontColor: textColor, size: '1000'});
229
- case 'categoryAxis': case 'valueAxis': case 'seriesAxis': return entry(name, {fontColor: textColor, spPr: faintLine, size: '900'});
230
- case 'chartArea': return entry(name, {mods: 'allowNoFillOverride allowNoLineOverride', spPr: `<a:solidFill><a:schemeClr val="bg1"/></a:solidFill>${faintLine}`, size: '1000'});
231
- case 'dataLabel': case 'dataLabelCallout': case 'dataTable': case 'legend': case 'trendlineLabel': return entry(name, {fontColor: textColor, size: '900'});
229
+ case 'categoryAxis': case 'valueAxis': case 'seriesAxis': return entry(name, {fontColor: textColor, spPr: faintLine, size: textSize});
230
+ case 'chartArea': return entry(name, {mods: 'allowNoFillOverride allowNoLineOverride', spPr: `<a:solidFill><a:schemeClr val="bg1"/></a:solidFill>${faintLine}`, size: textSize});
231
+ case 'dataLabel': case 'dataLabelCallout': case 'dataTable': case 'legend': case 'trendlineLabel': return entry(name, {fontColor: textColor, size: textSize});
232
232
  case 'dataPoint': case 'dataPoint3D': case 'dataPointWireframe': case 'upBar': case 'floor': case 'wall':
233
233
  return entry(name, {fillIdx: '1', fillColor: '<cs:styleClr val="auto"/>', spPr: '<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>'});
234
234
  case 'dataPointLine': case 'dataPointMarker': case 'trendline': case 'seriesLine': case 'hiLoLine': case 'dropLine': case 'leaderLine': case 'errorBar':
@@ -294,7 +294,7 @@ export function attachChartexParts(entries, chartex, parseRelationships) {
294
294
  const base = `ppt/charts/chartEx${number}.xml`;
295
295
  entries[base] = encoder.encode(chartexPartXml({...chart, number, workbookRelId: 'rId1'}));
296
296
  entries[`ppt/charts/_rels/chartEx${number}.xml.rels`] = encoder.encode(chartexRelationshipsXml({workbookTarget: workbook.target, number}));
297
- entries[`ppt/charts/style${number}.xml`] = encoder.encode(chartStyleXml({labelColor: chart.labelColor, gridColor: chart.gridColor, font: chart.font}));
297
+ entries[`ppt/charts/style${number}.xml`] = encoder.encode(chartStyleXml({labelColor: chart.labelColor, gridColor: chart.gridColor, font: chart.font, textSize: chart.textSize}));
298
298
  entries[`ppt/charts/colors${number}.xml`] = encoder.encode(chartColorStyleXml());
299
299
  overrides.push(`<Override PartName="/${base}" ContentType="${CHARTEX_CONTENT_TYPES.chart}"/>`,
300
300
  `<Override PartName="/ppt/charts/style${number}.xml" ContentType="${CHARTEX_CONTENT_TYPES.style}"/>`,
@@ -86,7 +86,7 @@ export function nativeShapeParagraphs(xml, rootElement = 'p:sld') {
86
86
  }
87
87
  if (child['a:pPr'] !== undefined) {
88
88
  level = Number(child[':@']?.lvl ?? 0);
89
- bullet = child['a:pPr'].some(node=>node['a:buChar'] !== undefined || node['a:buAutoNum'] !== undefined);
89
+ bullet = child['a:pPr'].some(node=>node['a:buChar'] !== undefined || node['a:buBlip'] !== undefined || node['a:buAutoNum'] !== undefined);
90
90
  }
91
91
  }
92
92
  return {text,maxFontSize,bullet,level,fields};
@@ -0,0 +1,295 @@
1
+ // Content topology (spec-gap closure P1): the structure of a slide's content
2
+ // survives a PPTX round trip through the OPF_SLIDE_V1 record.
3
+ //
4
+ // PowerPoint has no native counterpart for OPF's content structure: nested
5
+ // groups, promoted regions (`left`, `top:left` ...), a root payload (`text`,
6
+ // `items` ...) versus `blocks`, block ids, block extensions and group
7
+ // composition. Import rebuilds flat blocks from the native shapes in reading
8
+ // order. The topology record stores the structure and the reference-pixel box
9
+ // of every leaf, taken from the same `composeSlide` geometry the exporter draws
10
+ // from. Import matches each imported block to the smallest stored leaf whose
11
+ // box contains the block's native bounds and rebuilds the authored form. The
12
+ // record holds ids, extensions, composition and boxes only: never text, images
13
+ // or payload values, which the native shapes supply.
14
+ //
15
+ // Record (stored as `content` in OPF_SLIDE_V1, `full` mode only):
16
+ //
17
+ // Topology = {form: 'root', field, box?} // one root payload on the slide
18
+ // | {form: 'root', fields: [{field, box?}]} // root shorthand with several payloads
19
+ // | {form: 'blocks', blocks: Node[]}
20
+ // | {form: 'regions', regions: {[key]: Node}} // key = promoted region key
21
+ // Node = {t: 'group', id?, ext?, comp?, typed?, blocks: Node[]}
22
+ // | {t: 'leaf', k, id?, ext?, typed?, box?} // box = [x, y, w, h] reference px, 1 decimal
23
+ // typed = true when the authored block spelled out its `type`
24
+ //
25
+ // Tags are untrusted input: every field is type-, count- and depth-checked
26
+ // before use, and the rebuilt slide still has to validate with the document.
27
+
28
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
29
+ const clone = value => value === undefined ? undefined : JSON.parse(JSON.stringify(value));
30
+
31
+ export const CONTENT_KINDS = Object.freeze(['text', 'list', 'image', 'video', 'chart', 'table', 'code', 'metric', 'quote', 'timeline']);
32
+ export const ROOT_PAYLOAD_FIELDS = Object.freeze(['text', 'items', 'bullets', 'image', 'video', 'chart', 'table', 'code', 'metric', 'quote', 'timeline']);
33
+ const HORIZONTAL = ['left', 'center', 'right', 'left+center', 'center+right', 'left+center+right'];
34
+ const VERTICAL = ['top', 'middle', 'bottom', 'top+middle', 'middle+bottom', 'top+middle+bottom'];
35
+ export const REGION_KEYS = Object.freeze([...HORIZONTAL, ...VERTICAL, ...VERTICAL.flatMap(row => HORIZONTAL.map(column => `${row}:${column}`))]);
36
+ const REGION_KEY_PATTERN = /^[a-z]+(\+[a-z]+)*(:[a-z]+(\+[a-z]+)*)?$/;
37
+ const REGION_KEY_SET = new Set(REGION_KEYS);
38
+ // Groups nest at most this deep (a leaf inside three groups). Deeper structures
39
+ // are valid OPF but are not stored; the slide then imports as flat blocks.
40
+ export const MAX_GROUP_DEPTH = 3;
41
+ export const MAX_NODES = 256;
42
+ // Ids are any schema string up to this length (the empty string included); a
43
+ // longer id leaves the slide's topology unstored, and import rejects it the same way.
44
+ export const MAX_ID_LENGTH = 256;
45
+ // Native bounds may sit this far outside a stored leaf box (reference px).
46
+ export const BOX_TOLERANCE = 3;
47
+
48
+ const kindOfField = field => field === 'items' || field === 'bullets' ? 'list' : field;
49
+ const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
50
+ const round1 = value => Math.round(value * 10) / 10;
51
+
52
+ function blockKind(block) {
53
+ if (typeof block.type === 'string' && block.type !== 'group') return CONTENT_KINDS.includes(block.type) ? block.type : null;
54
+ for (const field of ROOT_PAYLOAD_FIELDS) if (block[field] !== undefined) return kindOfField(field);
55
+ return null;
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Export
60
+
61
+ /**
62
+ * The topology of `slide`'s content with leaf boxes from `items` (the
63
+ * `composeSlide` geometry items of that slide). Returns undefined when the
64
+ * slide has no content, or when its structure cannot be represented (an
65
+ * unknown payload, groups nested deeper than MAX_GROUP_DEPTH, too many nodes);
66
+ * `report(reason)` then names why.
67
+ */
68
+ export function contentTopology(slide, items, slideIndex, report = () => {}) {
69
+ if (!object(slide)) return undefined;
70
+ const boxes = new Map();
71
+ for (const item of items ?? []) if (typeof item?.path === 'string' && object(item.box)) boxes.set(item.path, item.box);
72
+ const base = `slides.${slideIndex}`;
73
+ let nodes = 0;
74
+ const fail = reason => { throw new TopologyError(reason); };
75
+ const leafBox = path => {
76
+ for (const field of ROOT_PAYLOAD_FIELDS) {
77
+ const box = boxes.get(`${path}.${field}`);
78
+ if (box) return [box.x, box.y, box.width, box.height].map(round1);
79
+ }
80
+ return undefined;
81
+ };
82
+ const identity = (block, node, path) => {
83
+ if (typeof block.id === 'string') {
84
+ if (block.id.length > MAX_ID_LENGTH) fail(`${path}.id is longer than ${MAX_ID_LENGTH} characters`);
85
+ node.id = block.id;
86
+ }
87
+ if (object(block.extensions)) node.ext = clone(block.extensions);
88
+ if (typeof block.type === 'string') node.typed = true;
89
+ return node;
90
+ };
91
+ const node = (block, path, depth) => {
92
+ if (!object(block)) fail(`${path} is not a content block`);
93
+ if (++nodes > MAX_NODES) fail(`the slide has more than ${MAX_NODES} content nodes`);
94
+ if (isGroup(block)) {
95
+ if (depth >= MAX_GROUP_DEPTH) fail(`groups nest deeper than ${MAX_GROUP_DEPTH} levels`);
96
+ const group = identity(block, {t: 'group'}, path);
97
+ if (object(block.composition)) group.comp = clone(block.composition);
98
+ group.blocks = (Array.isArray(block.blocks) ? block.blocks : []).map((child, index) => node(child, `${path}.blocks.${index}`, depth + 1));
99
+ return group;
100
+ }
101
+ const kind = blockKind(block);
102
+ if (!kind) fail(`${path} has no known content payload`);
103
+ const leaf = identity(block, {t: 'leaf', k: kind}, path);
104
+ const box = leafBox(path);
105
+ if (box) leaf.box = box;
106
+ return leaf;
107
+ };
108
+ try {
109
+ // Core precedence: promoted regions win over blocks, blocks over a root payload.
110
+ const regionKeys = Object.keys(slide).filter(key => REGION_KEY_SET.has(key) && object(slide[key]));
111
+ if (regionKeys.length) return {form: 'regions', regions: Object.fromEntries(regionKeys.map(key => [key, node(slide[key], `${base}.${key}`, 0)]))};
112
+ if (Array.isArray(slide.blocks)) {
113
+ if (!slide.blocks.length) return undefined;
114
+ return {form: 'blocks', blocks: slide.blocks.map((block, index) => node(block, `${base}.blocks.${index}`, 0))};
115
+ }
116
+ // Root shorthand: one payload is the common case; several payload fields
117
+ // on one slide compose as one item each (`slides.N.<field>`).
118
+ const fields = ROOT_PAYLOAD_FIELDS.filter(name => slide[name] !== undefined).map(field => {
119
+ const box = boxes.get(`${base}.${field}`);
120
+ return box ? {field, box: [box.x, box.y, box.width, box.height].map(round1)} : {field};
121
+ });
122
+ if (!fields.length) return undefined;
123
+ if (fields.length === 1) return {form: 'root', ...fields[0]};
124
+ return {form: 'root', fields};
125
+ } catch (error) {
126
+ if (!(error instanceof TopologyError)) throw error;
127
+ report(error.message);
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ class TopologyError extends Error {}
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Import: validation
136
+
137
+ const validBox = value => value === undefined || (Array.isArray(value) && value.length === 4 && value.every(number => typeof number === 'number' && Number.isFinite(number) && Math.abs(number) < 1e7));
138
+ const validId = value => value === undefined || (typeof value === 'string' && value.length <= MAX_ID_LENGTH);
139
+ const validField = value => object(value) && ROOT_PAYLOAD_FIELDS.includes(value.field) && validBox(value.box);
140
+ /** The root payload leaves of a root-form record: [{field, box?}]. */
141
+ export const rootFields = topology => topology.fields ?? [{field: topology.field, box: topology.box}];
142
+
143
+ /** Throws when `value` is not a well-formed topology record. Returns it otherwise. */
144
+ export function validateTopology(value) {
145
+ if (!object(value)) throw Error('Invalid content topology.');
146
+ let nodes = 0;
147
+ const node = (item, depth) => {
148
+ if (!object(item)) throw Error('Invalid content node.');
149
+ if (++nodes > MAX_NODES) throw Error(`Content topology has more than ${MAX_NODES} nodes.`);
150
+ if (!validId(item.id)) throw Error('Invalid content node id.');
151
+ if (item.ext !== undefined && !object(item.ext)) throw Error('Invalid content node extensions.');
152
+ if (item.typed !== undefined && item.typed !== true) throw Error('Invalid content node type flag.');
153
+ if (item.t === 'group') {
154
+ if (depth >= MAX_GROUP_DEPTH) throw Error(`Content groups nest deeper than ${MAX_GROUP_DEPTH} levels.`);
155
+ if (item.comp !== undefined && !object(item.comp)) throw Error('Invalid group composition.');
156
+ if (!Array.isArray(item.blocks)) throw Error('Invalid group blocks.');
157
+ for (const child of item.blocks) node(child, depth + 1);
158
+ return;
159
+ }
160
+ if (item.t !== 'leaf') throw Error('Unknown content node type.');
161
+ if (!CONTENT_KINDS.includes(item.k)) throw Error('Unknown content kind.');
162
+ if (item.comp !== undefined) throw Error('A content leaf has no composition.');
163
+ if (!validBox(item.box)) throw Error('Invalid content box.');
164
+ };
165
+ switch (value.form) {
166
+ case 'root': {
167
+ if (value.fields !== undefined) {
168
+ if (value.field !== undefined || value.box !== undefined) throw Error('Invalid root payload record.');
169
+ if (!Array.isArray(value.fields) || !value.fields.length || value.fields.length > ROOT_PAYLOAD_FIELDS.length || !value.fields.every(validField)) throw Error('Invalid root payload fields.');
170
+ if (new Set(value.fields.map(item => item.field)).size !== value.fields.length) throw Error('Repeated root payload field.');
171
+ return value;
172
+ }
173
+ if (!ROOT_PAYLOAD_FIELDS.includes(value.field)) throw Error('Unknown root payload field.');
174
+ if (!validBox(value.box)) throw Error('Invalid content box.');
175
+ return value;
176
+ }
177
+ case 'blocks':
178
+ if (!Array.isArray(value.blocks) || !value.blocks.length) throw Error('Invalid content blocks.');
179
+ for (const child of value.blocks) node(child, 0);
180
+ return value;
181
+ case 'regions': {
182
+ if (!object(value.regions)) throw Error('Invalid content regions.');
183
+ const keys = Object.keys(value.regions);
184
+ if (!keys.length || keys.length > REGION_KEYS.length) throw Error('Invalid content regions.');
185
+ for (const key of keys) {
186
+ if (!REGION_KEY_PATTERN.test(key) || !REGION_KEY_SET.has(key)) throw Error(`Unknown region key ${key}.`);
187
+ node(value.regions[key], 0);
188
+ }
189
+ return value;
190
+ }
191
+ default:
192
+ throw Error('Unknown content form.');
193
+ }
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Import: rebuild
198
+
199
+ const compatible = (leafKind, kind) => leafKind === kind || (['text', 'list'].includes(leafKind) && ['text', 'list'].includes(kind));
200
+ const containsOrigin = (box, bounds) => bounds.x >= box[0] - BOX_TOLERANCE && bounds.y >= box[1] - BOX_TOLERANCE
201
+ && bounds.x <= box[0] + box[2] + BOX_TOLERANCE && bounds.y <= box[1] + box[3] + BOX_TOLERANCE;
202
+ const contains = (box, bounds) => containsOrigin(box, bounds)
203
+ && bounds.x + bounds.width <= box[0] + box[2] + BOX_TOLERANCE && bounds.y + bounds.height <= box[1] + box[3] + BOX_TOLERANCE;
204
+
205
+ /**
206
+ * Rebuild the authored content form from the imported flat `blocks` and their
207
+ * native `bounds` (reference px, one entry per block, null when unknown).
208
+ * Returns {fields} (slide properties to set: root field, `blocks` or region
209
+ * keys; a `null` value removes the property) and the restored block ids, or
210
+ * {reason} when a block matches no leaf or several blocks land on one leaf.
211
+ * Leaves with no block are dropped: empty payloads export nothing.
212
+ */
213
+ export function rebuildContent(topology, blocks, bounds) {
214
+ if (!Array.isArray(blocks) || !Array.isArray(bounds) || blocks.length !== bounds.length) return {reason: 'the imported blocks carry no native bounds'};
215
+ const leaves = [];
216
+ const collect = item => {
217
+ if (item.t === 'group') { for (const child of item.blocks) collect(child); return; }
218
+ leaves.push({node: item, kind: item.k, box: item.box, matches: []});
219
+ };
220
+ if (topology.form === 'root') for (const item of rootFields(topology)) leaves.push({node: item, field: item.field, kind: kindOfField(item.field), box: item.box, matches: []});
221
+ else if (topology.form === 'blocks') topology.blocks.forEach(collect);
222
+ else Object.values(topology.regions).forEach(collect);
223
+ const kindOf = block => blockKind(block) ?? 'text';
224
+ for (const [index, block] of blocks.entries()) {
225
+ const native = bounds[index];
226
+ if (!object(native)) return {reason: `block ${index} has no native bounds`};
227
+ const kind = kindOf(block);
228
+ const smallest = (a, b) => (a.box[2] * a.box[3]) - (b.box[2] * b.box[3]) || (a.kind === kind ? -1 : 0) - (b.kind === kind ? -1 : 0);
229
+ let candidates = leaves.filter(leaf => leaf.box && compatible(leaf.kind, kind) && contains(leaf.box, native)).sort(smallest);
230
+ // Overflowing content (overflow: 'warn') runs past its box: the lines of a
231
+ // long list or timeline still start inside it, so the origin decides.
232
+ if (!candidates.length) candidates = leaves.filter(leaf => leaf.box && compatible(leaf.kind, kind) && containsOrigin(leaf.box, native)).sort(smallest);
233
+ if (!candidates.length) return {reason: `block ${index} (${kind}) lies in no stored content box`};
234
+ candidates[0].matches.push(index);
235
+ }
236
+ for (const leaf of leaves) {
237
+ if (leaf.matches.length <= 1) continue;
238
+ // A list's native lines can arrive as several bullet blocks when another
239
+ // object interleaves with them in reading order; they rejoin their list.
240
+ if (leaf.kind === 'list' && leaf.matches.every(index => blocks[index]?.type === 'list' && Array.isArray(blocks[index].items))) {
241
+ const merged = {...blocks[leaf.matches[0]], items: leaf.matches.flatMap(index => blocks[index].items)};
242
+ leaf.matches = [leaf.matches[0]];
243
+ leaf.payload = merged;
244
+ continue;
245
+ }
246
+ return {reason: `${leaf.matches.length} blocks lie in one stored content box`};
247
+ }
248
+ const ids = [];
249
+ const payloadOf = leaf => {
250
+ if (!leaf.matches.length) return undefined;
251
+ const payload = clone(leaf.payload ?? blocks[leaf.matches[0]]);
252
+ // The imported block always names its type; the authored one may have left it implicit.
253
+ if (!leaf.node.typed) delete payload.type;
254
+ if (leaf.node.id !== undefined) { payload.id = leaf.node.id; ids.push(leaf.node.id); }
255
+ if (leaf.node.ext !== undefined) payload.extensions = clone(leaf.node.ext);
256
+ return payload;
257
+ };
258
+ const build = item => {
259
+ if (item.t !== 'group') {
260
+ const leaf = leaves.find(entry => entry.node === item);
261
+ return leaf ? payloadOf(leaf) : undefined;
262
+ }
263
+ const children = item.blocks.map(build).filter(Boolean);
264
+ if (!children.length) return undefined;
265
+ const group = item.typed ? {type: 'group'} : {};
266
+ if (item.id !== undefined) { group.id = item.id; ids.push(item.id); }
267
+ if (item.ext !== undefined) group.extensions = clone(item.ext);
268
+ if (item.comp !== undefined) group.composition = clone(item.comp);
269
+ group.blocks = children;
270
+ return group;
271
+ };
272
+ const fields = {blocks: null};
273
+ if (topology.form === 'root') {
274
+ for (const leaf of leaves) {
275
+ const payload = payloadOf(leaf);
276
+ if (payload === undefined) continue;
277
+ // The slide `type` is restored separately; the imported block names any
278
+ // list by `items`, so an authored `bullets` payload takes its key back.
279
+ const {type: _type, ...rest} = payload;
280
+ if (leaf.field === 'bullets' && rest.items !== undefined && rest.bullets === undefined) { rest.bullets = rest.items; delete rest.items; }
281
+ Object.assign(fields, rest);
282
+ }
283
+ return {fields, ids};
284
+ }
285
+ if (topology.form === 'blocks') {
286
+ const rebuilt = topology.blocks.map(build).filter(Boolean);
287
+ if (rebuilt.length) fields.blocks = rebuilt;
288
+ return {fields, ids};
289
+ }
290
+ for (const [key, item] of Object.entries(topology.regions)) {
291
+ const host = build(item);
292
+ if (host !== undefined) fields[key] = host;
293
+ }
294
+ return {fields, ids};
295
+ }