@openpresentation/opf-editor 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,28 @@
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
+ ## OPF 0.15: catalogs from the host, backgrounds and image blocks (FA-23)
6
+
7
+ 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`.
8
+
9
+ - **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:
10
+
11
+ ```js
12
+ import { createEditorSession, saveDocument } from "@openpresentation/opf-editor";
13
+ import { defaultCatalog, catalogDisplay } from "@openpresentation/opf/catalog"; // the host app's choice, not the library's
14
+
15
+ const editor = createEditorSession(doc, { catalogs: [defaultCatalog, acmeCatalog] }); // the first is the host default
16
+ editor.catalogs; // the frozen list; editor.setCatalogs([...]) replaces it (one "catalogs" event)
17
+ const { document } = saveDocument(editor); // core embed: every referenced record embedded once, renders with no catalog
18
+ ```
19
+
20
+ `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`.
21
+ - **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.
22
+ - **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.
23
+ - **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)`).
24
+ - **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`.
25
+ - **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.
26
+
5
27
  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.
6
28
 
7
29
  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.
@@ -22,7 +44,7 @@ const control = mountJsonCodeEditor(container, {
22
44
  code: source,
23
45
  label: 'OPF JSON document',
24
46
  lineNumbers: true,
25
- catalogs: loadedCatalogRecords,
47
+ catalogs: editor.catalogs, // the host's Catalog[] (for example [defaultCatalog])
26
48
  onChange: nextSource => receiveDraft(nextSource),
27
49
  onError: error => showError(error),
28
50
  });
@@ -36,7 +58,7 @@ The host owns the source draft, OPF validation, slide preview, persistence and s
36
58
 
37
59
  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
60
 
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.
61
+ 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
62
 
41
63
  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
64
 
@@ -52,7 +74,7 @@ Version 0.7.0 uses core 0.10.0 and renderer 0.8.0. Code source/metadata edits pr
52
74
  - Compatibility target: `@openpresentation/opf`
53
75
  - Renderer relationship: built on `@openpresentation/opf-render` trace output
54
76
  - Headless JSON edit bindings for renderer trace attributes
55
- - Structured catalog controls that only commit known catalog IDs
77
+ - Structured catalog controls that only commit catalog references that resolve (in the document or the host's registered catalogs)
56
78
  - JSON Patch state transitions with inverse patches for undo/redo
57
79
  - Optional DOM controls plus React and Svelte bindings in separate embeddable entry points
58
80
  - 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 +112,13 @@ import { createCanvasEditor, whenFontsReady } from '@openpresentation/opf-editor
90
112
  import { loadFonts } from '@openpresentation/opf-render/fonts-browser';
91
113
 
92
114
  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 } });
115
+ // The canvas draws with the session's registered catalogs (plus renderOptions.catalogs) and hands the same list to the handle, so
116
+ // font schemes only your catalogs know resolve in the registry too.
117
+ const canvas = createCanvasEditor(container, { editor, fonts });
96
118
  // Other renders of your own: whenFontsReady(fonts, presentation, { ready: draw, failed: showError, loading: showSpinner })
97
119
  ```
98
120
 
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.
121
+ `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
122
 
101
123
  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
124
 
@@ -186,7 +208,7 @@ editor.redo();
186
208
 
187
209
  ## Catalog Controls
188
210
 
189
- Catalog helpers commit catalog IDs from the OPF catalog registry instead of accepting freeform incompatible values:
211
+ 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
212
 
191
213
  ```js
192
214
  import {
@@ -194,7 +216,7 @@ import {
194
216
  getCatalogOptions
195
217
  } from "@openpresentation/opf-editor";
196
218
 
197
- getCatalogOptions("themes").map((option) => option.id);
219
+ getCatalogOptions("themes", { editor }).map((option) => option.id); // references, embedded records first
198
220
 
199
221
  const themeSelect = createCatalogSelect(editor, {
200
222
  path: "design.theme",
@@ -205,7 +227,7 @@ const themeSelect = createCatalogSelect(editor, {
205
227
  toolbar.append(themeSelect);
206
228
  ```
207
229
 
208
- Invalid IDs throw before they reach the document. Existing object-form references keep their sibling override fields and replace only `id`.
230
+ References that resolve nowhere throw before they reach the document. Existing object-form references keep their sibling override fields and replace only `id`.
209
231
 
210
232
  ## Dimension switches
211
233
 
@@ -225,18 +247,19 @@ const { patches, presentation } = prepareDimensionSwitch(editor.presentation, "t
225
247
  | --- | --- | --- |
226
248
  | `layouts` | `/slides/N/layout` | Needs `slideIndex`. Adds the blank payloads the layout declares, like the JSON editor's layout choice; existing content stays. |
227
249
  | `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). |
250
+ | `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. |
251
+ | `languages` | `/language` | A BCP-47 tag (`en-US`, `ja`) or a language object; `listSwitchOptions` offers core's `LANGUAGES`, labelled from `vocabularies.languages`. |
252
+ | `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. |
253
+ | `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
254
  | `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. |
255
+ | `image-treatments` | `<image block>/fit`, `/focus`, `/shape`, ... | Needs `path` (the image block). The fields of `setImageTreatment`; `null` removes one. |
256
+ | `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
257
  | `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
258
  | `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
259
  | `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. |
260
+ | `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
261
 
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.
262
+ 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
263
 
241
264
  ### Content-type conversion (RR-06, RR-26)
242
265
 
@@ -280,14 +303,14 @@ Images, videos and any other group have no conversion. Everything else is replac
280
303
  | 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
304
  | 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
305
  | 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. |
306
+ | 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
307
  | 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
308
 
286
309
  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
310
 
288
311
  ### Pickers: options, chart types and current values
289
312
 
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 }`.
313
+ `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
314
 
292
315
  ## Design options (RR-06)
293
316
 
@@ -309,11 +332,11 @@ setHeaderFooterZone(editor, "footer", "right", { slideNumber: true, logo: true }
309
332
  | `chartPrimary` | `design.chartPrimary` | `none`, `top`, `bottom`, `left`, `right` |
310
333
  | `listBullet` | `design.listBullet` | `character`, `image` (picture bullets draw the logo) |
311
334
  | `contentBox` | `design.contentBox` | `true`, `false` |
335
+ | `imageFit` | `design.imageFit` | `cover`, `contain`, `stretch`: the fit of image blocks that choose none |
312
336
  | `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
337
  | `logo` | `design.logo` | a source, an Asset object or a LogoSet; `setLogoVariant` edits one of the 12 variants |
314
338
  | `organizationLogo` | `organization.logo` (deck only; `index` picks an organization) | a source or Asset object |
315
339
  | `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
340
  | 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
341
 
319
342
  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 +353,18 @@ const change = await applyImageUpload(editor, file, (reference, presentation) =>
330
353
  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
354
  ```
332
355
 
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.
356
+ `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
357
 
335
358
  ### Backgrounds (RR-06)
336
359
 
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.
360
+ `@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).
361
+
362
+ 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.
363
+
364
+ ### Image blocks (FA-22, OPF 0.15)
365
+
366
+ `@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.
367
+
338
368
 
339
369
  ## Footnotes, citations and captions (RR-34)
340
370
 
@@ -441,13 +471,14 @@ const controls = createDesignControls(designPanel, {
441
471
  editor,
442
472
  getSlideIndex: () => slideIndex,
443
473
  getSelectedPath: () => selectedPath,
444
- sections: ["look", "slide-image", "header-footer", "brand", "layout-options", "info"],
474
+ sections: ["look", "background", "header-footer", "brand", "layout-options", "info", "catalog"],
475
+ vocabularies: catalogDisplay, // from @openpresentation/opf/catalog, in the host app
445
476
  });
446
- const selectionControls = createDesignControls(contentPanel, { editor, getSlideIndex, getSelectedPath, sections: ["selection", "table", "slide-content"], onSelectPath: select });
477
+ const selectionControls = createDesignControls(contentPanel, { editor, getSlideIndex, getSelectedPath, sections: ["selection", "image", "table", "slide-content"], onSelectPath: select });
447
478
  controls.refresh(); // when the slide or the selection changes
448
479
  ```
449
480
 
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.
481
+ 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
482
 
452
483
  In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
453
484
 
@@ -459,13 +490,13 @@ useEffect(() => {
459
490
  // call controls.refresh() (keep it in a ref) when slide or selected changes
460
491
  ```
461
492
 
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.
493
+ 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
494
 
464
495
  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
496
 
466
497
  ### What a switch does not establish
467
498
 
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.
499
+ 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
500
 
470
501
  ## Autosave and restore (RR-22)
471
502
 
@@ -523,9 +554,9 @@ The headless pieces are usable on their own: `listTemplateFields(presentation, v
523
554
 
524
555
  ## Image crop and focal point (RR-25)
525
556
 
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.
557
+ 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
558
 
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`.
559
+ **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
560
 
530
561
  ## Phone and tablet (RR-25)
531
562
 
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>;
package/dist/canvas.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createEditorSession,
3
+ mergeCatalogs,
3
4
  opfPathToJsonPointer,
4
5
  applyJsonPatch,
5
6
  getValueAtPath,
@@ -67,12 +68,14 @@ export function createCanvasEditor(container, options = {}) {
67
68
  win = doc.defaultView;
68
69
  const editor =
69
70
  options.editor ??
70
- createEditorSession(options.presentation, { rejectInvalid: true });
71
+ createEditorSession(options.presentation, { rejectInvalid: true, catalogs: options.catalogs });
71
72
  // `options.fonts` is the renderer's fonts handle (`loadFonts()`): it measures the text and draws the faces. A document whose faces
72
73
  // are not loaded yet is never rendered: the canvas has the handle load them first and shows "Loading fonts…" meanwhile. Without a
73
74
  // handle every document renders at once, with core's portable text estimate.
74
75
  const handle = options.fonts, fonts = fontGate(handle);
75
- const drawOptions = (extra) => ({ ...renderOptions, fonts: previewFonts(handle), ...extra });
76
+ // The renderer draws with the session's registered catalogs (plus any `renderOptions.catalogs`): the same list core resolves with.
77
+ const resolveOptions = () => ({ ...renderOptions, catalogs: mergeCatalogs(editor.catalogs, renderOptions.catalogs) });
78
+ const drawOptions = (extra) => ({ ...resolveOptions(), fonts: previewFonts(handle), ...extra });
76
79
  let slideIndex = options.slideIndex ?? 0,
77
80
  // RR-32: the canvas edits the document as authored, so a template's {{tokens}} stay visible and an inline edit never
78
81
  // overwrites one with its resolved text. The Fill template panel previews the resolved deck. Pass `variables` to override.
@@ -180,11 +183,11 @@ export function createCanvasEditor(container, options = {}) {
180
183
  // A synchronous render or validation of a document whose faces are still loading fails with a clear "fonts-pending"
181
184
  // error and starts the load, so the next attempt succeeds. It never draws glyphs the registry cannot provide.
182
185
  function requireFonts(presentation) {
183
- const pending = fonts?.pending(presentation, renderOptions) ?? [];
186
+ const pending = fonts?.pending(presentation, resolveOptions()) ?? [];
184
187
  if (!pending.length) return;
185
188
  notice.hidden = false;
186
189
  notice.textContent = pendingMessage;
187
- fonts.ensure(presentation, { renderOptions }).then(
190
+ fonts.ensure(presentation, { renderOptions: resolveOptions() }).then(
188
191
  () => { if (!disposed && notice.textContent === pendingMessage) clearNotice(); },
189
192
  (error) => { if (!disposed) report(error); },
190
193
  );
@@ -223,7 +226,7 @@ export function createCanvasEditor(container, options = {}) {
223
226
  if (disposed) return;
224
227
  const token = ++showToken;
225
228
  return whenFontsReady(fonts, target ?? editor.presentation, {
226
- renderOptions,
229
+ renderOptions: resolveOptions(),
227
230
  isCurrent: () => !disposed && token === showToken,
228
231
  loading: (pending) => fontsState("loading", pending),
229
232
  ready: () => {
@@ -239,7 +242,7 @@ export function createCanvasEditor(container, options = {}) {
239
242
  }
240
243
  // Renders now when the document's fonts are loaded (errors throw to the caller), otherwise after loading them.
241
244
  function renderFor(presentation) {
242
- if (fonts?.pending(presentation ?? editor.presentation, renderOptions).length) show(presentation);
245
+ if (fonts?.pending(presentation ?? editor.presentation, resolveOptions()).length) show(presentation);
243
246
  else render(presentation);
244
247
  }
245
248
  function render(presentation = editor.presentation) {
@@ -366,11 +369,11 @@ export function createCanvasEditor(container, options = {}) {
366
369
  }
367
370
  // An in-progress edit whose text needs faces that are not loaded yet waits for them, then draws again.
368
371
  function deferDraft(draft) {
369
- if (!fonts?.pending(draft, renderOptions).length) return false;
372
+ if (!fonts?.pending(draft, resolveOptions()).length) return false;
370
373
  active.valid = false;
371
374
  notice.hidden = false;
372
375
  notice.textContent = pendingMessage;
373
- fonts.ensure(draft, { renderOptions }).then(
376
+ fonts.ensure(draft, { renderOptions: resolveOptions() }).then(
374
377
  () => {
375
378
  if (disposed) return;
376
379
  if (notice.textContent === pendingMessage) clearNotice();
@@ -1020,7 +1023,7 @@ export function createCanvasEditor(container, options = {}) {
1020
1023
  get layoutEditing() { return layoutHandles.enabled; },
1021
1024
  /** True while the crop layer is open. */
1022
1025
  get cropping() { return !!imageCropper?.isOpen; },
1023
- /** Open the crop layer for the picture at `path` (an image block, a slide's `image`, a slide image). `tool: "focus"` starts with the focal point. */
1026
+ /** Open the crop layer for the picture at `path` (an image block or a slide's `image`). `tool: "focus"` starts with the focal point. */
1024
1027
  cropImage(path, cropOptions) {
1025
1028
  if (!imageCropper) return Promise.resolve(false);
1026
1029
  return imageCropper.open(path, cropOptions);
@@ -0,0 +1,48 @@
1
+ import type { Catalog, CatalogKind, CatalogRecordChange, EmbeddedRecord, UnresolvedReferenceDiagnostic, ValidationReport } from "@openpresentation/opf";
2
+ import type { CatalogOption, EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
3
+
4
+ /** One catalog list: the session's registration first, then each host list; the first catalog with a `source` wins. Throws `invalid-catalogs` for an entry without a source. */
5
+ export declare function mergeCatalogs(...lists: (readonly Catalog[] | Catalog | undefined | null)[]): readonly Catalog[];
6
+ /** The catalogs for one operation: `editor.catalogs`, then `options.catalogs`. */
7
+ export declare function catalogsFor(editor: EditorSession | undefined, options?: { catalogs?: readonly Catalog[] }): readonly Catalog[];
8
+ /** A record's picker label: its name, then its gallery label, then the id. */
9
+ export declare function catalogRecordLabel(record: Record<string, unknown> | undefined, id: string): string;
10
+ /** Core's `catalogRecords` as picker options: embedded records first, then registered ones; `id` is the reference to write. */
11
+ export declare function listCatalogRecords(presentation: unknown, kind: CatalogKind, options?: { catalogs?: readonly Catalog[] }): CatalogOption[];
12
+
13
+ export interface SavedDocument {
14
+ /** The self-contained document: every referenced record embedded once under its group, `x-*` stripped. */
15
+ document: unknown;
16
+ added: EmbeddedRecord[];
17
+ unresolved: UnresolvedReferenceDiagnostic[];
18
+ }
19
+ /** Core's `embed`: the document to save or hand on, which renders the same with no catalog registered. The input is not changed. */
20
+ export declare function prepareSave(presentation: unknown, options?: { catalogs?: readonly Catalog[] }): SavedDocument;
21
+ /** `prepareSave` of the session's document with its registered catalogs (and `options.catalogs`). The session is not changed. */
22
+ export declare function saveDocument(editor: EditorSession, options?: { catalogs?: readonly Catalog[] }): SavedDocument;
23
+
24
+ export interface CatalogUpdateCheck {
25
+ /** One per embedded record (under `default` or a named group) whose registered record differs. */
26
+ changes: CatalogRecordChange[];
27
+ /** RFC 6902; apply only after the author approves (`applyCatalogUpdate`). */
28
+ patch: JsonPatchOperation[];
29
+ }
30
+ /** The "Update from catalog" check (core's `updateFromCatalog`). Applies nothing. `refs` limits it to some references. */
31
+ export declare function checkCatalogUpdates(presentation: unknown, options?: { catalogs?: readonly Catalog[]; refs?: readonly { kind: CatalogKind; reference: string }[] }): CatalogUpdateCheck;
32
+ /** Apply the approved updates (`refs`) as ONE undoable step, recomputed against the session's current document. */
33
+ export declare function applyCatalogUpdate(
34
+ editor: EditorSession,
35
+ options: { refs: readonly { kind: CatalogKind; reference: string }[]; catalogs?: readonly Catalog[]; meta?: Record<string, unknown> },
36
+ ): EditorChange & { changed: boolean; changes: CatalogRecordChange[] };
37
+
38
+ export declare const REFERENCE_FINDING_CODES: readonly ["opf/unresolved-reference", "opf/undeclared-catalog", "opf/catalog-record-not-in-source"];
39
+ /** The reference and embedded-record findings (`REFERENCE_FINDING_CODES`) of a report. */
40
+ export declare function referenceFindings(validation: ValidationReport | undefined): ValidationReport["findings"];
41
+
42
+ export interface CatalogRecordRef { group: string; kind: CatalogKind; id: string }
43
+ /** The record a `opf/catalog-record-not-in-source` finding path names (`/catalogs/<group>/<kind>/<id>`). */
44
+ export declare function catalogRecordAt(path: string): CatalogRecordRef | undefined;
45
+ /** Move a record embedded under `default` or a named group into `catalogs.custom`, rewriting the references that resolved to it. */
46
+ export declare function prepareMoveToCustom(presentation: unknown, target: CatalogRecordRef, options?: { catalogs?: readonly Catalog[] }): { presentation: unknown; patches: JsonPatchOperation[]; changed: boolean; from: CatalogRecordRef; to: CatalogRecordRef };
47
+ /** `prepareMoveToCustom` as one undoable step on the session. */
48
+ export declare function moveToCustom(editor: EditorSession, target: CatalogRecordRef, options?: { catalogs?: readonly Catalog[]; meta?: Record<string, unknown> }): EditorChange & { changed: boolean; from: CatalogRecordRef; to: CatalogRecordRef };