@openpresentation/opf-editor 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +93 -35
  2. package/dist/assets.js +1 -1
  3. package/dist/background-options.d.ts +10 -3
  4. package/dist/background-options.js +38 -13
  5. package/dist/canvas.d.ts +8 -1
  6. package/dist/canvas.js +25 -10
  7. package/dist/catalog-control.js +21 -0
  8. package/dist/catalogs.d.ts +48 -0
  9. package/dist/catalogs.js +217 -0
  10. package/dist/checks.js +9 -7
  11. package/dist/content-actions.d.ts +2 -2
  12. package/dist/content-actions.js +3 -3
  13. package/dist/content-controls.js +9 -9
  14. package/dist/design-controls.d.ts +12 -5
  15. package/dist/design-controls.js +286 -82
  16. package/dist/design-options.d.ts +4 -4
  17. package/dist/design-options.js +6 -26
  18. package/dist/export.d.ts +8 -1
  19. package/dist/export.js +14 -4
  20. package/dist/find-replace.js +1 -0
  21. package/dist/font-gate.js +0 -11
  22. package/dist/galleries.js +32 -67
  23. package/dist/image-crop.js +8 -9
  24. package/dist/image-options.d.ts +45 -0
  25. package/dist/image-options.js +195 -0
  26. package/dist/index.d.ts +26 -8
  27. package/dist/index.js +107 -51
  28. package/dist/json-editor.js +2 -2
  29. package/dist/json-menu.js +2 -2
  30. package/dist/json-options.d.ts +3 -3
  31. package/dist/json-options.js +37 -47
  32. package/dist/layout-handles.js +13 -0
  33. package/dist/layout-placeholders.js +15 -0
  34. package/dist/react.d.ts +1 -0
  35. package/dist/react.js +6 -2
  36. package/dist/review-panel.js +8 -2
  37. package/dist/schema-inspector.js +1 -1
  38. package/dist/schema.js +1 -1
  39. package/dist/slide-manager.js +4 -2
  40. package/dist/slides.d.ts +1 -1
  41. package/dist/slides.js +6 -5
  42. package/dist/svelte.d.ts +1 -0
  43. package/dist/svelte.js +4 -4
  44. package/dist/switches.d.ts +31 -16
  45. package/dist/switches.js +194 -92
  46. package/dist/table-options.d.ts +6 -1
  47. package/dist/table-options.js +43 -0
  48. package/dist/transfer.d.ts +12 -1
  49. package/dist/transfer.js +87 -131
  50. package/package.json +22 -9
package/README.md CHANGED
@@ -2,7 +2,48 @@
2
2
 
3
3
  Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs/roadmap-shaping-20260915.md); it is not part of the published runtime.
4
4
 
5
- The release after 0.12.1 (RR-55) moves the editor onto core 0.14, renderer 0.14 and PPTX 0.14, which ship together: core's one `validate` and `Finding` format, `resolveSlideContext`, and the renderer's `loadFonts()` handle as the `fonts` option of the canvas, the export and `composeSlide`/`paginateSlide`. See `changes/rr-55-short-verb-api.md` for what was renamed or removed (the font gate, `validateOpfDocument`, `resolveSlideFonts`, `runAudit`, the `*Available` checks). Version 0.11.2 adds the slide-size and purpose switches (additive; same requirements as 0.11.0). Version 0.11.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; install it with renderer 0.12.0 and PPTX 0.12.0 so the canvas, preview and export resolve the same core) and ships the new entry points listed in the changelog. Version 0.10.6 changes no editor source: it requires `@openpresentation/opf` ^0.11.4 (the design fields compose, cover tag and subtitle follow the title alignment, picture bullets and furniture images change geometry; install it with renderer 0.11.9 and PPTX 0.11.7 so the canvas, preview and export resolve the same core). Version 0.10.5 changes no editor source: its playground example loads its base faces through the renderer's `extraLazyFonts` (renderer 0.11.7 or later; the editor package itself runs with the optional peer ^0.11.0 as before). Version 0.10.4 changes no editor behavior: it requires `@openpresentation/opf` ^0.11.3 (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and PPTX 0.11.4 so the canvas, preview and export resolve the same core). Version 0.10.3 passes the host's render options (`catalogs`) through the font gate to the registry, so layouts and font schemes that only the host's catalogs know load their faces, and asks the script and vendored-face loaders separately; it keeps the 0.10.2 requirements and gates nothing new on renderers before 0.11.5. Version 0.10.2 adds pointer caret entry on the canvas (one click puts the caret where you click; new `textEntry` option; keyboard entry still selects all) and keeps the 0.10.1 and 0.10.0 requirements. Version 0.10.1 adds the opt-in font gate (`createFontGate`, FF-41) and keeps the 0.10.0 requirements; develop and test it against renderer 0.11.2 and PPTX 0.11.1. Version 0.10.0 requires `@openpresentation/opf` ^0.11.2, renderer ^0.11.0, and PPTX ^0.11.0 (install them together so cover centering resolves the same core everywhere; renderer 0.11.0 also activates the playground's automatic script fonts and lazy Intos fonts). Version 0.9.0 required `@openpresentation/opf` ^0.11.1, renderer ^0.10.0, and PPTX ^0.10.0. Composition and slide transfer fall back to the shared `aptos` font scheme and report unknown scheme ids through `onDiagnostic`; gallery apply keeps every font-scheme role.
5
+ ## Using the editor with opf-render 0.16: what to install (RR-63, unreleased)
6
+
7
+ `@openpresentation/opf-render` is an optional peer of the editor, and from render 0.16 its heavy pieces are optional peers too, so install only what you use. The editor itself draws with `@openpresentation/opf-render/svg` and loads fonts with `/fonts-browser`, which need nothing else. Add:
8
+
9
+ | You use | Also install |
10
+ | --- | --- |
11
+ | The canvas, the preview, SVG downloads in a browser (the host serves its own font files) | nothing |
12
+ | `loadFonts` from `/fonts-node` (a server, a test, a build script such as `npm run build:playground`) | the font packages of the pack: `@expo-google-fonts/roboto` and `roboto-mono` for `base`; also `arimo`, `caladea`, `cousine`, `gelasio`, `tinos` and `noto-sans` for `office`; a script face (`scripts: 'auto'`) needs its `@expo-google-fonts/noto-*` package. A missing one fails with `font-resource-unavailable` and names it |
13
+ | `svgToPng` / `svgToPdf` from the Node entries (`/png`, `/pdf`) | `@resvg/resvg-js` (and `sharp`, `pdf-lib` as the renderer's README says); a missing one fails with `converter-missing` |
14
+ | `exportDeck` PDF (vector), PNG, SVG | nothing: the browser entry `@openpresentation/opf-render/export-browser` imports no converter |
15
+ | `exportDeck` raster PDF (`pdfMode: "raster"`) | `pdf-lib`, imported by the page and passed as the `pdfLib` option (`import * as pdfLib from "pdf-lib"`); without it the export rejects with `converter-missing` |
16
+
17
+ ```js
18
+ const pdfLib = await import("pdf-lib");
19
+ await exportDeck(editor.presentation, { format: "pdf", pdfMode: "raster", pdfLib, fonts });
20
+ ```
21
+
22
+ The playground imports `pdf-lib` for you when its raster PDF choice is used.
23
+
24
+ ## OPF 0.15: catalogs from the host, backgrounds and image blocks (FA-23)
25
+
26
+ The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec: no migration). See `changes/fa-23-opf-0-15.md`.
27
+
28
+ - **Catalogs come from the host.** Core ships no catalog records; the editor library imports no catalog data either. The host registers its catalogs once, on the session, and the editor hands that one list to core (resolution, validation, `embed`, `copySlides`, `updateFromCatalog`), every picker, the canvas renderer and the exports:
29
+
30
+ ```js
31
+ import { createEditorSession, saveDocument } from "@openpresentation/opf-editor";
32
+ import { defaultCatalog, catalogDisplay } from "@openpresentation/opf/catalog"; // the host app's choice, not the library's
33
+
34
+ const editor = createEditorSession(doc, { catalogs: [defaultCatalog, acmeCatalog] }); // the first is the host default
35
+ editor.catalogs; // the frozen list; editor.setCatalogs([...]) replaces it (one "catalogs" event)
36
+ const { document } = saveDocument(editor); // core embed: every referenced record embedded once, renders with no catalog
37
+ ```
38
+
39
+ `mergeCatalogs(...lists)` is the one merge (the session's list first, the first catalog per `source` wins); `catalogsFor(editor, options)` is the list for one call. Pickers list core's `catalogRecords` and write the reference it gives, `id` or `name:id`.
40
+ - **Save and export embed.** `prepareSave`/`saveDocument`, `serializeOpfTransfer` (presentation and slide scope) and `exportDeck` call core's `embed`; the playground's OPF download and PPTX export do too.
41
+ - **Paste** (`prepareOpfImport`, insert mode) uses core's `copySlides`: groups match by `source`, identical records are reused, and the result's `renamed` (`custom-conflict`, `catalog-revision`, `group-name`) and `addedGroups` tell the user what changed.
42
+ - **Update from catalog** is an explicit action: `checkCatalogUpdates(presentation, { catalogs })` lists the embedded records whose catalog record differs, and `applyCatalogUpdate(editor, { refs })` applies only the approved ones as one undoable step. The design panel's `catalog` section runs both and lists the `opf/unresolved-reference` and `opf/undeclared-catalog` findings (`referenceFindings(editor.validation)`).
43
+ - **Editing a catalog's record forks it.** An edit of a layout, theme, colour scheme or font scheme embedded under `catalogs.default` or a named group (from a panel, Properties, Source or any `applyPatch`) moves it into `catalogs.custom` as `<id>-custom` (or the id in `meta.forkIds["<group>:<kind>:<id>"]`), rewrites every reference to it and drops the original, in the same undo step. The change and the session event carry `forked` and a `notice` ("… is now this presentation's own; it no longer receives catalog updates"); approved catalog updates and `moveToCustom` change records in place (`meta.catalogRecordEdit: "in-place"`). `moveToCustom(editor, { group, kind, id }, { id })` is the explicit form, offered for `opf/catalog-record-not-in-source`.
44
+ - **Backgrounds** take the flat image form `{ type: "image", src, alt, fit, focus, opacity, recolor, overlay }` (fits `cover`, `contain`, `stretch`, `tile`) and the image-source shorthand, on the deck and on a slide. **Image blocks** carry their own fit, focus, treatments and placement (`/image-options`, the panel's `image` section): a full-bleed photo is a background, a picture beside the content is a placed image block. `design.imageFit` is the default fit of image blocks.
45
+
46
+ Editor 0.15.0 uses core `^0.15.1` and the optional renderer peer `^0.15.0`. Install renderer and PPTX 0.15 together for the coordinated canvas and export workflow. The shared APIs are core's `validate`, `Finding`, `resolveSlideContext` and the catalog helpers above, and the renderer's `loadFonts()` handle passed as `fonts` to the canvas, export, composition and pagination. Historical version changes are recorded in `CHANGELOG.md`.
6
47
 
7
48
  Version 0.8.0 required `@openpresentation/opf` ^0.11.0, renderer ^0.9.0, and PPTX ^0.9.0. Named ColorRefs (scheme slots, roles, and `var:<id>`) paint through the published renderer and hex-resolve on export. Payload ids share the slide id namespace for pagination and transfer. Native `schemeClr`, theme write, and `p:hf` remain out of scope.
8
49
 
@@ -22,7 +63,7 @@ const control = mountJsonCodeEditor(container, {
22
63
  code: source,
23
64
  label: 'OPF JSON document',
24
65
  lineNumbers: true,
25
- catalogs: loadedCatalogRecords,
66
+ catalogs: editor.catalogs, // the host's Catalog[] (for example [defaultCatalog])
26
67
  onChange: nextSource => receiveDraft(nextSource),
27
68
  onError: error => showError(error),
28
69
  });
@@ -36,7 +77,7 @@ The host owns the source draft, OPF validation, slide preview, persistence and s
36
77
 
37
78
  The control provides indentation, paired quotes/brackets, folding shortcuts, search, undo/redo, JSON syntax diagnostics and explicit formatting (`Cmd/Ctrl+Shift+F`). Enter at the end of a nonempty string array item supplies indentation, quotes and commas. Other Enter positions use ordinary JSON indentation. Source offsets are UTF-16 in the original authored string. Existing CRLF, CR and LF spellings survive ordinary edits and undo; new typed lines use the first existing separator. Paste retains the text delivered by the browser clipboard event. Explicit formatting deliberately standardizes whitespace, preserving JSON values and numeric lexemes.
38
79
 
39
- Click a categorical property/value or press `Ctrl+Space`, `Cmd+Space` or `Alt+Down` to open its choices. Document catalog records override already-loaded records, which override built-ins; the actual schema supplies enums and booleans. Prose and extension fields do not gain menus just because their keys resemble schema fields. External catalog URLs are not fetched. `setCatalogs` replaces the host's loaded context and closes stale choices. Supply `onOptions(left?, top?)` to retain an existing menu UI.
80
+ Click a categorical property/value or press `Ctrl+Space`, `Cmd+Space` or `Alt+Down` to open its choices. Records the document embeds come first, then those of the catalogs the host passes as `catalogs` (`Catalog[]`, the session's registered list); there are no built-in records. The actual schema supplies enums and booleans. Prose and extension fields do not gain menus just because their keys resemble schema fields. External catalog URLs are not fetched. `setCatalogs(list)` replaces the host's catalogs and closes stale choices. Supply `onOptions(left?, top?)` to retain an existing menu UI.
40
81
 
41
82
  In version 0.7.1, layout choices add blank payloads for missing declared placeholders, preserving existing content. Repeated slots use separate blocks; subtitle slots use `subtitle`, and metric slots use `{ "metric": { "value": "", "label": "" } }`. Charts receive the smallest valid data scaffold rather than invented numeric values. Existing positioned content stays in its regions; additional body slots append inside the last region. Non-layout options still change only their value. Custom menus must apply the full source returned by `replaceFieldOption`, or use `fieldOptionEdit(context, value)` and pass its `from`, `to`, and `insert` to `control.api.replace` as one undoable edit. Always check that `context.source` still matches the editor first. Version 0.7.0 only changed the selected token. Version 0.7.1 requires core 0.10.1 for metric, quote and timeline placeholder contracts.
42
83
 
@@ -52,7 +93,7 @@ Version 0.7.0 uses core 0.10.0 and renderer 0.8.0. Code source/metadata edits pr
52
93
  - Compatibility target: `@openpresentation/opf`
53
94
  - Renderer relationship: built on `@openpresentation/opf-render` trace output
54
95
  - Headless JSON edit bindings for renderer trace attributes
55
- - Structured catalog controls that only commit known catalog IDs
96
+ - Structured catalog controls that only commit catalog references that resolve (in the document or the host's registered catalogs)
56
97
  - JSON Patch state transitions with inverse patches for undo/redo
57
98
  - Optional DOM controls plus React and Svelte bindings in separate embeddable entry points
58
99
  - Dimension switches, safe block conversion and content actions (list levels, grouping, regions, images to design, slide split and merge), design-level options, table style and cell merge as headless APIs (`/switches`, `/block-convert`, `/content-actions`, `/design-options`, `/tables`, `/assets`, `/backgrounds`) and one accessible DOM panel (`/design-controls`)
@@ -90,13 +131,13 @@ import { createCanvasEditor, whenFontsReady } from '@openpresentation/opf-editor
90
131
  import { loadFonts } from '@openpresentation/opf-render/fonts-browser';
91
132
 
92
133
  const fonts = await loadFonts({ faces, scriptBaseUrl, lazyFontsBaseUrl }); // a handle with no faces to load gates nothing
93
- // The canvas hands the handle its current renderOptions (catalogs, ...), so font schemes only your catalogs know resolve in the
94
- // registry too. Defaults for your own calls: loadFonts({ renderOptions: { catalogs } }).
95
- const canvas = createCanvasEditor(container, { presentation, fonts, renderOptions: { catalogs } });
134
+ // The canvas draws with the session's registered catalogs (plus renderOptions.catalogs) and hands the same list to the handle, so
135
+ // font schemes only your catalogs know resolve in the registry too.
136
+ const canvas = createCanvasEditor(container, { editor, fonts });
96
137
  // Other renders of your own: whenFontsReady(fonts, presentation, { ready: draw, failed: showError, loading: showSpinner })
97
138
  ```
98
139
 
99
- `session.composeSlide(index, { fonts })` and `session.paginateSlide(index, { fonts })` take the same handle and resolve the slide's canvas, layout, theme, colour scheme and font families with core's `resolveSlideContext` (slide design, then deck design, then theme, then the default: the order the renderer and the PowerPoint export use), so the editor composes what is drawn and exported. An unknown layout, theme, colour scheme or font-scheme id never throws: the slide composes with no layout record, `minimal`, `cool-horizon` or `aptos`, and `onDiagnostic` hears `unresolved-layout`, `unresolved-theme`, `unresolved-color-scheme` and `unresolved-font-scheme`. The handle's `textMeasurement` is strict: for a document with a script the design font lacks (Japanese under Aptos) pass `{ fonts: { textMeasurement: createScriptTextMeasurement(fonts.textMeasurement, resolveScriptFonts(presentation, { slideIndex })) } }` (`createScriptTextMeasurement` is from `@openpresentation/opf-render/fonts`, `resolveScriptFonts` from `@openpresentation/opf/composition`), as `renderSvg` does internally.
140
+ `session.composeSlide(index, { fonts })` and `session.paginateSlide(index, { fonts })` take the same handle and resolve the slide's canvas, layout, theme, colour scheme and font families with core's `resolveSlideContext` (slide design, then deck design, then theme, then the engine default: the order the renderer and the PowerPoint export use) and the session's registered catalogs, so the editor composes what is drawn and exported. A reference that resolves nowhere never throws: the slide composes automatically or with core's `ENGINE_DEFAULT_*`, and `onDiagnostic` hears each `unresolved-reference` diagnostic. The handle's `textMeasurement` is strict: for a document with a script the design font lacks (Japanese under Aptos) pass `{ fonts: { textMeasurement: createScriptTextMeasurement(fonts.textMeasurement, resolveScriptFonts(presentation, { slideIndex })) } }` (`createScriptTextMeasurement` is from `@openpresentation/opf-render/fonts`, `resolveScriptFonts` from `@openpresentation/opf/composition`), as `renderSvg` does internally.
100
141
 
101
142
  These APIs were introduced in 0.1.0. Version 0.7.0 requires core 0.10.0 and renderer 0.8.0 for the canvas, including shared accepted geometry, styled/merged cells, rich table values, headers and content-aware row heights. See the OPF repository’s `docs/live-editor.md` for setup, the support matrix and roadmap. `pnpm pack:ecosystem` in that repository also prepares local preview tarballs for coordinated development.
102
143
 
@@ -186,7 +227,7 @@ editor.redo();
186
227
 
187
228
  ## Catalog Controls
188
229
 
189
- Catalog helpers commit catalog IDs from the OPF catalog registry instead of accepting freeform incompatible values:
230
+ Catalog helpers commit catalog references (`id` or `name:id`) that resolve in the document or the session's registered catalogs, instead of accepting freeform incompatible values:
190
231
 
191
232
  ```js
192
233
  import {
@@ -194,7 +235,7 @@ import {
194
235
  getCatalogOptions
195
236
  } from "@openpresentation/opf-editor";
196
237
 
197
- getCatalogOptions("themes").map((option) => option.id);
238
+ getCatalogOptions("themes", { editor }).map((option) => option.id); // references, embedded records first
198
239
 
199
240
  const themeSelect = createCatalogSelect(editor, {
200
241
  path: "design.theme",
@@ -205,7 +246,7 @@ const themeSelect = createCatalogSelect(editor, {
205
246
  toolbar.append(themeSelect);
206
247
  ```
207
248
 
208
- Invalid IDs throw before they reach the document. Existing object-form references keep their sibling override fields and replace only `id`.
249
+ References that resolve nowhere throw before they reach the document. Existing object-form references keep their sibling override fields and replace only `id`.
209
250
 
210
251
  ## Dimension switches
211
252
 
@@ -225,18 +266,19 @@ const { patches, presentation } = prepareDimensionSwitch(editor.presentation, "t
225
266
  | --- | --- | --- |
226
267
  | `layouts` | `/slides/N/layout` | Needs `slideIndex`. Adds the blank payloads the layout declares, like the JSON editor's layout choice; existing content stays. |
227
268
  | `color-schemes`, `font-schemes` | `/design/colorScheme`, `/design/fontScheme` | Deck by default, one slide with `slideIndex`. An inline object value is replaced by the bare id, except an accent font in a font scheme object, which stays (`{ id, accent }`). |
228
- | `themes` | `/design/theme` plus the theme's color scheme, font scheme, background and dimensions | Writes the whole bundle, as the gallery's theme snippet does, so fonts follow. `bundle: false` changes only the id. |
229
- | `languages`, `narratives`, `tones`, `audiences` | `/language`, `/narrative`, `/tone`, `/audience` | Catalog ids; `audiences` accepts an id or an array. `narrative` is always a string (a custom narrative is a `catalogs.narratives.records` entry, listed first); slides keep their `beat` links. |
230
- | `backgrounds` | `/design/background` | A background object or a shorthand string (theme slot such as `dark1`, or a hex color). |
269
+ | `themes` | `/design/theme` | The theme's own color scheme, font scheme, background (and, at the deck, size) apply: overrides of those at the switched scope are removed, nothing is copied out of the record. On a slide whose deck sets one of them, the theme's value is written on the slide (its references qualified with the theme's catalog group). `bundle: false` changes only the reference. |
270
+ | `languages` | `/language` | A BCP-47 tag (`en-US`, `ja`) or a language object; `listSwitchOptions` offers core's `LANGUAGES`, labelled from `vocabularies.languages`. |
271
+ | `narratives`, `tones`, `audiences` | `/narrative`, `/tone`, `/audience` | Catalog references; `audiences` accepts one or an array. A custom narrative is a `catalogs.custom.narratives` record; slides keep their `beat` links. |
272
+ | `backgrounds` | `/design/background` | A background object or a shorthand string (theme slot such as `dark1`, a hex color, or an image source: a cover image). |
231
273
  | `headers-footers` | `/design/header`, `/design/footer` | `{header?, footer?}`: an absent field stays, `null` removes it. |
232
- | `image-treatments` | `/design/slideImage`, `/design/imageFill` | `{slideImage?, imageFill?}`, same rule. |
233
- | `socials` | `/speaker/socials/<platform>` or the `organization` | `{platform, handle}` with `owner` and `index`; the owner must exist. |
274
+ | `image-treatments` | `<image block>/fit`, `/focus`, `/shape`, ... | Needs `path` (the image block). The fields of `setImageTreatment`; `null` removes one. |
275
+ | `socials` | `/speaker/socials/<platform>` or the `organization` | `{platform, handle}` with `owner` and `index`; the owner must exist. The platform is a key of core's `SOCIAL_PLATFORMS`. |
234
276
  | `charts` | `<chart owner>/chart/type` | The slide's first chart, or the block named by `path`. The data is kept as it is and only the document schema is checked: switching does not verify that the data suits the new type (`compatibleChartTypes` lists the types the data can use; map types, for example, expect their own data). |
235
277
  | `blocks` | replaces one block, or with `convert: true` moves its content to the new kind | `path` names a complete `blocks/N` block or a slide/region with one content field. The value is a block kind or a block object. See *Content-type conversion*. |
236
278
  | `slide-sizes` | `/design/dimensions` | One of the schema's ten presets (`SLIDE_SIZE_PRESETS`: `16:9`, `4:3`, `16:10`, the social-feed ratios `1:1`, `4:5` and `9:16`, `letter`, `a4`, `widescreen`, `standard`). Deck only (`slideIndex` is refused: a presentation has one slide size). The preview recomposes at the new canvas and the PowerPoint export writes the matching `p:sldSz`; a custom size (inches) is replaced by the preset. `listSwitchOptions` labels each preset with its inches; `currentSwitchValue` reads `design.dimensions`, else the theme's size. A slide's design cannot set `dimensions`, so nothing shadows the deck's size. A deck-scope theme switch still writes its own size; a slide-scope one does not. |
237
- | `purposes` | `/purpose` | A `purposes` catalog id, any free-form goal text (not checked against the catalog), or a Purpose object. `record` adds a gallery item's purpose record inline. Authoring metadata: the preview does not change. |
279
+ | `purposes` | `/purpose` | A `purposes` catalog reference, any free-form goal text (not checked against the catalogs), or a Purpose object. `record` embeds a gallery item's purpose record. Authoring metadata: the preview does not change. |
238
280
 
239
- A deck-level design switch cannot reach a slide that carries its own value for that key. The result lists those slides in `shadowed`; `clearSlideOverrides: true` removes the overrides in the same transaction. `record` adds a gallery item's catalog record inline in the same transaction when neither the document nor the bundled catalog defines its id (a gallery-only layout or font scheme). Every switch is validated: an unknown catalog id, an invalid value or an invalid resulting document throws before anything changes, and switching to the current value commits nothing. The editor session emits the usual `patch`, `undo` and `redo` events with `meta.source: "dimension-switch"` and `meta.dimension`, so the canvas and any host preview recompose from the switched document. `resolveSlideContext(presentation, slideIndex).options.fontFamilies` (core) returns the heading, body and code families the preview measures and the export names.
281
+ A deck-level design switch cannot reach a slide that carries its own value for that key. The result lists those slides in `shadowed`; `clearSlideOverrides: true` removes the overrides in the same transaction. `record` embeds a gallery item's catalog record in the same transaction, under the group whose source is `recordSource` (else `custom`), when the document does not embed it yet. `catalogs` are the host's catalogs (merged after the session's when a panel calls the switch). Every switch is validated: an unknown catalog reference, an invalid value or an invalid resulting document throws before anything changes, and switching to the current value commits nothing. The editor session emits the usual `patch`, `undo` and `redo` events with `meta.source: "dimension-switch"` and `meta.dimension`, so the canvas and any host preview recompose from the switched document. `resolveSlideContext(presentation, slideIndex).options.fontFamilies` (core) returns the heading, body and code families the preview measures and the export names.
240
282
 
241
283
  ### Content-type conversion (RR-06, RR-26)
242
284
 
@@ -280,14 +322,14 @@ Images, videos and any other group have no conversion. Everything else is replac
280
322
  | List levels | `shiftListItems(editor, listPath, [indexes], delta)` | Indent (`1`) or outdent (`-1`) items; a level is at most one deeper than the item above, items under a moved item move with it, nothing is lost. |
281
323
  | Group | `groupBlocks(editor, containerPath, [indexes])`, `ungroupBlock(editor, groupPath)` | Wrap blocks of a slide or group in a group, or dissolve one (its composition and id are reported). |
282
324
  | Regions | `placeBlocksInRegions`, `regionsAsBlocks`, `moveSlideRegion` | Give each block its own named region (no overlap), turn regions back into blocks in reading order (placement is reported), move or swap a region. |
283
- | Images | `moveImageToDesign(editor, blockPath, "slideImage" \| "background" \| "watermark", options)`, `moveImageToContent(editor, slideIndex, source)` | Promote an image block to the slide image (with a position), background or watermark and back; alt text, titles, placement and opacity that the target cannot hold are reported. |
325
+ | Images | `moveImageToDesign(editor, blockPath, "background" \| "watermark", options)`, `moveImageToContent(editor, slideIndex, source)` | Promote an image block to the slide's background (it keeps alt, fit, focus, opacity and overlay) or watermark and back; what the target cannot hold is reported. Bleeding an image to an edge beside the content is the block's `placement`, not a move. |
284
326
  | Slides | `splitSlideByBlocks`, `splitSlideOnOverflow`, `mergeSlides`, `unpaginateSlides` | Split a slide by its blocks, or where it overflows through the existing pagination (the change carries `pages`), merge consecutive slides, and put paginated slides back together from `pages`. Each is one undo step made of per-slide `test`, `replace`, `remove` and `add` operations. |
285
327
 
286
328
  The playground's Content tab mounts the block-level and slide-structure actions (`slide-content` section of `/design-controls`). Slide-level split and merge are in the playground's slide menu (the slide manager's `contentActions` option) as well as in the API.
287
329
 
288
330
  ### Pickers: options, chart types and current values
289
331
 
290
- `listSwitchOptions(presentation, dimension, options)` lists what a catalog dimension offers (the document's inline records first, then caller-loaded ones, then the bundled catalog, without duplicates). `compatibleChartTypes(presentation, { slideIndex, path })` lists the chart types the chart's inline data can use as it is, by data shape: the first column labels the categories and each further column is a series (how the renderers read it), a type with N series needs exactly N value columns, except that stacked types and the combo chart take that many or more (combo: two or more), and the single-series, distribution and geographic types are offered only where they fit. Switching a combo chart to another type removes its `line`, `secondaryAxis` and secondary axis title. It is data-shape compatibility, not a claim that an engine draws the type. `currentSwitchValue(presentation, dimension, { slideIndex })` reads the value back as `{ value, scope }`.
332
+ `listSwitchOptions(presentation, dimension, { catalogs, vocabularies })` lists what a dimension offers: a catalog dimension lists core's `catalogRecords` (the document's embedded records first, then the host catalogs'), each with the reference to write; `charts`, `socials` and `languages` list core's vocabularies, labelled from `vocabularies` (`catalogDisplay` from `@openpresentation/opf/catalog`, supplied by the host). `compatibleChartTypes(presentation, { slideIndex, path })` lists the chart types the chart's inline data can use as it is, by data shape: the first column labels the categories and each further column is a series (how the renderers read it), a type with N series needs exactly N value columns, except that stacked types and the combo chart take that many or more (combo: two or more), and the single-series, distribution and geographic types are offered only where they fit. Switching a combo chart to another type removes its `line`, `secondaryAxis` and secondary axis title. It is data-shape compatibility, not a claim that an engine draws the type. `currentSwitchValue(presentation, dimension, { slideIndex })` reads the value back as `{ value, scope }`.
291
333
 
292
334
  ## Design options (RR-06)
293
335
 
@@ -309,11 +351,11 @@ setHeaderFooterZone(editor, "footer", "right", { slideNumber: true, logo: true }
309
351
  | `chartPrimary` | `design.chartPrimary` | `none`, `top`, `bottom`, `left`, `right` |
310
352
  | `listBullet` | `design.listBullet` | `character`, `image` (picture bullets draw the logo) |
311
353
  | `contentBox` | `design.contentBox` | `true`, `false` |
354
+ | `imageFit` | `design.imageFit` | `cover`, `contain`, `stretch`: the fit of image blocks that choose none |
312
355
  | `accentFont` | `design.fontScheme.accent` | a family name; the font scheme becomes its object form and collapses back to the bare id when the accent is cleared |
313
356
  | `logo` | `design.logo` | a source, an Asset object or a LogoSet; `setLogoVariant` edits one of the 12 variants |
314
357
  | `organizationLogo` | `organization.logo` (deck only; `index` picks an organization) | a source or Asset object |
315
358
  | `watermark` | `design.watermark` | `false`, a source, `{ src, opacity }` or the text stamp `{ text, opacity }` (fields merge; a lone source stays a bare source; setting `text` drops `src` and setting `src` drops `text`: a watermark is one or the other) |
316
- | `slideImage` | `design.slideImage` | a source or `{ src, position, size, fill, shape, inset, ... }` (fields merge; `position` defaults to `background` because the object form requires it) |
317
359
  | header and footer zones | `design.header` / `design.footer` `.left/.center/.right` | `setHeaderFooterZone` merges every part a zone can hold (`text`, `logo`, `image`, `slideNumber` and `slideNumberFormat` (must contain `{current}`), `date` (true, or a fixed date) and `dateFormat`, `organization`, `speaker` (the first speaker's name and title), `socials`, `section`; `ZONE_FIELDS`) into one zone, checking each value; a removed field, an emptied zone and an emptied header are all deleted rather than left as `{}`. A slide's own header or footer replaces the deck's whole one, so the first edit on a slide starts from a copy of the deck's (its other zones stay), and a slide emptied that way hides the furniture (`false`) instead of inheriting it again |
318
360
 
319
361
  A deck-scope change reports `shadowed` slides whose own design hides it (`clearSlideOverrides: true` removes those values in the same transaction). Results carry `warnings`: a header or footer zone with `logo: true`, or picture bullets, with no logo to draw (no slide, deck or primary-organization logo) is reported as `unresolved-logo`, and a zone that shows the organization or its social profiles when there are none as `unresolved-content`, before export, as `designWarnings(presentation, slideIndex)` does for the current document. `DESIGN_OPTIONS` describes every option for a generic panel, and `getDesignOption` reads `{ value, scope, inherited }`.
@@ -330,11 +372,18 @@ const change = await applyImageUpload(editor, file, (reference, presentation) =>
330
372
  change.assetId; // "acme-logo": the document now has assets["acme-logo"] = { src: "data:image/png;base64,...", mediaType, title, alt } and design.logo.light = "asset:acme-logo"
331
373
  ```
332
374
 
333
- `build(reference, presentation)` is any `prepare...` function of this package, so the same upload works for the logo (all 12 variants), organization logo, watermark, slide image, a background (`prepareBackground`) and a header/footer zone image. The file is checked before anything changes: PNG, JPEG, GIF, WebP or SVG by its bytes (a `.jpg` that is really a PNG, a text file, an empty file and an SVG with script are refused), and at most `maxBytes` (default 2 MiB, `DEFAULT_MAX_IMAGE_BYTES`) with a message that says what to do. A host that stores images elsewhere passes `onAddAsset({ name, mediaType, bytes, size, alt, file })` and returns the reference to use (a web address, or an `asset:` id it added); nothing is then added to `assets`. `setAssetAlt` edits an asset's alt text as one step.
375
+ `build(reference, presentation)` is any `prepare...` function of this package, so the same upload works for the logo (all 12 variants), organization logo, watermark, a background (`prepareBackground`) and a header/footer zone image. The file is checked before anything changes: PNG, JPEG, GIF, WebP or SVG by its bytes (a `.jpg` that is really a PNG, a text file, an empty file and an SVG with script are refused), and at most `maxBytes` (default 2 MiB, `DEFAULT_MAX_IMAGE_BYTES`) with a message that says what to do. A host that stores images elsewhere passes `onAddAsset({ name, mediaType, bytes, size, alt, file })` and returns the reference to use (a web address, or an `asset:` id it added); nothing is then added to `assets`. `setAssetAlt` edits an asset's alt text as one step.
334
376
 
335
377
  ### Backgrounds (RR-06)
336
378
 
337
- `@openpresentation/opf-editor/backgrounds` covers every background form the schema has: a theme slot, a solid color, a linear gradient, an image (`cover`, `contain` or `tile`) and a pattern, each with an optional opacity. Colors are ColorRefs: hex, a scheme slot or role (`accent1`, `surface`, ...) or `var:<id>`. `normalizeBackground` validates with sentences a person can act on (at least two stops, positions 0 to 1, a color that is none of the above), `setBackground(editor, spec, { slideIndex })` is one undoable patch through the `backgrounds` switch, `null` removes the background so the theme's (or the deck's) shows again, and `readBackground` flattens the current one for a form. `PATTERN_PRESETS` is the 54 DrawingML presets (`PATTERN_GROUPS` groups them in five families); PPTX export writes them as native pattern fills and import returns the same name. Radial gradients are not part of the OPF schema (a gradient has an angle and stops), so there is no radial control.
379
+ `@openpresentation/opf-editor/backgrounds` covers every background form the schema has: a theme slot, a solid color, a linear gradient, an image and a pattern, each with an optional opacity. The image form is flat (OPF 0.15): `{ type: "image", src, alt, fit, focus: { x, y }, opacity, overlay: { color, opacity, edge?, size? } }`, with the fits `cover`, `contain`, `stretch` and `tile`; a plain cover image is stored as the shorthand string (an `asset:`, `https://`, `data:`, `./` or `../` source). A background never moves content; with `alt` it is meaningful (PowerPoint export draws a picture at the back that carries the text).
380
+
381
+ Colors are ColorRefs: hex, a scheme slot or role (`accent1`, `surface`, ...) or `var:<id>`. `normalizeBackground` validates with sentences a person can act on (at least two stops, positions 0 to 1, a color that is none of the above), `setBackground(editor, spec, { slideIndex })` is one undoable patch through the `backgrounds` switch, `null` removes the background so the theme's (or the deck's) shows again, and `readBackground` flattens the current one for a form. `PATTERN_PRESETS` is the 54 DrawingML presets (`PATTERN_GROUPS` groups them in five families); PPTX export writes them as native pattern fills and import returns the same name. Radial gradients are not part of the OPF schema (a gradient has an angle and stops), so there is no radial control.
382
+
383
+ ### Image blocks (FA-22, OPF 0.15)
384
+
385
+ `@openpresentation/opf-editor/image-options` frames one image block: `setImageTreatment(editor, "slides.1.blocks.0", { fit: "contain", focus: { x: 0.5, y: 0.3 }, shape: "rounded", cornerRadius: 0.1, border: { color: "dark1", width: 4 }, opacity: 0.9, recolor: "grayscale", overlay: { color: "dark1", opacity: 0.4, edge: "bottom", size: 0.25 }, aspectRatio: 1.78, placement: { edge: "right", size: 0.45, inset: false } })` is one validated, undoable patch (`null` removes a field), and `readImageTreatments(presentation, path)` reads them back with `placeable` and the `usedEdges` other placed blocks take. Placement bleeds the block to one slide edge and composes the title and content in the rest; only a top-level block (`slides.N.blocks.I`) can be placed, at most one per edge, and the slide's own `image` takes no framing fields (`placement-not-top-level`, `placement-edge-taken`). The design panel's `image` section shows these controls for the selected image block.
386
+
338
387
 
339
388
  ## Footnotes, citations and captions (RR-34)
340
389
 
@@ -368,6 +417,8 @@ setTableCellStyle(editor, "slides.4.blocks.1.table", [{ section: "header", colum
368
417
 
369
418
  Styles use scheme roles, so they follow the color scheme, and the renderers keep the text readable on any fill. A style sets the header fill (`theme`, `plain`, `accent`), banded rows and borders (`theme`, `none`, `horizontal`, `grid`); named presets are `theme`, `banded`, `grid`, `minimal` and `open`, and `theme` removes a previous style. A style owns only the fill and border fields of a cell: text color, alignment, padding, values and merges are kept. Merging never hides text: covered cells that hold text refuse with `merge-would-lose-content` unless you pass `join: true`, which joins the words into the anchor with a space and keeps run formatting. Other refusals are `merge-overlap` (the region crosses another merge), `invalid-table-span` (outside the table, or a header cell spanning into the body) and `table-cell-covered`. Splitting leaves the formerly covered cells as empty text. `parseTableCellPath` maps a canvas selection to a table and cell, and `describeTableCell` and `readTableStyle` read the current state.
370
419
 
420
+ Table alt text (FA-27): `setTableAlt(editor, tablePath, { alt: "North America leads EMEA in Q4." })` writes the table's text alternative (`Table.alt`) as one undoable patch, like a chart's. `alt` is trimmed and an empty string or `null` removes it; `{ decorative: true }` writes the empty alt (decorative, and it wins over `alt`) and `{ decorative: false }` removes an empty alt. It works on a table that shows a shared dataset too, since the alt belongs to the table and not to its data. `readTableAlt(table)` gives `{ alt, decorative }` for a form and `prepareTableAlt` is the headless patch. The table section of the design controls has an "Alt text" field and a "Decorative (no alt text)" box, and find and replace searches `table.alt`. `validate` does not ask for a table alt, so the field is optional.
421
+
371
422
  ## Slide management (RR-21)
372
423
 
373
424
  `@openpresentation/opf-editor/slides` adds, duplicates, deletes, reorders, hides and sections slides. Every function has a `prepare…` form that returns the JSON Patch for a document without touching a session (`{ presentation, patches, changed, selection }`), and an apply form that commits it as ONE validated, undoable change, so a single Undo restores the deck exactly and the preview, thumbnails and PPTX export follow from the document. An operation that changes nothing commits nothing (`changed: false`).
@@ -441,13 +492,14 @@ const controls = createDesignControls(designPanel, {
441
492
  editor,
442
493
  getSlideIndex: () => slideIndex,
443
494
  getSelectedPath: () => selectedPath,
444
- sections: ["look", "slide-image", "header-footer", "brand", "layout-options", "info"],
495
+ sections: ["look", "background", "header-footer", "brand", "layout-options", "info", "catalog"],
496
+ vocabularies: catalogDisplay, // from @openpresentation/opf/catalog, in the host app
445
497
  });
446
- const selectionControls = createDesignControls(contentPanel, { editor, getSlideIndex, getSelectedPath, sections: ["selection", "table", "slide-content"], onSelectPath: select });
498
+ const selectionControls = createDesignControls(contentPanel, { editor, getSlideIndex, getSelectedPath, sections: ["selection", "image", "table", "slide-content"], onSelectPath: select });
447
499
  controls.refresh(); // when the slide or the selection changes
448
500
  ```
449
501
 
450
- Sections: `look` (theme, color scheme, font scheme, language, this slide's layout), `background` (type, theme slot, solid color, gradient with its stops, image, any of the 54 patterns, opacity; a draft until Apply, with Remove), `slide-image`, `header-footer` (choose header or footer and a zone, then every part the zone supports: text, logo, image, organization, socials, section, slide number and format, current or fixed date and format, with a summary of the zones in use), `brand` (logo variants, organization logo, picture bullets, accent font, watermark), `layout-options` (alignment, direction, primary chart, content box), `info` (narrative, tone, audience, socials), `selection` (content type with a loss report, list levels, group of metrics, group and ungroup, image to design, replacement, chart type), `slide-content` (blocks to regions and back, design images back into the content) and `table` (style, merge, split, cell fill and alignment). The panel is native `details`, `fieldset`, `select`, checkbox and text controls with a label on each, so it works with the keyboard and with screen readers: groups open with Enter or Space, a select changes with the arrow keys, text fields commit on Enter or when you leave them, and results and refusals are announced in `role="status"` and `role="alert"` regions. A refused change (an invalid value, text a merge would hide) is explained, changes nothing, and puts the field back. "Applies to" switches the design controls between the whole presentation and the current slide, and a control shows when a slide value is "set on this slide" or "from the presentation". The panel offers only conversions that exist, shows what a conversion loses before you choose it, and lists why an unavailable one is unavailable.
502
+ Sections: `look` (theme, color scheme, font scheme, language, this slide's layout), `background` (type, theme slot, solid color, gradient with its stops, image with fit, focus, alt text, recolor and overlay, any of the 54 patterns, opacity; a draft until Apply, with Remove), `header-footer` (choose header or footer and a zone, then every part the zone supports: text, logo, image, organization, socials, section, slide number and format, current or fixed date and format, with a summary of the zones in use), `brand` (logo variants, organization logo, picture bullets, accent font, watermark), `layout-options` (alignment, direction, primary chart, content box, default image fit), `info` (narrative, tone, audience, socials), `selection` (content type with a loss report, list levels, group of metrics, group and ungroup, image to background or watermark, replacement, chart type), `image` (the selected image block's fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio and placement), `catalog` (reference findings and the explicit "Update from catalog"), `slide-content` (blocks to regions and back, design images back into the content) and `table` (style, merge, split, cell fill and alignment). The panel is native `details`, `fieldset`, `select`, checkbox and text controls with a label on each, so it works with the keyboard and with screen readers: groups open with Enter or Space, a select changes with the arrow keys, text fields commit on Enter or when you leave them, and results and refusals are announced in `role="status"` and `role="alert"` regions. A refused change (an invalid value, text a merge would hide) is explained, changes nothing, and puts the field back. "Applies to" switches the design controls between the whole presentation and the current slide, and a control shows when a slide value is "set on this slide" or "from the presentation". The panel offers only conversions that exist, shows what a conversion loses before you choose it, and lists why an unavailable one is unavailable.
451
503
 
452
504
  In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
453
505
 
@@ -459,13 +511,13 @@ useEffect(() => {
459
511
  // call controls.refresh() (keep it in a ref) when slide or selected changes
460
512
  ```
461
513
 
462
- The playground mounts both panels (the Design tab, and the selection area of the Content tab). One click on text still enters text editing; the panels follow the selection after Escape. Every image field (logo variants, organization logo, watermark, slide image, background image and header/footer zone image) takes an asset reference (suggested from the document's assets), a web address or a data address, and has a file picker and an alt-text field: the chosen file is validated (PNG, JPEG, GIF, WebP or SVG, up to `maxImageBytes`, 2 MiB by default), added to `assets` and used in one undo step, or handed to your app through `onAddAsset`. Alt text is saved on the asset; typed before an upload it is used for that upload.
514
+ The playground mounts both panels (the Design tab, and the selection area of the Content tab). One click on text still enters text editing; the panels follow the selection after Escape. Every image field (logo variants, organization logo, watermark, background image and header/footer zone image) takes an asset reference (suggested from the document's assets), a web address or a data address, and has a file picker and an alt-text field: the chosen file is validated (PNG, JPEG, GIF, WebP or SVG, up to `maxImageBytes`, 2 MiB by default), added to `assets` and used in one undo step, or handed to your app through `onAddAsset`. Alt text is saved on the asset; typed before an upload it is used for that upload.
463
515
 
464
516
  Run `npm run test:design-controls-browser` (after `npm run build:playground`) for the real-browser check of every control, its keyboard operation and its undo.
465
517
 
466
518
  ### What a switch does not establish
467
519
 
468
- A switch changes the document; it does not change what the engines support. Language changes recompose fonts only as far as the installed core, renderer and PPTX packages implement the language and script model (FF-18, FF-19); the editor's own composition measures the Latin families. `image-treatments` previews the `design.slideImage` the renderer draws. `test/switches.mjs` checks the patch, one undo step, undo/redo, and preview refresh for all 16 dimensions (the 14 gallery dimensions, slide sizes and purposes), and for every slide-size preset the composed canvas and the preview viewBox. `test/switches-export.mjs` exports after each switch, undo and redo, checks that every slide-size preset writes the matching `p:sldSz` and re-imports as that preset, and applies opf-pptx's FF-08 typeface check (`checkTypefaces`) to every package it writes.
520
+ A switch changes the document; it does not change what the engines support. Language changes recompose fonts only as far as the installed core, renderer and PPTX packages implement the language and script model (FF-18, FF-19); the editor's own composition measures the Latin families. `image-treatments` previews the image block treatments the renderer draws. `test/switches.mjs` checks the patch, one undo step, undo/redo, and preview refresh for all 16 dimensions (the 14 gallery dimensions, slide sizes and purposes), and for every slide-size preset the composed canvas and the preview viewBox. `test/switches-export.mjs` exports after each switch, undo and redo, checks that every slide-size preset writes the matching `p:sldSz` and re-imports as that preset, and applies opf-pptx's FF-08 typeface check (`checkTypefaces`) to every package it writes.
469
521
 
470
522
  ## Autosave and restore (RR-22)
471
523
 
@@ -523,9 +575,9 @@ The headless pieces are usable on their own: `listTemplateFields(presentation, v
523
575
 
524
576
  ## Image crop and focal point (RR-25)
525
577
 
526
- Select a picture on the canvas and use **Crop picture** (or `canvas.cropImage(path, { tool: "focus" })`, or the inspector's Picture section, which also reaches a slide's `design.slideImage`). The crop layer shows the whole picture with a crop rectangle: eight drag handles, an aspect lock (Free, Original, **Frame shape**, 1:1, 4:3, 3:2, 16:9 and the portrait ratios; Shift while dragging keeps the current ratio), exact pixel fields, Reset, Cancel and Apply. The **Focal point** tool cuts the frame's own shape around a point you click or drag, with a zoom. Keyboard: the crop area takes arrow keys (move), Shift+arrows (resize), plus and minus (scale), Enter (apply) and Esc (cancel); the layer is a modal dialog that keeps Tab inside it. On a phone or tablet the layer fills the screen and the handles have finger-sized targets. Each Apply is one undoable change.
578
+ Select a picture on the canvas and use **Crop picture** (or `canvas.cropImage(path, { tool: "focus" })`, or the inspector's Picture section). The crop layer shows the whole picture with a crop rectangle: eight drag handles, an aspect lock (Free, Original, **Frame shape**, 1:1, 4:3, 3:2, 16:9 and the portrait ratios; Shift while dragging keeps the current ratio), exact pixel fields, Reset, Cancel and Apply. The **Focal point** tool cuts the frame's own shape around a point you click or drag, with a zoom. Keyboard: the crop area takes arrow keys (move), Shift+arrows (resize), plus and minus (scale), Enter (apply) and Esc (cancel); the layer is a modal dialog that keeps Tab inside it. On a phone or tablet the layer fills the screen and the handles have finger-sized targets. Each Apply is one undoable change.
527
579
 
528
- **What is written.** The OPF schema has no crop rectangle or focal point (`Asset` is `src`, `alt`, `title`, `description`, `mediaType`, `format`; the only fit controls are `design.imageFill` and a slide image's `fill`, both "crop" or "fit", and a crop is always centred; `crop` and `focalPoint` are listed as deferred fields in core's `docs/content-item-design-overrides.md`). So the editor writes the crop into the picture itself: the cropped pixels (at the picture's own resolution; JPEG stays JPEG, everything else PNG) become a new entry of `assets` with `description: "Cropped from asset:<id>"`, and the image points at it, in one patch. The preview and the PPTX export both read those pixels, so they cannot disagree about what is shown, and the fit the document already has places the cropped picture in its frame as before (opf-pptx writes its usual `a:srcRect` for a covering placement and none for a contained one; `test/image-crop-browser.mjs` exports a cropped deck and checks that the media is the cropped asset and that the placement matches the preview in both fills). The original asset stays so **Restore original** can put it back (one undo step); a crop of a crop points at the first original and the unused intermediate asset is removed. A vector (SVG) picture is not croppable (it has no pixels; use Fit); a picture hosted on another site is cropped only if the host allows its pixels to be read (otherwise upload it first); a GIF becomes its first frame. The model is `@openpresentation/opf-editor/image-crop` (`describeImage`, `prepareCrop`, `prepareRestore`, `applyCrop`, `cropImagePixels`, and the pure rectangle maths `resizeRect`, `fitAspect`, `focalWindow`) and the layer is `/image-cropper`.
580
+ **What is written.** The OPF schema has no crop rectangle (`Asset` is `src`, `alt`, `title`, `description`, `mediaType`, `format`). An image block has a `fit` (cover, contain, stretch) and, since OPF 0.15, a `focus` point a cover fit keeps in view, set in the "Selected image" section (`/image-options`). So the editor writes the crop into the picture itself: the cropped pixels (at the picture's own resolution; JPEG stays JPEG, everything else PNG) become a new entry of `assets` with `description: "Cropped from asset:<id>"`, and the image points at it, in one patch. The preview and the PPTX export both read those pixels, so they cannot disagree about what is shown, and the fit the document already has places the cropped picture in its frame as before (opf-pptx writes its usual `a:srcRect` for a covering placement and none for a contained one; `test/image-crop-browser.mjs` exports a cropped deck and checks that the media is the cropped asset and that the placement matches the preview in both fills). The original asset stays so **Restore original** can put it back (one undo step); a crop of a crop points at the first original and the unused intermediate asset is removed. A vector (SVG) picture is not croppable (it has no pixels; use Fit); a picture hosted on another site is cropped only if the host allows its pixels to be read (otherwise upload it first); a GIF becomes its first frame. The model is `@openpresentation/opf-editor/image-crop` (`describeImage`, `prepareCrop`, `prepareRestore`, `applyCrop`, `cropImagePixels`, and the pure rectangle maths `resizeRect`, `fitAspect`, `focalWindow`) and the layer is `/image-cropper`.
529
581
 
530
582
  ## Phone and tablet (RR-25)
531
583
 
@@ -564,7 +616,7 @@ import { exportDeck } from "@openpresentation/opf-editor/export";
564
616
  const result = await exportDeck(editor.presentation, {
565
617
  format: "pdf", // "pdf" | "png" | "svg"
566
618
  slides: "all", // or "current" with slideIndex, or [slide numbers]; hidden slides only with includeHidden
567
- pdfMode: "vector", // or "raster" (an image per slide); PNG and raster density: scale 1 to 4
619
+ pdfMode: "vector", // or "raster" (an image per slide; also pass pdfLib: import * as pdfLib from "pdf-lib"); PNG and raster density: scale 1 to 4
568
620
  fonts, // the renderer's fonts handle (loadFonts): measures, loads the faces the deck needs, supplies the faces to embed
569
621
  renderOptions, // catalogs, date, ...
570
622
  signal, onProgress, onDiagnostic,
@@ -578,7 +630,11 @@ const result = await exportDeck(editor.presentation, {
578
630
  - **Names.** `exportFileName(deck, ext, suffix)` uses the deck's `filename` (a trailing .pptx/.pdf/.png/.svg dropped), else the slugified `name`, else `presentation`; slides are `name-01.png`, archives `name-png.zip`.
579
631
  - **Progress and cancel.** `onProgress({ stage, done, total, message })` reports fonts, drawing, per-page conversion and packing; an aborted `signal` rejects with `export-aborted` between pages and slides.
580
632
  - **Diagnostics.** `describeDiagnostic` normalises renderer and converter notes into `{ code, severity, message, slide? }`: `pdf-font-substituted`, `pdf-glyph-missing`, `pdf-raster-fallback` and the renderer's own are `warning`; `pdf-font-embedded` is `info`.
581
- - PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
633
+ - PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. The entry imports no PDF library (opf-render 0.16): the vector PDF, PNG and SVG need nothing else, and `pdfMode: "raster"` needs the `pdfLib` option. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
634
+
635
+ Catalog controls initialize an absent deck or slide `design` for known theme, color-scheme and font-scheme fields as one validated transaction. Undo removes a newly created parent; existing design fields are retained. DOM, React and Svelte catalog controls accept `onError(error)` and emit a bubbling `opferror` event on rejected edits, restore the committed selection and expose native validity feedback. General `editor.set` and JSON Patch require existing parents.
636
+
637
+ Optional downloads can be deferred until requested: dynamically import `@openpresentation/opf-editor/export` inside the download handler. For a PowerPoint download, dynamically import `@openpresentation/opf-pptx` inside that handler too; the `/export` helper covers PDF, PNG and SVG. This defers conversion code, while canvas rendering and font measurement still have their own startup costs.
582
638
 
583
639
  ## Optional React Bindings
584
640
 
@@ -626,7 +682,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
626
682
 
627
683
  The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
628
684
 
629
- Version 0.11.0 requires `@openpresentation/opf@^0.12.0` and the optional renderer peer `@openpresentation/opf-render@^0.12.0`, with development and coordinated playground export on renderer 0.12.0 and `@openpresentation/opf-pptx@^0.12.0`; 0.10.6 requires `@openpresentation/opf@^0.11.4`; 0.10.5 and 0.10.4 require ^0.11.3 (0.10.3 and earlier: ^0.11.2). The optional renderer peer requires `@openpresentation/opf-render@^0.11.0` (the font gate gates nothing on a registry without the lazy loaders). Development and coordinated playground export use renderer 0.11.9 (design fields, picture bullets; 0.11.8 tag colour; the playground example needs 0.11.7 for `extraLazyFonts`; 0.11.6 chart label rotation; 0.11.5 face-level lazy fonts; the gate passes render options, which older renderers ignore) and `@openpresentation/opf-pptx@^0.11.7`. Clean registry installs support the composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
685
+ The current editor requires core `^0.15.1`; canvas rendering uses the optional renderer peer `^0.15.0`, and playground PPTX import/export uses `@openpresentation/opf-pptx@^0.15.0`. Clean registry installs support composition without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
630
686
 
631
687
  ## Local interactive demo
632
688
 
@@ -638,7 +694,7 @@ Nested content groups expose their bounds through `editor.composeSlide(index).gr
638
694
 
639
695
  `editor.paginateSlide(index, {minFontSize:24})` splits a crowded draft into ordinary OPF slides as one undoable transaction. It returns the change and source mappings; failed pagination leaves the document unchanged. The playground’s Split overflow action demonstrates the workflow.
640
696
 
641
- `composeSlide(index, { fonts })` and `paginateSlide(index, { fonts })` accept the same fonts handle as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions. The font a user selects (for example Calibri or Aptos) is the source of truth and stays in the document; because license-restricted (proprietary) fonts are never bundled, the canvas draws an open look-alike (a metric-compatible one such as Carlito for Calibri where it exists, a visual-only fallback for Aptos today) and the substitution report says so. Release caveat: the published editor depends on opf-pptx `^0.9.0`, which resolves 0.9.1 and still writes the substitute into the PPTX; selected-name export arrives with the next opf-pptx release. See the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31).
697
+ `composeSlide(index, { fonts })` and `paginateSlide(index, { fonts })` accept the same fonts handle as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions. The font a user selects (for example Calibri or Aptos) is the source of truth and stays in the document; because license-restricted (proprietary) fonts are never bundled, the canvas draws an open look-alike (a metric-compatible one such as Carlito for Calibri where it exists, the metric-compatible Intos family for Aptos) and the substitution report says so. Since 0.14, PPTX export names the selected font, while preview substitutions remain visible in the font report. See the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31).
642
698
 
643
699
 
644
700
  ## Copy, paste, and gallery imports
@@ -649,7 +705,7 @@ The `/galleries` export provides `loadOpfGallery` and `loadOpfGalleryItem`, acce
649
705
 
650
706
  The playground includes Copy OPF and Import dialogs with paste, file/drop, gallery search, and URL tabs. Files accept OPF JSON and PowerPoint `.pptx` up to 20 MB. PPTX conversion runs locally using `@openpresentation/opf-pptx`; review the converted preview and diagnostics before inserting slides or opening a presentation. Applying an import is one undoable edit. Custom registries persist in local storage; defaults are configured in `examples/galleries.json`. Copy permissions may require using the Select all fallback. Pasting within text fields keeps normal text editing. Cmd/Ctrl+Shift+C opens copying and Cmd/Ctrl+O opens file import.
651
707
 
652
- The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. With an opf-pptx that includes FF-31 (after 0.9.1), the download names the font the user selected, never the preview look-alike, so PowerPoint shows that font when it is installed or available as a Microsoft 365 cloud font; opf-pptx 0.9.1, which the published editor resolves today, still writes the substitute. The download does not embed font binaries, and license-restricted (proprietary) fonts are never embedded. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
708
+ The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. Since 0.14, PPTX export names the font the user selected, so PowerPoint uses it when installed or available as a Microsoft 365 cloud font. The download does not embed font binaries, and license-restricted (proprietary) fonts are never embedded. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
653
709
 
654
710
  Run `npm ci`, `npm run build:playground`, then serve `artifacts/playground` with a local static server. This example uses the published core/render/PPTX packages and this checkout's editor source; it does not require an account, AI provider or paid service. `npm run test:playground` builds and executes a real browser flow (local Edge on Windows, installed Chromium in CI): author JSON, inline edit, export a native merged table, validate/reimport the actual downloaded file, undo/redo, save OPF and reject malformed input without changing the document. It is browser behavior evidence, not a claim of native PowerPoint raster equivalence.
655
711
 
@@ -685,6 +741,8 @@ The canvas can show draggable, keyboard-accessible dividers for root and nested
685
741
 
686
742
  ### Move complete blocks
687
743
 
744
+ When the slide's layout record has placeholder groups (FA-26, `{ "type": "group", "composition", "placeholders" }`), Arrange also outlines every slot of the record at its cell (`geometry.slots`): groups, filled regions and labelled empty regions. The slots belong to the record, so they are shown, not edited; the slide's content in them keeps its own selection and handles. Layout pickers and the JSON field menu describe such a record as `title, column (text, text), chart` and compare layouts by their leaf regions.
745
+
688
746
  Arrange mode also shows numbered block handles. Drag to reorder siblings, use arrow keys for earlier/later, or click a handle to choose an existing destination group/slide and insertion position. The move preserves the whole block, including rich text, table/chart data, and nested groups. Each move is validated, preflighted by the renderer, and undoable. Parent weights stay with layout positions; moves that leave an empty container or create a containment cycle are rejected.
689
747
 
690
748
  Agents can import `prepareBlockMove` and `listBlockContainers` from `@openpresentation/opf-editor/layout`. The prepared result includes a full candidate document, guarded atomic patches, the new block path, and a changed flag. Destination indexes refer to the pre-removal document. Use `canvas.openBlockMenu(path)` for the corresponding browser controls.
package/dist/assets.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Image assets for the design controls (RR-06): turn a local file into an entry of the document's
2
- // `assets` map and reference it from a logo, watermark, slide image, background or header/footer
2
+ // `assets` map and reference it from a logo, watermark, background or header/footer
3
3
  // zone in the same undoable patch. The file is validated before anything changes: the type must be
4
4
  // PNG, JPEG, GIF, WebP or SVG, its bytes must match that type, and its size must be under a cap with a
5
5
  // clear error. A host that keeps images elsewhere passes `onAddAsset` and gets the bytes instead.
@@ -1,9 +1,12 @@
1
1
  import type { EditorSession } from "./index.js";
2
+ import type { ImageFocus, ImageOverlay } from "./image-options.js";
2
3
  import type { DimensionSwitchChange, DimensionSwitchOptions, PreparedDimensionSwitch } from "./switches.js";
3
4
 
4
5
  export declare const BACKGROUND_TYPES: readonly ["theme", "solid", "gradient", "image", "pattern"];
5
6
  export declare const THEME_BACKGROUND_SLOTS: readonly ["light1", "light2", "dark1", "dark2"];
6
- export declare const IMAGE_BACKGROUND_FITS: readonly ["cover", "contain", "tile"];
7
+ export declare const IMAGE_BACKGROUND_FITS: readonly ["cover", "contain", "stretch", "tile"];
8
+ /** Image sources the background string shorthand accepts: `asset:`, `https://`, `data:`, `./`, `../`. */
9
+ export declare const IMAGE_SOURCE: RegExp;
7
10
  /** Scheme slots and roles a ColorRef may name. */
8
11
  export declare const COLOR_NAMES: readonly string[];
9
12
  /** The 54 DrawingML preset patterns by family. */
@@ -19,12 +22,12 @@ export type BackgroundSpec =
19
22
  | { type: "theme"; slot: (typeof THEME_BACKGROUND_SLOTS)[number] }
20
23
  | { type: "solid"; color: string; opacity?: number }
21
24
  | { type: "gradient"; gradient: { angle?: number; stops: GradientStop[] }; opacity?: number }
22
- | { type: "image"; image: { src: string; fit?: (typeof IMAGE_BACKGROUND_FITS)[number] }; opacity?: number }
25
+ | { type: "image"; src: string; alt?: string; fit?: (typeof IMAGE_BACKGROUND_FITS)[number]; focus?: ImageFocus; opacity?: number; recolor?: "grayscale" | { dark: string; light: string }; overlay?: ImageOverlay }
23
26
  | { type: "pattern"; pattern: { preset: string; foregroundColor?: string; backgroundColor?: string }; opacity?: number };
24
27
 
25
28
  /** Whether `value` is a ColorRef: a hex color, a scheme slot or role name, or `var:<id>`. */
26
29
  export declare function isColorRef(value: unknown): boolean;
27
- /** Validate a background description and return the value to store (a theme slot or plain hex stays the shorthand string). Throws `invalid-background`. */
30
+ /** Validate a background description and return the value to store (a theme slot, a plain hex color or a plain cover image stays the shorthand string). Throws `invalid-background`. */
28
31
  export declare function normalizeBackground(spec: BackgroundSpec): string | Record<string, unknown>;
29
32
  export declare function prepareBackground(presentation: unknown, spec: BackgroundSpec | null, options?: DimensionSwitchOptions): PreparedDimensionSwitch;
30
33
  /** Set the background as one undoable transaction; `null` removes it. */
@@ -37,7 +40,11 @@ export interface BackgroundState {
37
40
  angle?: number;
38
41
  stops?: GradientStop[];
39
42
  src?: string;
43
+ alt?: string;
40
44
  fit?: string;
45
+ focus?: ImageFocus;
46
+ recolor?: "grayscale" | { dark: string; light: string };
47
+ overlay?: ImageOverlay;
41
48
  preset?: string;
42
49
  foregroundColor?: string;
43
50
  backgroundColor?: string;
@@ -1,13 +1,18 @@
1
- // Backgrounds (RR-06): every background form the schema has, with validation that explains itself.
2
- // A background is a theme slot, a solid color, a linear gradient, an image or a pattern, at the deck
3
- // or on one slide. `setBackground` is one undoable patch through the `backgrounds` switch, so the
4
- // preview, export and Undo behave like every other dimension.
1
+ // Backgrounds (RR-06, FA-22/FA-23): every background form the schema has, with validation that explains itself.
2
+ // A background is a theme slot, a solid color, a linear gradient, an image or a pattern, at the deck or on one slide. It is
3
+ // the only canvas fill and never moves content. The image form is flat (OPF 0.15): `{ type: "image", src, alt, fit, focus,
4
+ // opacity, recolor, overlay }`; an image source string (`asset:`, `https://`, `data:`, `./`, `../`) is the cover-image shorthand.
5
+ // `setBackground` is one undoable patch through the `backgrounds` switch, so the preview, export and Undo behave like every
6
+ // other dimension.
5
7
  import { prepareDimensionSwitch, switchDimension } from "./switches.js";
6
8
  import { fail } from "./edit-helpers.js";
9
+ import { normalizeFocus, normalizeOverlay, normalizeRecolor } from "./image-options.js";
7
10
 
8
11
  export const BACKGROUND_TYPES = Object.freeze(["theme", "solid", "gradient", "image", "pattern"]);
9
12
  export const THEME_BACKGROUND_SLOTS = Object.freeze(["light1", "light2", "dark1", "dark2"]);
10
- export const IMAGE_BACKGROUND_FITS = Object.freeze(["cover", "contain", "tile"]);
13
+ export const IMAGE_BACKGROUND_FITS = Object.freeze(["cover", "contain", "stretch", "tile"]);
14
+ /** Image sources the background string shorthand accepts (the schema's ImageSource). */
15
+ export const IMAGE_SOURCE = /^(?:asset:|https:\/\/|data:|\.\/|\.\.\/)/;
11
16
  /** Scheme slots and roles a ColorRef may name (the schema's ColorRef enum), besides hex colors and `var:<id>`. */
12
17
  export const COLOR_NAMES = Object.freeze([
13
18
  "accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "dark1", "dark2", "light1", "light2", "hyperlink", "followedHyperlink",
@@ -55,7 +60,8 @@ export function normalizeBackground(spec) {
55
60
  if (typeof spec === "string") {
56
61
  if (THEME_BACKGROUND_SLOTS.includes(spec)) return spec;
57
62
  if (HEX.test(spec)) return spec.toUpperCase();
58
- throw fail("invalid-background", `A background shorthand is a theme slot (${THEME_BACKGROUND_SLOTS.join(", ")}) or a hex color.`, { spec });
63
+ if (IMAGE_SOURCE.test(spec.trim())) return spec.trim();
64
+ throw fail("invalid-background", `A background shorthand is a theme slot (${THEME_BACKGROUND_SLOTS.join(", ")}), a hex color, or an image source starting with asset:, https://, data:, ./ or ../.`, { spec });
59
65
  }
60
66
  if (!isObject(spec)) throw fail("invalid-background", "Describe the background as { type, ... } or a theme slot / hex shorthand.", { spec });
61
67
  const opacity = opacityOf(spec);
@@ -83,10 +89,25 @@ export function normalizeBackground(spec) {
83
89
  return withOpacity({ type: "gradient", gradient: { ...(angle === undefined ? {} : { angle }), stops } });
84
90
  }
85
91
  case "image": {
86
- const image = spec.image;
87
- if (!isObject(image) || typeof image.src !== "string" || !image.src.trim()) throw fail("invalid-background", "An image background needs an image: choose a file or enter a source.", { spec });
88
- if (image.fit !== undefined && image.fit !== "" && !IMAGE_BACKGROUND_FITS.includes(image.fit)) throw fail("invalid-background", `Image fit is one of ${IMAGE_BACKGROUND_FITS.join(", ")}.`, { spec });
89
- return withOpacity({ type: "image", image: { src: image.src.trim(), ...(image.fit ? { fit: image.fit } : {}) } });
92
+ if (typeof spec.src !== "string" || !spec.src.trim()) throw fail("invalid-background", "An image background needs an image: choose a file or enter a source.", { spec });
93
+ const src = spec.src.trim();
94
+ if (!IMAGE_SOURCE.test(src)) throw fail("invalid-background", "An image source starts with asset:, https://, data:, ./ or ../.", { spec });
95
+ const fit = spec.fit === "" || spec.fit === null ? undefined : spec.fit;
96
+ if (fit !== undefined && !IMAGE_BACKGROUND_FITS.includes(fit)) throw fail("invalid-background", `Image fit is one of ${IMAGE_BACKGROUND_FITS.join(", ")}.`, { spec });
97
+ const alt = typeof spec.alt === "string" && spec.alt.trim() ? spec.alt.trim() : undefined;
98
+ const wrap = (call) => {
99
+ try {
100
+ return call();
101
+ } catch (error) {
102
+ throw fail("invalid-background", error.message, { spec });
103
+ }
104
+ };
105
+ const focus = spec.focus === undefined || spec.focus === null ? undefined : wrap(() => normalizeFocus(spec.focus));
106
+ const overlay = spec.overlay === undefined || spec.overlay === null ? undefined : wrap(() => normalizeOverlay(spec.overlay));
107
+ const recolor = spec.recolor === undefined || spec.recolor === null || spec.recolor === "" ? undefined : wrap(() => normalizeRecolor(spec.recolor));
108
+ const value = { type: "image", src, ...(alt ? { alt } : {}), ...(fit ? { fit } : {}), ...(focus ? { focus } : {}), ...(opacity === undefined ? {} : { opacity }), ...(recolor ? { recolor } : {}), ...(overlay ? { overlay } : {}) };
109
+ // A cover image with nothing else is the shorthand string.
110
+ return Object.keys(value).length === 2 ? src : value;
90
111
  }
91
112
  case "pattern": {
92
113
  const pattern = spec.pattern;
@@ -113,7 +134,7 @@ export function setBackground(editor, spec, options = {}) {
113
134
 
114
135
  /**
115
136
  * The background that applies at a scope as a flat description for a form:
116
- * `{ type, slot?, color?, opacity?, angle?, stops?, src?, fit?, preset?, foregroundColor?, backgroundColor?, scope, value }`.
137
+ * `{ type, slot?, color?, opacity?, angle?, stops?, src?, alt?, fit?, focus?, recolor?, overlay?, preset?, foregroundColor?, backgroundColor?, scope, value }`.
117
138
  * `type` is undefined when nothing is set; `scope` is "slide" when the slide sets its own, else "deck".
118
139
  */
119
140
  export function readBackground(presentation, { slideIndex } = {}) {
@@ -121,13 +142,17 @@ export function readBackground(presentation, { slideIndex } = {}) {
121
142
  const value = own !== undefined ? own : presentation.design?.background;
122
143
  const scope = own !== undefined ? "slide" : "deck";
123
144
  if (value === undefined) return { scope };
124
- if (typeof value === "string") return THEME_BACKGROUND_SLOTS.includes(value) ? { type: "theme", slot: value, scope, value } : { type: "solid", color: value, scope, value };
145
+ if (typeof value === "string") {
146
+ if (THEME_BACKGROUND_SLOTS.includes(value)) return { type: "theme", slot: value, scope, value };
147
+ if (IMAGE_SOURCE.test(value)) return { type: "image", src: value, fit: "cover", scope, value };
148
+ return { type: "solid", color: value, scope, value };
149
+ }
125
150
  if (!isObject(value)) return { scope, value };
126
151
  const base = { type: value.type, opacity: value.opacity, scope, value };
127
152
  if (value.type === "theme") return { ...base, slot: value.slot };
128
153
  if (value.type === "solid") return { ...base, color: value.color };
129
154
  if (value.type === "gradient") return { ...base, angle: value.gradient?.angle, stops: structuredClone(value.gradient?.stops ?? []) };
130
- if (value.type === "image") return { ...base, src: value.image?.src, fit: value.image?.fit };
155
+ if (value.type === "image") return { ...base, src: value.src, alt: value.alt, fit: value.fit, focus: value.focus && { ...value.focus }, recolor: value.recolor && structuredClone(value.recolor), overlay: value.overlay && { ...value.overlay } };
131
156
  if (value.type === "pattern") return { ...base, preset: value.pattern?.preset, foregroundColor: value.pattern?.foregroundColor, backgroundColor: value.pattern?.backgroundColor };
132
157
  return base;
133
158
  }
package/dist/canvas.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { EditorSession } from "./index.js";
2
2
  import type { RenderSvgOptions } from "@openpresentation/opf-render";
3
3
  import type { BrowserFontsHandle } from "@openpresentation/opf-render/fonts-browser";
4
4
  import type { SlideComposition } from "@openpresentation/opf/composition";
5
+ import type { Catalog } from "@openpresentation/opf";
5
6
  /**
6
7
  * The fonts the canvas, the export and `whenFontsReady` take: the renderer's handle from `loadFonts()` (`@openpresentation/opf-render/fonts-browser`).
7
8
  * Only `textMeasurement` is required to measure; `pending` and `ensure` make the canvas load script and vendored faces before it draws.
@@ -30,6 +31,12 @@ export declare function whenFontsReady(
30
31
  export interface CanvasEditorOptions {
31
32
  editor?: EditorSession;
32
33
  presentation?: unknown;
34
+ /**
35
+ * The host's catalogs (core `Catalog[]`, for example `[defaultCatalog]` from `@openpresentation/opf/catalog`) for the session the
36
+ * canvas creates from `presentation`. With `editor`, register them on that session instead (`createEditorSession(doc, { catalogs })`);
37
+ * `renderOptions.catalogs` adds catalogs for drawing only, merged after the session's.
38
+ */
39
+ catalogs?: readonly Catalog[];
33
40
  slideIndex?: number;
34
41
  /** Show keyboard-accessible dividers for resizing composition tracks. */
35
42
  layoutEditing?: boolean;
@@ -86,7 +93,7 @@ export interface CanvasEditor {
86
93
  /** True while the crop layer is open. */
87
94
  readonly cropping: boolean;
88
95
  /**
89
- * Open the crop layer for the picture at `path` (an image block, a slide's `image`, or `slides.N.design.slideImage`); `tool: "focus"`
96
+ * Open the crop layer for the picture at `path` (an image block or a slide's `image`); `tool: "focus"`
90
97
  * starts with the focal point. Apply writes one undoable change (see `@openpresentation/opf-editor/image-crop`). Resolves to whether it opened.
91
98
  */
92
99
  cropImage(path: string, options?: { tool?: "crop" | "focus" }): Promise<boolean>;