@openpresentation/opf-editor 0.10.6 → 0.11.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 (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
package/README.md CHANGED
@@ -2,7 +2,7 @@
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
- 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
+ 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
6
 
7
7
  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
8
 
@@ -55,11 +55,16 @@ Version 0.7.0 uses core 0.10.0 and renderer 0.8.0. Code source/metadata edits pr
55
55
  - Structured catalog controls that only commit known catalog IDs
56
56
  - JSON Patch state transitions with inverse patches for undo/redo
57
57
  - Optional DOM controls plus React and Svelte bindings in separate embeddable entry points
58
+ - 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`)
59
+
60
+ - Dimension switches, safe block conversion, design-level options, table style and cell merge, captions, references, citations and footnotes as headless APIs (`/switches`, `/block-convert`, `/design-options`, `/tables`, `/assets`, `/backgrounds`, `/annotations`) and one accessible DOM panel (`/design-controls`)
58
61
 
59
62
  ## Live browser canvas
60
63
 
61
64
  The new `@openpresentation/opf-editor/canvas` entry provides a framework-independent SVG canvas with inline text/table-cell editing, live validated drafts, undo/redo, cancellation, and structured property forms for charts, lists, metrics, quotes, code, timelines and images. Mount it in a DOM container:
62
65
 
66
+ Chart options (RR-35): `@openpresentation/opf-editor/chart-options` edits a chart's `axisTitles`, `legend` and `dataLabels` as one undoable patch (`setChartOptions(editor, chartPath, { legend: "bottom", dataLabels: { position: "inside-end" } })`), and `@openpresentation/opf-editor/chart-options-panel` mounts the control panel (`createChartOptionsPanel(host, { editor, getSelectedPath })`). The panel offers only what the chart type can show.
67
+
63
68
  ```js
64
69
  import { createCanvasEditor } from '@openpresentation/opf-editor/canvas';
65
70
 
@@ -108,6 +113,10 @@ Plain-text carets are resolved from the rendered SVG glyphs: each line carries i
108
113
 
109
114
  The canvas retains canonical SVG glyphs while a transparent native input supplies the caret. Advanced shaping, freeform object positioning, all chart/media treatments, and cross-engine pixel identity remain work in progress. The Source dialog in the playground now provides a live JSON preview; changes are validated before committing.
110
115
 
116
+ ## List numbering (RR-33)
117
+
118
+ `@openpresentation/opf-editor/numbering` is the headless model and `/numbering-panel` the DOM control for the `numbering` field of `items` and `bullets` payloads (a style name, a `{ style, start, suffix }` object, or an array per list level). `setNumbering(editor, path, value)` numbers the list a path points at or into (`undefined` turns numbering off and drops the entry `start` values), `setEntryStart(editor, itemPath, start)` restarts the count at an entry, `numberingValue(levels)` writes the shortest form, `numberingState(document, path)` reads the settings and the markers the list draws, and `findNumberableLists(document, slideIndex)` lists a slide's lists. Each write is one validated, undoable session edit. `createNumberingPanel(container, { editor, getTarget, getSlideIndex, onStatus })` mounts the control: number this list, style, start and suffix, different numbering per level, the markers it will draw, and a restart at the selected entry. The playground opens it from **List numbering**. Needs a core release that ships `numbering`; see core's [numbered lists](https://github.com/OpenPresentation/opf/blob/main/docs/numbered-lists.md).
119
+
111
120
  ## Runtime Policy
112
121
 
113
122
  The package runtime must stay local and deterministic:
@@ -211,26 +220,342 @@ const { patches, document } = prepareDimensionSwitch(editor.document, "themes",
211
220
  | Dimension | Document patch | Notes |
212
221
  | --- | --- | --- |
213
222
  | `layouts` | `/slides/N/layout` | Needs `slideIndex`. Adds the blank payloads the layout declares, like the JSON editor's layout choice; existing content stays. |
214
- | `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. |
223
+ | `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 }`). |
215
224
  | `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. |
216
225
  | `languages`, `narratives`, `tones`, `audiences` | `/language`, `/narrative`, `/tone`, `/audience` | Catalog ids; `audiences` accepts an id or an array. |
217
226
  | `backgrounds` | `/design/background` | A background object or a shorthand string (theme slot such as `dark1`, or a hex color). |
218
227
  | `headers-footers` | `/design/header`, `/design/footer` | `{header?, footer?}`: an absent field stays, `null` removes it. |
219
228
  | `image-treatments` | `/design/slideImage`, `/design/imageFill` | `{slideImage?, imageFill?}`, same rule. |
220
229
  | `socials` | `/speaker/socials/<platform>` or the `organization` | `{platform, handle}` with `owner` and `index`; the owner must exist. |
221
- | `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: the editor does not verify that the data suits the new type, so preview the result (map types, for example, expect their own data). |
222
- | `blocks` | replaces one block | `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. |
230
+ | `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). |
231
+ | `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*. |
223
232
 
224
233
  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. `resolveSlideFonts(document, slideIndex)` returns the heading, body and code families the preview measures and the export names.
225
234
 
226
- ### Content-type conversion: replacement only (FF-16 decision)
235
+ ### Content-type conversion (RR-06, RR-26)
236
+
237
+ `blocks` replaces a block by default and discards its old payload. Pass `convert: true` (or call `convertBlock`) to move the block's own content into the new kind instead. A conversion keeps the user's content and never adds any: no value, label, date, number or sentence is invented, what a target kind cannot carry is reported in `loss` rather than dropped silently, and a pair with no meaningful mapping is refused. The converters themselves are pure functions in core, `@openpresentation/opf/convert` (see [content conversions](https://github.com/OpenPresentation/opf/blob/main/docs/conversions.md) for every pair, its loss report and the decisions behind them); this entry point is the transaction around them: it finds the block, guards the patch with a `test` of what it read, validates the document and applies one undoable step.
238
+
239
+ ```js
240
+ import { convertBlock, blockConversionTargets, prepareBlockConversion } from "@openpresentation/opf-editor/block-convert";
241
+
242
+ blockConversionTargets(editor.document, "slides.2.blocks.0");
243
+ // [{ kind: "list", label: "List", available: true, lossless: true, loss: [] }, { kind: "metric", available: false, reason: "The first line is longer than 24 characters, ..." }, ...]
244
+ const change = convertBlock(editor, "slides.2.blocks.0", "list"); // one undoable step
245
+ change.lossless; change.loss; // for example [] or ["text formatting", "list nesting levels"]
246
+ convertBlock(editor, "slides.2.blocks.0", "table", {}, { delimiter: "," }); // the fifth argument is core's conversion options
247
+ switchDimension(editor, "blocks", "list", { path: "slides.2.blocks.0", convert: true }); // the same through the switch (`conversion` carries the options)
248
+ ```
249
+
250
+ | From | To | What happens |
251
+ | --- | --- | --- |
252
+ | text | list | One item per line; indentation and `-`, `*`, `1.` markers become nesting levels (numbering is reported); blank lines are dropped and reported. Run formatting stays. |
253
+ | text | quote | The text is the quote. A trailing dash line (`— Name, Title`, `–`, `--`, `~`, `-`) is the attribution and a second one the source; only unambiguous endings are read. Formatting is flattened and reported. |
254
+ | text | metric | The first line (at most 24 characters) is the value (a canonical number becomes a number), the second the label, the rest the description. Refused when the first line is longer. |
255
+ | text | code | One fenced block gives the source, the language and the file name; nothing is guessed from the code. |
256
+ | text | timeline | One event per line; `2024 — Launch`, `Q1 2026: Pilot` and `Jan - Kickoff` give `when`; an indented line is the previous event's description. |
257
+ | text | table | A Markdown pipe table, tab-separated lines or a `delimiter`; refused without that structure. |
258
+ | list | text, timeline, table | Nesting becomes indentation (lossless) or is reported; a description is kept as an indented line or an event description; a table has one column, or text and description, with no invented headings. |
259
+ | quote, metric, code | text | The quote, then `— attribution` and `— source`; the metric as `value unit`, label, description and delta (a trend is reported as lost); code in a fenced block that keeps its language and file name. |
260
+ | timeline | text, list, table | `when: what` per event with the description indented; a table has `When`, `What`, `Description` columns for the fields in use. Lossless apart from the timeline name and description. |
261
+ | chart | table | Inline data only. A chart's type is reported as lost. |
262
+ | table | chart | Needs a plain label for every column and numbers after the first; styled, merged or rich cells and external data are refused. |
263
+ | table | list, timeline, text, metric blocks | First column is the item; columns are read by heading (`When`, `What`, `Value`, ...); Markdown or tab-separated text. Dropped columns and headings are reported. |
264
+ | group of metrics | table | A group (or a slide, or a region) whose blocks are all metrics; only the columns in use. |
265
+
266
+ Images, videos and any other group have no conversion. Everything else is replacement. `blockPathForSelection(document, selectedPath)` maps a selection such as `slides.0.blocks.1.text` to its block for a host that offers the control on selection, and `metricGroupForSelection` finds the group of metrics around a selected metric. Conversions are guarded by a `test` operation, so one built from a stale read cannot overwrite a concurrent edit.
267
+
268
+ ### Content actions (RR-26)
269
+
270
+ `@openpresentation/opf-editor/content-actions` holds the other pure transforms of core's `/convert` as editor transactions. Every action has a `prepare...` form that returns `{ document, patches, path, changed, lossless, loss, reason }` without touching a session (pass `{ validate: false }` for a dry run that only needs the loss report), and an applying form that is one guarded, validated, undoable step. A refusal throws `content-action-refused` with core's reason.
271
+
272
+ | Action | Applying form | What it does |
273
+ | --- | --- | --- |
274
+ | 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. |
275
+ | 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). |
276
+ | 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. |
277
+ | 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. |
278
+ | 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. |
279
+
280
+ 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.
281
+
282
+ ### Pickers: options, chart types and current values
283
+
284
+ `listSwitchOptions(document, 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(document, { 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, and the single-series, distribution and geographic types are offered only where they fit. It is data-shape compatibility, not a claim that an engine draws the type. `currentSwitchValue(document, dimension, { slideIndex })` reads the value back as `{ value, scope }`.
285
+
286
+ ## Design options (RR-06)
287
+
288
+ `@openpresentation/opf-editor/design-options` edits the design-level settings that used to need All properties. Each call is one validated patch and one undo step, at the deck or on one slide with `slideIndex`; `null` removes a value so it is inherited again. `prepare...` forms return the patch without touching a session.
289
+
290
+ ```js
291
+ import { setDesignOption, setLogoVariant, setHeaderFooterZone, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
292
+
293
+ setDesignOption(editor, "titleAlignment", "center", { slideIndex: 2 }); // /slides/2/design/titleAlignment
294
+ setDesignOption(editor, "watermark", { src: "asset:mark", opacity: 0.1 }); // merges into an existing watermark; false hides an inherited one
295
+ setLogoVariant(editor, "light", "asset:logo-white"); // default stays a bare source; more variants make a LogoSet
296
+ setHeaderFooterZone(editor, "footer", "right", { slideNumber: true, logo: true });
297
+ ```
298
+
299
+ | Option | Writes | Values |
300
+ | --- | --- | --- |
301
+ | `titleAlignment`, `contentAlignment` | `design.titleAlignment`, `design.contentAlignment` | `left`, `center`, `right` |
302
+ | `contentDirection` | `design.contentDirection` | `horizontal`, `vertical` |
303
+ | `chartPrimary` | `design.chartPrimary` | `none`, `top`, `bottom`, `left`, `right` |
304
+ | `listBullet` | `design.listBullet` | `character`, `image` (picture bullets draw the logo) |
305
+ | `contentBox` | `design.contentBox` | `true`, `false` |
306
+ | `accentFont` | `design.fontScheme.accent.family` | a family name; the font scheme becomes its object form and collapses back to the bare id when the accent is cleared |
307
+ | `logo` | `design.logo` | a source, an Asset object or a LogoSet; `setLogoVariant` edits one of the 12 variants |
308
+ | `organizationLogo` | `organization.logo` (deck only; `index` picks an organization) | a source or Asset object |
309
+ | `watermark` | `design.watermark` | `false`, a source, or `{ src, opacity }` (fields merge; a lone source stays a bare source) |
310
+ | `slideImage` | `design.slideImage` | a source or `{ src, position, size, fill, shape, inset, ... }` (fields merge; `position` defaults to `background` because the object form requires it) |
311
+ | 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`, `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 |
312
+
313
+ 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(document, slideIndex)` does for the current document. `DESIGN_OPTIONS` describes every option for a generic panel, and `getDesignOption` reads `{ value, scope, inherited }`.
314
+
315
+ ### Image uploads (RR-06)
316
+
317
+ `@openpresentation/opf-editor/assets` turns a local file into an `assets` entry and uses it in the same undoable patch:
318
+
319
+ ```js
320
+ import { applyImageUpload } from "@openpresentation/opf-editor/assets";
321
+ import { prepareLogoVariant } from "@openpresentation/opf-editor/design-options";
322
+
323
+ const change = await applyImageUpload(editor, file, (reference, document) => prepareLogoVariant(document, "light", reference), { alt: "Acme logo" });
324
+ 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"
325
+ ```
326
+
327
+ `build(reference, document)` 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.
328
+
329
+ ### Backgrounds (RR-06)
330
+
331
+ `@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.
332
+
333
+ ## Footnotes, citations and captions (RR-34)
334
+
335
+ `@openpresentation/opf-editor/annotations` edits the core RR-34 fields as validated, undoable session edits (a `test` guard on the edited container, then one replace), so the canvas redraws the caption band, the superscript markers and the slide's footnote area, and Undo restores the document:
336
+
337
+ ```js
338
+ import { setCaption, addReference, citeRun, setFootnote, listCitations, referencesSlideFor } from "@openpresentation/opf-editor/annotations";
339
+
340
+ setCaption(editor, "slides.1.blocks.0", { text: "Figure 1. Adoption by year", align: "center" }); // image, chart, table or video block
341
+ addReference(editor, { id: "gartner-2026", text: "Gartner, Market Guide, 2026", url: "https://www.gartner.com" });
342
+ citeRun(editor, "slides.0.text.0", "gartner-2026"); // the run shows a superscript marker; the slide lists the reference
343
+ setFootnote(editor, "slides.0.text.1", "Internal forecast, not audited.");
344
+ listCitations(editor.document); // the numbering every engine draws: notes, references, per-slide markers, unused ids
345
+ referencesSlideFor(editor.document, { title: "Sources" }); // an ordinary list slide to insert
346
+ ```
347
+
348
+ `captionTargets`, `readCaption`, `listReferences`, `updateReference`, `removeReference` (refuses while a run cites the id unless `force`, which also removes those cites), `unciteRun` and `runAt` complete the set; every `prepare*` variant returns the patches and the validated candidate without applying them. A string run becomes an object run when it gains a cite or footnote and returns to a string when nothing is left. The module needs the core that ships the fields; on an older core `listCitations` and `referencesSlideFor` throw `annotations-unavailable`.
349
+
350
+ ## Table style and cell merge (RR-06)
351
+
352
+ `@openpresentation/opf-editor/tables` edits the styled-cell structure with one guarded patch per call (a `test` of the table, then a replace), so the canvas draws the styled and merged table and Undo restores it:
353
+
354
+ ```js
355
+ import { setTableStyle, mergeTableCells, splitTableCell, setTableCellStyle, parseTableCellPath } from "@openpresentation/opf-editor/tables";
356
+
357
+ setTableStyle(editor, "slides.4.blocks.1.table", "banded"); // or { header: "accent", banding: true, borders: "horizontal" }
358
+ mergeTableCells(editor, "slides.4.blocks.1.table", { section: "body", row: 1, column: 0 }, { colSpan: 3 });
359
+ splitTableCell(editor, "slides.4.blocks.1.table", { section: "body", row: 1, column: 0 });
360
+ setTableCellStyle(editor, "slides.4.blocks.1.table", [{ section: "header", column: 0 }], { fill: "accent", align: "center" });
361
+ ```
362
+
363
+ 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.
364
+
365
+ ## Slide management (RR-21)
366
+
367
+ `@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 (`{ document, 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`).
368
+
369
+ ```js
370
+ import { addSlide, duplicateSlides, removeSlides, moveSlides, moveSlidesBy, setHidden, addSection, renameSection, removeSection, moveSection, setSection, listSections } from "@openpresentation/opf-editor/slides";
371
+
372
+ addSlide(editor, { at: 2, layout: "list-2x" }); // the layout's placeholders become empty slots, as when switching a slide's layout
373
+ duplicateSlides(editor, [1, 3]); // copies follow the last selected slide, with fresh ids (also for content ids)
374
+ removeSlides(editor, [4]); // a deck keeps at least one slide: cannot-remove-all-slides
375
+ moveSlides(editor, [0, 1], 5); // `to` is the drop gap in the original order (0 to the slide count)
376
+ moveSlidesBy(editor, [2], -1); // Alt+Up: a block moves together, a scattered selection one place each
377
+ setHidden(editor, [3], true); // OPF `hidden: true`; showing removes the field. Omit the flag to toggle.
378
+ addSection(editor, 3, "Details"); // a section starts at slide 3 and runs to the end of its current section
379
+ ```
380
+
381
+ Sections are OPF's `section` label on each slide: consecutive slides with the same label form a section, slides without a label form an unnamed run (`listSections` returns `{ index, name, start, count, unnamed }`). Moving slides takes a section decision, `section: "adopt"` (default: the slides join the section of the slide just before them, or after them at the start), `"keep"`, a name, or `null`; drag and drop passes the section of the slide you drop beside, and dropping on a section header starts that section. `renameSection`, `removeSection` (its slides join the section before; `deleteSlides: true` deletes them too) and `moveSection` take an index from `listSections`. Collapsing a section in the list is view state, not part of the document.
382
+
383
+ `@openpresentation/opf-editor/slide-manager` mounts the slide list as the navigator or, with `variant: "sorter"`, as a thumbnail grid. It selects (click, Ctrl/Cmd+click, Shift+click, Shift+arrows, Ctrl+A), reorders by drag and drop and by keyboard (Alt+arrows; Alt+Left and Right in the sorter), duplicates (Ctrl/Cmd+D), deletes (Delete), and has a toolbar and a context menu (the context menu key, Shift+F10 or a right click) with hide, sections, move to start or end, add with layout and, when the host passes the RR-26 split and merge functions as `contentActions`, split and merge. Every result is announced in a polite live region; the roving tab stop is the current slide; the card names say position, title and whether the slide is hidden or selected.
384
+
385
+ ```js
386
+ import { createSlideManager } from "@openpresentation/opf-editor/slide-manager";
387
+ const manager = createSlideManager(document.querySelector("#slide-list"), {
388
+ editor, getSlideIndex: () => current, setSlideIndex: (index) => { current = index; redraw(); },
389
+ renderThumbnail: (deck, index) => renderSvg(deck, { slideIndex: index, trace: false }), toolbar: document.querySelector("#slide-toolbar"),
390
+ contentActions, // optional: { splitSlideByBlocks, mergeSlides } from "@openpresentation/opf-editor/content-actions"
391
+ });
392
+ editor.subscribe(() => manager.render());
393
+ ```
394
+
395
+ `@openpresentation/opf-editor/outline` and `/outline-view` edit the deck as an outline: a row per slide title, subtitle, text paragraph and list item, and a read-only row for content that is not text (a chart, a table, an image) or text that carries formatting, so nothing is flattened away. Typing commits as one change when the row loses focus or on Enter. Enter adds a line (after a title: a slide), Alt+Up and Alt+Down move a bullet with the bullets nested under it, or a slide with its text, Alt+Shift+Right demotes and Alt+Shift+Left promotes. Demoting a bullet nests it (`level`); promoting a top-level bullet turns it into a new slide that takes the bullets after it; demoting a plain slide makes it a bullet of the slide before it (refused, with the reason, when that would drop content such as notes, blocks or a design). Backspace on an empty line removes it. Tab keeps moving focus, so the outline is no keyboard trap.
227
396
 
228
- The editor does not convert one content type into another. `blocks` is block replacement only: the old payload is discarded (text is not turned into list items, a list into a chart, and so on) and the block keeps only its `id` and `extensions`. Author the replacement content explicitly, or insert and remove blocks. This release provides no conversion API.
397
+ ## Data grid (RR-24)
398
+
399
+ `@openpresentation/opf-editor/data-grid` mounts a spreadsheet-like grid for a chart's inline data (the first column is the categories, each further column a series; an empty cell is a gap, never 0) and for tables (rich and styled cells, merged cells drawn with their spans). Cells edit from the keyboard (arrows, Enter, F2, Tab), paste TSV or CSV from Excel and Sheets, copy out as TSV, and rows and columns insert, delete and move, sort (stable and typed) and, for charts, swap. Numbers are read in one stated number format, never guessed; text that is not a number is refused inline with the reason. Every edit is one undoable patch, so the preview redraws from the session events.
400
+
401
+ ```js
402
+ import { createDataGrid } from "@openpresentation/opf-editor/data-grid";
403
+ import { insertTableRows, deleteTableColumns, sortTableRows, setTableHeader } from "@openpresentation/opf-editor/tables";
404
+ import { setChartCells, transposeChart, renameChartSeries } from "@openpresentation/opf-editor/chart-data";
405
+
406
+ createDataGrid(container, { editor, getSelectedPath: () => selectedPath });
407
+ insertTableRows(editor, "slides.4.blocks.1.table", 2); // merged cells grow, never split
408
+ sortTableRows(editor, "slides.4.blocks.1.table", 1, { direction: "desc" }); // stable, typed, empty cells last
409
+ setChartCells(editor, "slides.3.blocks.0.chart", [{ section: "body", row: 0, column: 1, text: "12,5" }], { decimal: "," });
410
+ ```
411
+
412
+ The number rules, paste and copy format, merged-cell behaviour, sorting, keyboard and accessibility are in [docs/data-grid.md](docs/data-grid.md).
413
+
414
+ ## Design controls panel (RR-06)
415
+
416
+ `@openpresentation/opf-editor/design-controls` mounts the controls for everything above in one call. Each control commits one undoable change through the session, so a host that already subscribes to the session (the canvas does) redraws the preview, loads fonts first through its font gate, and the controls themselves follow Undo and Redo.
417
+
418
+ ```js
419
+ import { createDesignControls } from "@openpresentation/opf-editor/design-controls";
420
+
421
+ const controls = createDesignControls(designPanel, {
422
+ editor,
423
+ getSlideIndex: () => slideIndex,
424
+ getSelectedPath: () => selectedPath,
425
+ sections: ["look", "slide-image", "header-footer", "brand", "layout-options", "info"],
426
+ });
427
+ const selectionControls = createDesignControls(contentPanel, { editor, getSlideIndex, getSelectedPath, sections: ["selection", "table", "slide-content"], onSelectPath: select });
428
+ controls.refresh(); // when the slide or the selection changes
429
+ ```
430
+
431
+ 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.
432
+
433
+ In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
434
+
435
+ ```jsx
436
+ useEffect(() => {
437
+ const controls = createDesignControls(ref.current, { editor, getSlideIndex: () => slide, getSelectedPath: () => selected });
438
+ return () => controls.destroy();
439
+ }, [editor]);
440
+ // call controls.refresh() (keep it in a ref) when slide or selected changes
441
+ ```
442
+
443
+ 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.
444
+
445
+ 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.
229
446
 
230
447
  ### What a switch does not establish
231
448
 
232
449
  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 only where the installed renderer draws `design.slideImage`. `test/switches.mjs` checks the patch, one undo step, undo/redo, and preview refresh for all 14 dimensions. `test/switches-export.mjs` exports after each switch, undo and redo and applies opf-pptx's FF-08 typeface check (`checkPptxTypefaces`) when the installed package has it; set `OPF_REQUIRE_FF08=1` to fail instead of skip when it does not. Published opf-pptx 0.9.1 does not include it.
233
450
 
451
+ ## Autosave and restore (RR-22)
452
+
453
+ `@openpresentation/opf-editor/persistence` keeps an editor session in this browser's own storage and brings it back after a reload. It is local only: the module makes no network request, and the data stays in the browser profile (IndexedDB, with localStorage as the fallback). A host that stores documents itself simply does not call it.
454
+
455
+ ```js
456
+ import { createPersistence } from "@openpresentation/opf-editor/persistence";
457
+ import { createPersistenceUi } from "@openpresentation/opf-editor/persistence-ui";
458
+
459
+ const ui = createPersistenceUi({ banner: document.querySelector("#restore-banner"), indicator: document.querySelector("#autosave-status") });
460
+ const persistence = createPersistence(editor, {
461
+ key: "my-document", // names this document; two documents with one key share a stored copy
462
+ storage: "indexeddb", // default; or "localstorage", "memory", false, or your own { get, set, delete } adapter
463
+ onRestorePrompt: ui.prompt, // or return "restore" | "discard" | "later" (or a promise) yourself
464
+ onStatus: ui.status, // "Saved on this device at 2:03 PM", or why autosave is off
465
+ beforeFlush: () => canvas.commit(), // commit a draft before every write and before the page unloads
466
+ });
467
+ await persistence.ready; // { available, offered }: storage opened and read (it does not wait for the user's decision)
468
+ await persistence.markSaved(); // after the host saved the document somewhere of its own: not unsaved, and the stored copy says so
469
+ ```
470
+
471
+ - **Writes** happen after a change, 800 ms after the last one (at most 5 s late), serialized, and never for a document that was only opened: a stored copy is not overwritten until the document changes. The undo and redo history is stored with it (the newest 200 entries, 2,000,000 bytes at most; set `includeHistory: false` to skip it). `flush()` writes now.
472
+ - **Restore** is offered when a stored copy differs from the document the session starts with. `restore()` puts the copy back as one undoable step; into a session nobody has edited it restores the undo history too (`restoreState`, which replays the history against the document and refuses one that does not belong to it, so a stale copy never corrupts the session). If the person keeps editing while the offer is open, the offered copy is first moved aside, so ignoring the prompt never loses it; after a reload the offer mentions the older copy (`restoreEarlier()`). `discard()` deletes the stored copy.
473
+ - **Unsaved changes.** `dirty` is true when the document differs from the last `markSaved()` (or from how the session started); `beforeunload` warns while it is true (`warnOnUnload: false` to opt out) and a final write is started on unload, `pagehide` and when the tab is hidden. A host that loads a document into the editor (the gallery hands a snippet over) calls `rebase()` so that document is neither autosaved nor warned about until the person changes it. The playground marks the document saved on Save OPF, not on a PowerPoint export (a PPTX is not the OPF document).
474
+ - **Degradation.** Private browsing, blocked site data, a failing read or a full store never throw: `status` says `unavailable` or `error` with a sentence the host shows ("... download it to keep it", "browser storage is full ..."), a full store first drops the undo history and then reports, and the next change tries again. Dirty tracking and the unload warning keep working without storage.
475
+ - The playground enables it with the key `opf-editor-playground`. A host page sets `globalThis.OPF_EDITOR_HOST = { persistence: { key, storage, onRestorePrompt } }` (or `persistence: false`) before the script runs; a frame can pass `?persist=<key>` or `?persist=0`. The footer text of the inspector is the autosave indicator and a prompt appears under the document bar.
476
+ ## Fill template panel (RR-32)
477
+
478
+ A template is an OPF file with variables (`{{id}}` tokens and `var:id` references, root `"template": true`; see [templates and variables](https://github.com/OpenPresentation/opf/blob/main/docs/templates-and-variables.md)). `/templates` is the headless model and `/template-panel` the DOM panel over it. Both need the core release that ships `resolveVariables` (older cores load them and throw `templates-unavailable`).
479
+
480
+ ```js
481
+ import { createTemplatePanel } from '@openpresentation/opf-editor/template-panel';
482
+ import { renderSvg } from '@openpresentation/opf-render/svg';
483
+
484
+ const panel = createTemplatePanel(container, {
485
+ editor,
486
+ // The live preview: the template drawn with the values typed so far (unfilled variables show their example).
487
+ renderPreview: ({ document, variables, slideIndex }) => renderSvg(document, { ...layoutOptions, variables, slideIndex }),
488
+ getTarget: () => ({ path: selectedPath, start, end }), // the text field a token is inserted into; omit to hide that section
489
+ onApply: () => redraw(),
490
+ });
491
+ ```
492
+
493
+ The panel lists every variable with the input its kind needs (text, number, date, color, link, one-entry-per-line list, and an image source with an asset pick or an uploaded file, 5 MB at most), marks which are filled, defaulted, optional or still needed, says where each is used, rejects a bad value in place, and previews the result as values change. **Fill the presentation** resolves the variables and replaces the document with the concrete deck as one validated, undoable edit (one Undo restores the template); **Fill what is ready** keeps the unfilled variables declared. **Insert a variable into text** inserts `{{id}}` into the selected text, optionally declaring a new variable in the same edit. A checkbox marks the document as a template.
494
+
495
+ The headless pieces are usable on their own: `listTemplateFields(document, values)`, `templateStatus`, `previewTemplate`, `createTemplateFill(editor)` (`set`, `setText`, `clear`, `reset`, `preview`, `apply({partial})`), `declareVariable`, `setTemplate`, `insertVariableToken(editor, path, id, {start, end, runIndex, format, declare})`, `variableToken`, `suggestVariableId`. Every write goes through the session. The canvas draws a template as authored (`renderOptions.variables` defaults to `false`), so its tokens stay visible and an inline edit never overwrites one with resolved text; the panel's preview draws the resolved deck. The playground adds a **Fill template** button.
496
+
497
+ ## Find and replace (RR-25)
498
+
499
+ `@openpresentation/opf-editor/find` is the headless model and `/find-panel` the DOM panel. The model lists every piece of text in a document (`collectSearchFields`: presentation name and description, header and footer text, and per slide the title, subtitle, tag, section, text and its runs, list items and bullets, code, string metrics and their label, unit and delta, quotes with attribution and source, timeline names, dates and events, chart column labels and string cells, table headers and cells including styled cells, image and video alt text, and speaker notes). Numbers, ids, asset references, URLs, colours and layout names are never searched or changed. `findMatches(document, query, { matchCase, wholeWord, regex, notes, slideIndex })` returns the matches with the field, offsets and a context snippet; an invalid regular expression comes back as `error` and an empty match is skipped. `replaceAll(editor, query, replacement, options)` replaces every match as **one** `applyPatch` (one undo step restores every field; a document the schema rejects changes nothing) and `replaceMatch(editor, match, query, replacement, options)` replaces one. In regular-expression mode the replacement reads `$&`, `$1` to `$99`, `$<name>`, `` $` ``, `$'` and `$$`; otherwise it is literal.
500
+
501
+ **Rich text.** A run array is searched as its runs joined, so a phrase that crosses a bold/plain boundary is found. A match inside one run changes only that run and every run keeps its formatting. A match that spans several runs is replaced with the formatting of the run that holds the first matched character (the Word and Google Docs rule): the matched characters of the later runs are removed, text before and after keeps its own formatting, and a run left empty is dropped (a field always keeps at least one run). A run written as a plain string stays a plain string.
502
+
503
+ `createFindPanel(container, { editor, canvas, goToSlide, onGoTo, ... })` mounts a docked, non-modal panel: find and replace fields, Match case / Whole word / Regex / This slide only, previous and next, Replace and Replace all (with an Undo button right there), and a results list. Choosing a result shows its slide and selects the nearest content the canvas can select (`canvas.reveal(path)`; a list item selects its list); the host puts speaker notes or the presentation name in view through `onGoTo`. `installFindShortcuts(panel)` wires Ctrl/Cmd+F (find) and Ctrl/Cmd+H or Ctrl/Cmd+Shift+H (find and replace; macOS reserves Cmd+H, so use Ctrl+H or Cmd+Shift+H there). In the panel: Enter / Shift+Enter or F3 / Shift+F3 step through matches, Enter in the replace field replaces the current match, Ctrl/Cmd+Alt+Enter replaces all, Esc closes and returns focus. The panel is a labelled dialog, its count is a polite live region, an invalid pattern is an alert, and the controls are 44px on a touch screen. A modal dialog that is open keeps its own Ctrl+F. The playground mounts it under the canvas toolbar.
504
+
505
+ ## Image crop and focal point (RR-25)
506
+
507
+ 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.
508
+
509
+ **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`.
510
+
511
+ ## Phone and tablet (RR-25)
512
+
513
+ At 900px and narrower the playground is one screen: the slide strip on top, the slide in the middle, and a bottom bar (Edit, Design, Find, Undo, Redo) whose Edit and Design open the inspector as a **bottom sheet** that leaves the slide in view above it; the header buttons sit behind More; nothing scrolls sideways down to 320px. On a touch screen the canvas grows each text block's selection target to about 44px (up to 24 slide units each way), a tap on text enters editing at that point, `touch-action: manipulation` removes the double-tap delay, and the field being typed in is scrolled above the on-screen keyboard (visual viewport). The inline field mirrors the slide text, which is smaller than 16px on a phone; iOS would zoom into it on focus, so the playground sets `maximum-scale=1` on the viewport only while a slide text field has the focus and restores it after (embedding hosts should do the same, or keep their own fields at 16px). `test/mobile-browser.mjs` drives six emulated devices (iPhone SE, iPhone 14 Pro, Pixel 7, a 320px Android, an iPad and an Android tablet: Chromium with each device's viewport, scale factor, touch and user agent) through the layout, the sheet, tap-to-type, find and replace by touch and a crop by touch; it does not replace a check on a real iOS Safari.
514
+
515
+ ## Review panel (RR-29)
516
+
517
+ `@openpresentation/opf-editor/review-panel` mounts the audit's findings next to the document: contrast, text that does not fit, missing alt text, reading order, fonts, links and more, from core's `auditPresentation` (the same rules as `opf audit`; see the [audit guide](https://github.com/OpenPresentation/opf/blob/main/docs/audit.md)). It needs a core release after 0.11.4 and reports `audit-unavailable` on an older one.
518
+
519
+ ```js
520
+ import { createReviewPanel } from "@openpresentation/opf-editor/review-panel";
521
+
522
+ const panel = createReviewPanel(container, {
523
+ editor,
524
+ getSlideIndex: () => slideIndex,
525
+ // the host's measured fonts, per slide, so overflow is judged like the preview
526
+ getAuditOptions: (deck) => ({ textMeasurement: (index) => measurementFor(deck, index) }),
527
+ onGoTo: ({ finding, target }) => select(target.slide, target.path), // target.path: the nearest existing field
528
+ onFocusField: ({ finding, fix, target }) => focusTextField(target.path), // a title, text, link, language or size
529
+ });
530
+ panel.refresh(); // after the host has redrawn (set autoRefresh: false) or after anything the session did not see
531
+ ```
532
+
533
+ Findings show a severity word, the slide and the rule id. **Go to** selects the content. A fix is a single undoable session edit and is refused when the document has changed since the finding (the list is refreshed instead): switching a failing colour to the readable one is a one-click safe fix; **Write alt text** opens a field in the panel (an `asset:` reference stores the text on the asset so every use has it); **Mark as decorative** (an empty alt) is an explicit choice flagged as one that changes meaning. A check can be hidden in the panel and shown again, and the list can be limited to errors and warnings or to the current slide. The list re-audits after every session change and follows Undo and Redo; arrow keys, Home and End move between findings, Escape cancels the alt-text field and focus stays on a finding after a fix. The headless `/review` entry (`runAudit`, `reviewFindings`, `applyReviewFix`, `setReviewAltText`, `markDecorative`) needs no DOM.
534
+
535
+ ## PDF, PNG and SVG downloads (RR-23)
536
+
537
+ `@openpresentation/opf-editor/export` turns the deck into a download in the page, next to the PowerPoint export; the playground's "PDF · PNG · SVG" button is a thin dialog over it.
538
+
539
+ ```js
540
+ import { exportDeck } from "@openpresentation/opf-editor/export";
541
+ const result = await exportDeck(editor.document, {
542
+ format: "pdf", // "pdf" | "png" | "svg"
543
+ slides: "all", // or "current" with slideIndex, or [slide numbers]; hidden slides only with includeHidden
544
+ pdfMode: "vector", // or "raster" (an image per slide); PNG and raster density: scale 1 to 4
545
+ renderOptions, fonts: fontGate, registry: fontRegistry,
546
+ signal, onProgress, onDiagnostic,
547
+ });
548
+ // result.download = { name, type, bytes }: one file, or a ZIP of the slides; result.diagnostics lists what to review.
549
+ ```
550
+
551
+ - **Same drawing as the preview.** The slides are drawn by `renderSvgDeck` with the host's `renderOptions` (the same `textMeasurement`), so a PNG or SVG is the preview, and the PDF is converted from those SVGs rather than laid out again.
552
+ - **Fonts.** The font gate loads the faces the deck needs before anything is drawn (a failure rejects with `fonts-unavailable`). Only faces the registry holds are embedded (bundled or hash-pinned, never a system font), only where a slide draws them, as `@font-face` data in each SVG and as subsets in the PDF. A face whose own license text is not OFL, Apache, MIT or UFL is left out and reported (`export-font-license`).
553
+ - **PDF** is the renderer's vector PDF (selectable text, vector shapes, embedded subsets, tagged structure), `mode: "raster"` is the image-only form. **PNG** is drawn on a canvas from the same SVG (within anti-aliasing of the renderer's resvg PNG) and is limited to 40 megapixels. **SVG** files are standalone (XML header, fonts embedded, no external references). Several files are packed in a ZIP (`createZip`, no dependency).
554
+ - **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`.
555
+ - **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.
556
+ - **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`.
557
+ - 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.
558
+
234
559
  ## Optional React Bindings
235
560
 
236
561
  React bindings are isolated under `@openpresentation/opf-editor/react` and require the host app to pass its React runtime. The core package does not add React to the critical path.
@@ -277,7 +602,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
277
602
 
278
603
  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.
279
604
 
280
- Version 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.
605
+ 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.
281
606
 
282
607
  ## Local interactive demo
283
608
 
@@ -329,7 +654,7 @@ Headless applications and agents can import `formatRichTextRange`, `replaceRichT
329
654
 
330
655
  The canvas can show draggable, keyboard-accessible dividers for root and nested composition tracks. Pass `layoutEditing: true`, or call `canvas.setLayoutEditing(true)`. A pointer drag produces live preview drafts and commits one undo step. Escape cancels; strict overflow prevents invalid fit. Automatic layouts become explicit grids when resized. Promoted regions stay fixed, while their nested groups can be resized.
331
656
 
332
- `prepareTrackResize(document, flow, boundary, fraction)` from `@openpresentation/opf-editor/layout` returns a candidate document and guarded patches for headless agents. Obtain `flow` from the shared renderer's `geometry.flows`; preview the candidate before applying. The editor supports JSON Patch `test` guards alongside add/replace/remove; failed guards leave state and history unchanged. This export is included in version 0.1.0.
657
+ `prepareTrackResize(document, flow, boundary, fraction)` from `@openpresentation/opf-editor/layout` returns a candidate document and guarded patches for headless agents. Obtain `flow` from the shared renderer's `geometry.flows`; preview the candidate before applying. The editor supports JSON Patch `test` guards alongside add/replace/remove/move/copy (RFC 6902, executed by core's `@openpresentation/opf/patch`, the module `opf edit` and `opf diff` also use); failed guards leave state and history unchanged. This export is included in version 0.1.0.
333
658
 
334
659
 
335
660
  ### Move complete blocks
@@ -0,0 +1,71 @@
1
+ import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
+
3
+ export type CaptionPosition = "below" | "above";
4
+ export type CaptionAlignment = "left" | "center" | "right";
5
+ export type RichText = string | ReadonlyArray<string | Record<string, unknown>>;
6
+ export interface CaptionSettings { text: RichText; position: CaptionPosition; align: CaptionAlignment }
7
+ export type CaptionInput = RichText | { text: RichText; position?: CaptionPosition; align?: CaptionAlignment };
8
+ export interface Reference { id: string; text: RichText; url?: string }
9
+
10
+ export declare const CAPTION_POSITIONS: readonly ["below", "above"];
11
+ export declare const CAPTION_ALIGNMENTS: readonly ["left", "center", "right"];
12
+ export declare const CAPTIONABLE_FIELDS: readonly ["image", "chart", "table", "video"];
13
+
14
+ export interface PreparedAnnotationChange {
15
+ action: "caption" | "add-reference" | "update-reference" | "remove-reference" | "cite" | "uncite" | "footnote";
16
+ /** The edited container path (the block, `references`, or the run array). */
17
+ path: string;
18
+ /** The validated candidate document. */
19
+ document: Record<string, unknown>;
20
+ /** A test guard plus one replace; empty when nothing changes. */
21
+ patches: JsonPatchOperation[];
22
+ changed: boolean;
23
+ field?: string;
24
+ id?: string;
25
+ runPath?: string;
26
+ ids?: string[];
27
+ removedCites?: string[];
28
+ }
29
+ export type AnnotationChange = (EditorChange & Omit<PreparedAnnotationChange, "document" | "patches">) | PreparedAnnotationChange;
30
+
31
+ export interface CaptionTarget { blockPath: string; field: string; caption?: CaptionSettings }
32
+ export interface CaptionState { blockPath: string; field: string; caption?: CaptionSettings }
33
+ export interface ReferenceState { index: number; id: string; text: RichText; url?: string; cited: boolean; number?: number }
34
+ export interface CitationNoteState { number: number; kind: "reference" | "footnote"; id?: string; text: RichText; sourcePath: string; url?: string; resolved: boolean }
35
+ export interface CitationState {
36
+ notes: CitationNoteState[];
37
+ references: CitationNoteState[];
38
+ unused: string[];
39
+ slides: { slideIndex: number; markers: { path: string; text: string; numbers: number[] }[]; notes: number[] }[];
40
+ }
41
+
42
+ /** Normalize a caption value to `{ text, position, align }`, or undefined. */
43
+ export declare function normalizeCaption(value: unknown): CaptionSettings | undefined;
44
+ /** Every block (and one-payload slide root) that can carry a caption, with its current caption. */
45
+ export declare function captionTargets(document: unknown): CaptionTarget[];
46
+ export declare function readCaption(document: unknown, blockPath: string | string[]): CaptionState;
47
+ export declare function prepareCaption(document: unknown, blockPath: string | string[], caption: CaptionInput | null): PreparedAnnotationChange;
48
+ export declare function setCaption(editor: EditorSession, blockPath: string | string[], caption: CaptionInput | null, meta?: Record<string, unknown>): AnnotationChange;
49
+
50
+ export declare function listReferences(document: unknown): ReferenceState[];
51
+ export declare function prepareReference(document: unknown, reference: Reference): PreparedAnnotationChange;
52
+ export declare function addReference(editor: EditorSession, reference: Reference, meta?: Record<string, unknown>): AnnotationChange;
53
+ export declare function prepareReferenceUpdate(document: unknown, id: string, fields: { text?: RichText; url?: string | null }): PreparedAnnotationChange;
54
+ export declare function updateReference(editor: EditorSession, id: string, fields: { text?: RichText; url?: string | null }, meta?: Record<string, unknown>): AnnotationChange;
55
+ /** Refuses while a run cites the id unless `force`, which also removes those cites. */
56
+ export declare function prepareReferenceRemoval(document: unknown, id: string, options?: { force?: boolean }): PreparedAnnotationChange;
57
+ export declare function removeReference(editor: EditorSession, id: string, options?: { force?: boolean }, meta?: Record<string, unknown>): AnnotationChange;
58
+
59
+ /** The run at a run path (`slides.0.text.2`): its run array path segments, index and value. */
60
+ export declare function runAt(document: unknown, runPath: string | string[]): { parts: string[]; index: number; run: string | Record<string, unknown> };
61
+ export declare function prepareCite(document: unknown, runPath: string | string[], ids: string | string[]): PreparedAnnotationChange;
62
+ export declare function citeRun(editor: EditorSession, runPath: string | string[], ids: string | string[], meta?: Record<string, unknown>): AnnotationChange;
63
+ export declare function prepareUncite(document: unknown, runPath: string | string[]): PreparedAnnotationChange;
64
+ export declare function unciteRun(editor: EditorSession, runPath: string | string[], meta?: Record<string, unknown>): AnnotationChange;
65
+ export declare function prepareFootnote(document: unknown, runPath: string | string[], text: RichText | null): PreparedAnnotationChange;
66
+ export declare function setFootnote(editor: EditorSession, runPath: string | string[], text: RichText | null, meta?: Record<string, unknown>): AnnotationChange;
67
+
68
+ /** The deck numbering the engines draw (core `collectCitations`). Throws `annotations-unavailable` on a core without it. */
69
+ export declare function listCitations(document: unknown): CitationState;
70
+ /** An ordinary list slide of the cited references (core `referencesSlide`). */
71
+ export declare function referencesSlideFor(document: unknown, options?: { title?: string }): Record<string, unknown>;