@openpresentation/opf-editor 0.17.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,13 +10,13 @@ Unfinished prepared shaping work is preserved in the [September 15 roadmap](docs
10
10
  | --- | --- |
11
11
  | The canvas, the preview, SVG downloads in a browser (the host serves its own font files) | nothing |
12
12
  | `loadFonts` from `/fonts-node` (a server, a test, a build script such as `npm run build:playground`) | the font packages of the pack: `@expo-google-fonts/roboto` and `roboto-mono` for `base`; also `arimo`, `caladea`, `cousine`, `gelasio`, `tinos` and `noto-sans` for `office`; a script face (`scripts: 'auto'`) needs its `@expo-google-fonts/noto-*` package. A missing one fails with `font-resource-unavailable` and names it |
13
- | `svgToPng` / `svgToPdf` from the Node entries (`/png`, `/pdf`) | `@resvg/resvg-js` (and `sharp`, `pdf-lib` as the renderer's README says); a missing one fails with `converter-missing` |
14
- | `exportDeck` PDF (vector), PNG, SVG | nothing: the browser entry `@openpresentation/opf-render/export-browser` imports no converter |
15
- | `exportDeck` raster PDF (`pdfMode: "raster"`) | `pdf-lib`, imported by the page and passed as the `pdfLib` option (`import * as pdfLib from "pdf-lib"`); without it the export rejects with `converter-missing` |
13
+ | `toPng` / `toPdf` from the Node entries (`/png`, `/pdf`) | `@resvg/resvg-js` (and `sharp`, `pdf-lib` as the renderer's README says); a missing one fails with `converter-missing` |
14
+ | `convert` PDF (vector), PNG, SVG | nothing: the browser entry `@openpresentation/opf-render/export-browser` imports no converter |
15
+ | `convert` raster PDF (`raster: true`) | `pdf-lib`, imported by the page and passed as the `pdfLib` option (`import * as pdfLib from "pdf-lib"`); without it the conversion rejects with `converter-missing` |
16
16
 
17
17
  ```js
18
18
  const pdfLib = await import("pdf-lib");
19
- await exportDeck(editor.presentation, { format: "pdf", pdfMode: "raster", pdfLib, fonts });
19
+ await convert(editor.presentation, { format: "pdf", raster: true, pdfLib, fonts });
20
20
  ```
21
21
 
22
22
  The playground imports `pdf-lib` for you when its raster PDF choice is used.
@@ -37,7 +37,7 @@ The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec
37
37
  ```
38
38
 
39
39
  `mergeCatalogs(...lists)` is the one merge (the session's list first, the first catalog per `source` wins); `catalogsFor(editor, options)` is the list for one call. Pickers list core's `catalogRecords` and write the reference it gives, `id` or `name:id`.
40
- - **Save and export embed.** `prepareSave`/`saveDocument`, `serializeOpfTransfer` (presentation and slide scope) and `exportDeck` call core's `embed`; the playground's OPF download and PPTX export do too.
40
+ - **Save and export embed.** `prepareSave`/`saveDocument`, `serializeOpfTransfer` (presentation and slide scope) and `convert` (`/export`) call core's `embed`; the playground's OPF download and PPTX export do too.
41
41
  - **Paste** (`prepareOpfImport`, insert mode) uses core's `copySlides`: groups match by `source`, identical records are reused, and the result's `renamed` (`custom-conflict`, `catalog-revision`, `group-name`) and `addedGroups` tell the user what changed.
42
42
  - **Update from catalog** is an explicit action: `checkCatalogUpdates(presentation, { catalogs })` lists the embedded records whose catalog record differs, and `applyCatalogUpdate(editor, { refs })` applies only the approved ones as one undoable step. The design panel's `catalog` section runs both and lists the `opf/unresolved-reference` and `opf/undeclared-catalog` findings (`referenceFindings(editor.validation)`).
43
43
  - **Editing a catalog's record forks it.** An edit of a layout, theme, colour scheme or font scheme embedded under `catalogs.default` or a named group (from a panel, Properties, Source or any `applyPatch`) moves it into `catalogs.custom` as `<id>-custom` (or the id in `meta.forkIds["<group>:<kind>:<id>"]`), rewrites every reference to it and drops the original, in the same undo step. The change and the session event carry `forked` and a `notice` ("… is now this presentation's own; it no longer receives catalog updates"); approved catalog updates and `moveToCustom` change records in place (`meta.catalogRecordEdit: "in-place"`). `moveToCustom(editor, { group, kind, id }, { id })` is the explicit form, offered for `opf/catalog-record-not-in-source`.
@@ -122,7 +122,7 @@ await canvas.ready;
122
122
  // canvas.destroy() when unmounting.
123
123
  ```
124
124
 
125
- Load the same font bytes into the browser with `loadFonts` from `@openpresentation/opf-render/fonts-browser` before mounting; the handle it returns is the one `fonts` option of the canvas, `exportDeck`, `editor.composeSlide` and `editor.paginateSlide`. The host owns font URLs, storage and collaboration. For non-Latin documents, pass the pinned script pack's location as `scriptBaseUrl` and the canvas calls `fonts.ensure(presentation)` after edits (`scripts: 'auto'`, FF-19): only the faces for the scripts a document draws are fetched, once, hash-verified, for example Noto Sans JP for Japanese; a Latin-only document fetches none. The playground does this from `./script-fonts/`, which `npm run build:playground` fills with the pinned faces. `onDraft` provides live document drafts; the session only changes on commit. Escape cancels. Concurrent edits to the selected payload cancel a stale draft.
125
+ Load the same font bytes into the browser with `loadFonts` from `@openpresentation/opf-render/fonts-browser` before mounting; the handle it returns is the one `fonts` option of the canvas, `convert` (`/export`), `editor.composeSlide` and `editor.paginateSlide`. The host owns font URLs, storage and collaboration. For non-Latin documents, pass the pinned script pack's location as `scriptBaseUrl` and the canvas calls `fonts.ensure(presentation)` after edits (`scripts: 'auto'`, FF-19): only the faces for the scripts a document draws are fetched, once, hash-verified, for example Noto Sans JP for Japanese; a Latin-only document fetches none. The playground does this from `./script-fonts/`, which `npm run build:playground` fills with the pinned faces. `onDraft` provides live document drafts; the session only changes on commit. Escape cancels. Concurrent edits to the selected payload cancel a stale draft.
126
126
 
127
127
  Fonts load before pixels (FF-41). A document can need faces the browser has not loaded yet: script faces for the languages it draws and vendored preview faces for the font families it resolves (Intos for Aptos, Open Sans, Barlow). The handle knows what is missing (`fonts.pending(presentation)`) and loads it (`await fonts.ensure(presentation)`), so the canvas needs no separate gate: with the handle as `fonts` it never renders such a document early: on every path (editor changes, undo and redo, imports, dimension switches, slide changes, in-progress edits) it shows "Loading fonts…", loads the faces and then renders. A load that fails is reported through `onFonts`/`onError` and offers a retry button; nothing retries by itself, and the failed document is not drawn.
128
128
 
@@ -137,7 +137,7 @@ const canvas = createCanvasEditor(container, { editor, fonts });
137
137
  // Other renders of your own: whenFontsReady(fonts, presentation, { ready: draw, failed: showError, loading: showSpinner })
138
138
  ```
139
139
 
140
- `session.composeSlide(index, { fonts })` and `session.paginateSlide(index, { fonts })` take the same handle and resolve the slide's canvas, layout, theme, colour scheme and font families with core's `resolveSlideContext` (slide design, then deck design, then theme, then the engine default: the order the renderer and the PowerPoint export use) and the session's registered catalogs, so the editor composes what is drawn and exported. A reference that resolves nowhere never throws: the slide composes automatically or with core's `ENGINE_DEFAULT_*`, and `onDiagnostic` hears each `unresolved-reference` diagnostic. The handle's `textMeasurement` is strict: for a document with a script the design font lacks (Japanese under Aptos) pass `{ fonts: { textMeasurement: createScriptTextMeasurement(fonts.textMeasurement, resolveScriptFonts(presentation, { slideIndex })) } }` (`createScriptTextMeasurement` is from `@openpresentation/opf-render/fonts`, `resolveScriptFonts` from `@openpresentation/opf/composition`), as `renderSvg` does internally.
140
+ `session.composeSlide(index, { fonts })` and `session.paginateSlide(index, { fonts })` take the same handle and resolve the slide's canvas, layout, theme, colour scheme and font families with core's `resolveSlideContext` (slide design, then deck design, then theme, then the engine default: the order the renderer and the PowerPoint export use) and the session's registered catalogs, so the editor composes what is drawn and exported. The slide it composes is `resolveSlideContext(...).slide`, so the organization logo references in its image fields (`var:organization.logo.icon` in a header or footer zone, an image block or a slide image) are already the right artwork for the slide's background (`onLight` on a light slide, `onDark` on a dark one); the cover and section logo and the zone parts come back in `composeSlide(index).logo` and `.furniture.parts` (each part is drawn from its `box`). 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 `toSvg` does internally.
141
141
 
142
142
  These APIs were introduced in 0.1.0. Version 0.7.0 requires core 0.10.0 and renderer 0.8.0 for the canvas, including shared accepted geometry, styled/merged cells, rich table values, headers and content-aware row heights. See the OPF repository’s `docs/live-editor.md` for setup, the support matrix and roadmap. `pnpm pack:ecosystem` in that repository also prepares local preview tarballs for coordinated development.
143
143
 
@@ -158,6 +158,19 @@ Plain-text carets are resolved from the rendered SVG glyphs: each line carries i
158
158
 
159
159
  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.
160
160
 
161
+ ### Input methods (IME): not certified
162
+
163
+ IME input is **not certified**. Real Japanese (Kana to Kanji), Chinese (pinyin) and Korean (Hangul) input methods have not been tested in this editor, on any browser or operating system; no run with a real input method is recorded (opf-editor#137). Only synthetic `CompositionEvent`s are tested (`test/rich-input-browser.mjs` dispatches `compositionstart`, an `insertCompositionText` input and `compositionend` on the rich-text input and checks that the canvas does not commit midway and that one Undo restores the original). Synthetic events do not reproduce a candidate window, a re-render during a composition, or the order of `keydown`, `compositionend` and `input` that real browsers and input methods produce, so treat inline editing with an input method as unverified until a pass is recorded.
164
+
165
+ What exists in the code (written for input methods, not verified with one):
166
+
167
+ - **Canvas** (`src/canvas.js`): the inline text input marks the field as composing between `compositionstart` and `compositionend` (no draft and no commit are made meanwhile, and the input is repositioned), ignores `keydown` while `isComposing` (so Enter and Escape that act on a composition do not commit or cancel the edit), and queues a draft when the composition ends.
168
+ - **Rich-text input** (`src/rich-text-input.js`): tracks composition, keeps the pre-composition value so one history step covers the composed text, and ignores `isComposing` key events.
169
+ - **Data grid** (`src/data-grid.js`): an Enter or Tab that confirms a composition is not treated as a command (`isComposing`).
170
+ - **Outline view** (`src/outline-view.js`) and the **design controls** (`src/design-controls.js`): Enter during a composition does not commit (`isComposing`).
171
+
172
+ Not tested at all, even with synthetic events: the data grid, the outline view and the design controls. Failures with a real input method are reported as defects with the input method named.
173
+
161
174
  ## List numbering (RR-33)
162
175
 
163
176
  `@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(presentation, path)` reads the settings and the markers the list draws, and `findNumberableLists(presentation, 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).
@@ -189,10 +202,10 @@ import {
189
202
  createEditorSession,
190
203
  createSvgTraceBinding
191
204
  } from "@openpresentation/opf-editor";
192
- import { renderSlideSvg } from "@openpresentation/opf-render";
205
+ import { toSvg } from "@openpresentation/opf-render";
193
206
 
194
207
  const editor = createEditorSession(opfDocument, { rejectInvalid: true });
195
- const svg = renderSlideSvg(editor.presentation, 0, { trace: true, fonts });
208
+ const svg = toSvg(editor.presentation, 1, { trace: true, fonts });
196
209
 
197
210
  preview.innerHTML = svg;
198
211
  const binding = createSvgTraceBinding(preview.querySelector("svg"), editor, {
@@ -336,13 +349,16 @@ The playground's Content tab mounts the block-level and slide-structure actions
336
349
  `@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.
337
350
 
338
351
  ```js
339
- import { setDesignOption, setLogoVariant, setHeaderFooterZone, insertZoneValue, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
352
+ import { setDesignOption, setOrganizationLogo, setHeaderFooterZone, insertZoneValue, insertZoneLogo, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
340
353
 
341
354
  setDesignOption(editor, "titleAlignment", "center", { slideIndex: 2 }); // /slides/2/design/titleAlignment
342
355
  setDesignOption(editor, "watermark", { src: "asset:mark", opacity: 0.1 }); // merges into an existing watermark; false hides an inherited one
343
- setLogoVariant(editor, "light", "asset:logo-white"); // default stays a bare source; more variants make a LogoSet
344
- setHeaderFooterZone(editor, "footer", "right", { text: "{{slide.number}} / {{deck.slideCount}}", logo: true });
356
+ setOrganizationLogo(editor, "all", "asset:logo"); // organization.logo: one image for every shape
357
+ setOrganizationLogo(editor, "icon", "asset:mark-white", { background: "onDark" }); // a shape's own image for dark backgrounds (the rest keeps what it had)
358
+ setDesignOption(editor, "logo", { organization: "beta", shape: "icon" }); // design.logo = "var:organization.beta.logo.icon"; false draws none, null the primary organization's
359
+ setHeaderFooterZone(editor, "footer", "right", { text: "{{slide.number}} / {{deck.slideCount}}" });
345
360
  insertZoneValue(editor, "footer", "left", "organization.name", { start: 0, end: 0 }); // puts {{organization.name}} at the start of the left text
361
+ insertZoneLogo(editor, "header", "left"); // the zone's image becomes var:organization.logo.icon
346
362
  ```
347
363
 
348
364
  | Option | Writes | Values |
@@ -354,12 +370,12 @@ insertZoneValue(editor, "footer", "left", "organization.name", { start: 0, end:
354
370
  | `contentBox` | `design.contentBox` | `true`, `false` |
355
371
  | `imageFit` | `design.imageFit` | `cover`, `contain`, `stretch`: the fit of image blocks that choose none |
356
372
  | `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 |
357
- | `logo` | `design.logo` | a source, an Asset object or a LogoSet; `setLogoVariant` edits one of the 12 variants |
358
- | `organizationLogo` | `organization.logo` (deck only; `index` picks an organization) | a source or Asset object |
373
+ | `logo` | `design.logo` | which organization's logo covers, sections and picture bullets draw: `null` (the primary organization's, the default), `false` (none), a reference such as `"var:organization.beta.logo.icon"`, or `{ organization, shape }` (an organization id and a shape) to write one. A reference to an organization the deck does not have is refused (`unknown-organization`). `readLogoChoice` reads it as `{ mode: "primary" \| "none" \| "reference" \| "custom", organization?, shape?, scope, inherited }`; `parseLogoReference` and `logoReference` convert between the string and `{ organization, shape }` |
374
+ | the organization's logo | `organization.logo` (deck only; not a `setDesignOption` option) | `setOrganizationLogo(editor, shape, source, { background, organization })` sets or clears (`null`) it: `shape` is `"all"` (one image for every shape: `organization.logo` is that image) or one of `LOGO_SHAPES` (`full`, `stacked`, `icon`, `wordmark`); `background` is `"both"` (default), `"onLight"` or `"onDark"` (a different image on light or dark slides; the other keeps what the shape had, and a plain image counts as both); `organization` is an index or id (default: the primary organization, `role: "primary"` else the first). A bare logo is the full logo, so setting a shape keeps it as `full`, and a lone plain `full` collapses back to the bare image. `readOrganizationLogo` reads `{ organization, all, shapes }` and `listOrganizations` lists `{ index, id, name, primary, hasLogo }` |
359
375
  | `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) |
360
- | header and footer zones | `design.header` / `design.footer` `.left/.center/.right` | `setHeaderFooterZone` merges every part a zone can hold (`text`, `logo`, `image`, `date` (true, or a fixed date) and `dateFormat`, `socials`; `ZONE_FIELDS`) into one zone, checking each value. Generated values are `{{ }}` variables written in `text`, in the order the author wants and with `\n` between lines: `{{slide.number}}`, `{{deck.slideCount}}`, `{{slide.section}}`, `{{organization.name}}`, `{{speaker.name}}`, `{{deck.name}}` (the entries of `ZONE_VALUES`, the panel's "Insert value" menu). `insertZoneValue(editor, which, zone, name, { start, end, text, slideIndex })` puts one token into a zone's text at UTF-16 offsets (a selection is replaced; the end by default; `text` when a text box holds typing that is not committed yet) as one undoable, validated edit, and `prepareZoneValue` returns the patch without a session. The 0.16 fields `organization`, `speaker`, `section`, `slideNumber` and `slideNumberFormat` are gone with no alias (`{current}` and `{total}` too), and are refused with a message that names the variable to write instead. A removed field, an emptied zone and an emptied header are all deleted rather than left as `{}`; 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 |
376
+ | header and footer zones | `design.header` / `design.footer` `.left/.center/.right` | `setHeaderFooterZone` merges every part a zone can hold (`text`, `image`, `date` (true, or a fixed date) and `dateFormat`, `socials`; `ZONE_FIELDS`) into one zone, checking each value. Generated values are `{{ }}` variables written in `text`, in the order the author wants and with `\n` between lines: `{{slide.number}}`, `{{deck.slideCount}}`, `{{slide.section}}`, `{{organization.name}}`, `{{speaker.name}}`, `{{deck.name}}` (the entries of `ZONE_VALUES`, the panel's "Insert value" menu). `insertZoneValue(editor, which, zone, name, { start, end, text, slideIndex })` puts one token into a zone's text at UTF-16 offsets (a selection is replaced; the end by default; `text` when a text box holds typing that is not committed yet) as one undoable, validated edit, and `prepareZoneValue` returns the patch without a session. The logo is the zone's `image`, a reference to the organization's logo: `insertZoneLogo(editor, which, zone, { shape, organization, slideIndex })` writes `var:organization.logo.<shape>` (`icon` by default, which fits a zone; `full` is plain `var:organization.logo`; `organization: "beta"` writes `var:organization.beta.logo.<shape>`) as one undoable, validated edit, replacing an image the zone had, and `prepareZoneLogo` returns the patch without a session. Which artwork is drawn (`onLight` or `onDark`) follows each slide's background. The 0.17 `logo: true` and the 0.16 fields `organization`, `speaker`, `section`, `slideNumber` and `slideNumberFormat` are gone with no alias (`{current}` and `{total}` too), and are refused with a message that says what to write instead. A removed field, an emptied zone and an emptied header are all deleted rather than left as `{}`; 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 |
361
377
 
362
- 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's social profiles when there are none, or whose text uses a deck-wide variable the document has no value for (`{{organization.name}}` without an organization, `{{speaker.name}}` without a named speaker), or `{{slide.section}}` on a slide without a section, 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 }`.
378
+ 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 zone image or `design.logo` that names a logo no organization has, or picture bullets (they use the icon shape) with no logo to draw, is reported as `unresolved-logo`, and a zone that shows the organization's social profiles when there are none, or whose text uses a deck-wide variable the document has no value for (`{{organization.name}}` without an organization, `{{speaker.name}}` without a named speaker), or `{{slide.section}}` on a slide without a section, 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 }`.
363
379
 
364
380
  ### Image uploads (RR-06)
365
381
 
@@ -367,13 +383,13 @@ A deck-scope change reports `shadowed` slides whose own design hides it (`clearS
367
383
 
368
384
  ```js
369
385
  import { applyImageUpload } from "@openpresentation/opf-editor/assets";
370
- import { prepareLogoVariant } from "@openpresentation/opf-editor/design-options";
386
+ import { prepareOrganizationLogo } from "@openpresentation/opf-editor/design-options";
371
387
 
372
- const change = await applyImageUpload(editor, file, (reference, presentation) => prepareLogoVariant(presentation, "light", reference), { alt: "Acme logo" });
373
- 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"
388
+ const change = await applyImageUpload(editor, file, (reference, presentation) => prepareOrganizationLogo(presentation, "icon", reference, { background: "onDark" }), { alt: "Acme logo" });
389
+ change.assetId; // "acme-logo": the document now has assets["acme-logo"] = { src: "data:image/png;base64,...", mediaType, title, alt } and organization.logo.icon.onDark = "asset:acme-logo"
374
390
  ```
375
391
 
376
- `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.
392
+ `build(reference, presentation)` is any `prepare...` function of this package, so the same upload works for the organization logo (one image for every shape, or each shape for both, light or dark backgrounds), 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.
377
393
 
378
394
  ### Backgrounds (RR-06)
379
395
 
@@ -444,7 +460,7 @@ Sections are OPF's `section` label on each slide: consecutive slides with the sa
444
460
  import { createSlideManager } from "@openpresentation/opf-editor/slide-manager";
445
461
  const manager = createSlideManager(document.querySelector("#slide-list"), {
446
462
  editor, getSlideIndex: () => current, setSlideIndex: (index) => { current = index; redraw(); },
447
- renderThumbnail: (deck, index) => renderSlideSvg(deck, index, { fonts, trace: false }), toolbar: document.querySelector("#slide-toolbar"),
463
+ renderThumbnail: (deck, index) => toSvg(deck, index + 1, { fonts, trace: false }), toolbar: document.querySelector("#slide-toolbar"),
448
464
  contentActions, // optional: { splitSlideByBlocks, mergeSlides } from "@openpresentation/opf-editor/content-actions"
449
465
  });
450
466
  editor.subscribe(() => manager.render());
@@ -471,12 +487,12 @@ Chart columns and table headers may be `DataColumn` objects (`{ name, format }`;
471
487
 
472
488
  ```js
473
489
  import { setGridColumnFormat, setChartMapping, detachGridDataset } from "@openpresentation/opf-editor/data-grid";
474
- import { prepareDatasetImport, importData } from "@openpresentation/opf-editor/data";
490
+ import { prepareDatasetImport, ingest } from "@openpresentation/opf-editor/data";
475
491
 
476
492
  setGridColumnFormat(editor, "slides.3.blocks.0.chart", 1, "$#,##0.0"); // header "Revenue" becomes { name: "Revenue", format }; null clears it
477
493
  setChartMapping(editor, "slides.3.blocks.0.chart", { category: "Region", series: ["Revenue"] });
478
494
  detachGridDataset(editor, "slides.3.blocks.1.table"); // its own copy instead of the shared dataset
479
- const stored = prepareDatasetImport(editor.presentation, importData(csv, { as: "chart" }), { id: "revenue" });
495
+ const stored = prepareDatasetImport(editor.presentation, ingest(csv, { as: "chart" }), { id: "revenue" });
480
496
  editor.applyPatch([...stored.patches, { op: "add", path: "/slides/-", value: { id: "rev", title: "Revenue", ...stored.content } }]);
481
497
  ```
482
498
 
@@ -500,7 +516,7 @@ const selectionControls = createDesignControls(contentPanel, { editor, getSlideI
500
516
  controls.refresh(); // when the slide or the selection changes
501
517
  ```
502
518
 
503
- 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: a text box with an "Insert value" menu that puts the slide number, slide count, section, organization, speaker or deck name at the cursor as a `{{ }}` variable (Enter commits, Shift+Enter starts a new line), logo, image, social profiles, and the 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.
519
+ 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: a text box with an "Insert value" menu that puts the slide number, slide count, section, organization, speaker or deck name at the cursor as a `{{ }}` variable (Enter commits, Shift+Enter starts a new line), an image with an "Insert logo" action (a shape and, for several organizations, an organization; it writes the logo reference as the zone's image), social profiles, and the current or fixed date and format, with a summary of the zones in use), `brand` (the organization logo: one image for every shape, or each of the four shapes for both, light and dark backgrounds, for the organization you choose; which organization's logo and shape covers, sections and picture bullets draw, or none; 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.
504
520
 
505
521
  In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
506
522
 
@@ -512,7 +528,7 @@ useEffect(() => {
512
528
  // call controls.refresh() (keep it in a ref) when slide or selected changes
513
529
  ```
514
530
 
515
- 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.
531
+ 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 (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.
516
532
 
517
533
  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.
518
534
 
@@ -551,18 +567,18 @@ A template is an OPF file with variables (`{{id}}` tokens and `var:id` reference
551
567
 
552
568
  ```js
553
569
  import { createTemplatePanel } from '@openpresentation/opf-editor/template-panel';
554
- import { renderSlideSvg } from '@openpresentation/opf-render/svg';
570
+ import { toSvg } from '@openpresentation/opf-render/svg';
555
571
 
556
572
  const panel = createTemplatePanel(container, {
557
573
  editor,
558
574
  // The live preview: the template drawn with the values typed so far (unfilled variables show their example).
559
- renderPreview: ({ presentation, variables, slideIndex }) => renderSlideSvg(presentation, slideIndex, { fonts, variables }),
575
+ renderPreview: ({ presentation, variables, slideIndex }) => toSvg(presentation, slideIndex + 1, { fonts, variables }),
560
576
  getTarget: () => ({ path: selectedPath, start, end }), // the text field a token is inserted into; omit to hide that section
561
577
  onApply: () => redraw(),
562
578
  });
563
579
  ```
564
580
 
565
- 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 **Built-in variables** section lists the values the document supplies itself (`deck.*`, `speaker.*`, `speakers`, `organization.*`, and `speaker.<id>.*` / `organization.<id>.*` by id), read-only, each with its current value or "not set" and where it is used; they are filled from the document's own fields, not in the panel, and the same names appear in the insert list so `{{speaker.name}}` can be inserted without declaring anything. The three slide-scoped built-ins (`slide.number`, `slide.section`, `deck.slideCount`; `scope: "slide"` in `listBuiltins`) come last and have no single value: the panel shows them as "varies per slide" (and notes when no slide has a section), because each slide gets its own when it is drawn or exported. A checkbox marks the document as a template.
581
+ 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 **Built-in variables** section lists the values the document supplies itself (`deck.*`, `speaker.*`, `speakers`, `organization.*`, and `speaker.<id>.*` / `organization.<id>.*` by id), read-only, each with its current value or "not set" and where it is used; they are filled from the document's own fields, not in the panel, and the same names appear in the insert list so `{{speaker.name}}` can be inserted without declaring anything. The three slide-scoped text built-ins (`slide.number`, `slide.section`, `deck.slideCount`; `scope: "slide"` in `listBuiltins`) come last and have no single value: the panel shows them as "varies per slide" (and notes when no slide has a section), because each slide gets its own when it is drawn or exported. After them come the organization logos (`organization.logo`, `.stacked`, `.icon`, `.wordmark`, and the same under `organization.<id>.logo` for each organization; `kind: "image"`, `scope: "slide"`), read-only: they are images placed as a whole image field (`var:organization.logo.icon`), not `{{ }}` text, so the panel shows the `var:` reference, "light or dark artwork per slide" and "no logo yet" when the organization has none, and leaves them out of the insert list. A checkbox marks the document as a template.
566
582
 
567
583
  The headless pieces are usable on their own: `listTemplateFields(presentation, values)`, `templateStatus`, `previewTemplate`, `createTemplateFill(editor)` (`set`, `setText`, `clear`, `reset`, `preview`, `apply({partial})`), `declareVariable`, `setTemplate`, `insertVariableToken(editor, path, id, {start, end, runIndex, format, declare})`, `variableToken` (also for built-in names), `listBuiltins(document)` (the built-ins with kind, label, scope, value, availability and uses), `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 slide-scoped built-ins are the exception on both: the renderer substitutes them for the slide it draws (its own number, section and the deck's count), and `editor.composeSlide(index, { slideNumber, slideCount })` composes that substituted slide, while the document and an inline edit keep the token. The playground adds a **Fill template** button.
568
584
 
@@ -610,28 +626,44 @@ Findings show a severity word, the slide, the rule id and, for a hosted reviewer
610
626
 
611
627
  ## PDF, PNG and SVG downloads (RR-23)
612
628
 
613
- `@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.
629
+ `convert(deck, { format })` from `@openpresentation/opf-editor/export` turns the deck into files in the page, next to the PowerPoint export; the playground's "PDF · PNG · SVG" button is a thin dialog over it. It has the name and the result of core's in-memory `convert` (OPF 0.18, RR-73): `{ files, findings }`, core's `ConvertResult`.
614
630
 
615
631
  ```js
616
- import { exportDeck } from "@openpresentation/opf-editor/export";
617
- const result = await exportDeck(editor.presentation, {
632
+ import { convert } from "@openpresentation/opf-editor/export";
633
+ const { files, findings } = await convert(editor.presentation, {
618
634
  format: "pdf", // "pdf" | "png" | "svg"
619
- slides: "all", // or "current" with slideIndex, or [slide numbers]; hidden slides only with includeHidden
620
- pdfMode: "vector", // or "raster" (an image per slide; also pass pdfLib: import * as pdfLib from "pdf-lib"); PNG and raster density: scale 1 to 4
635
+ slides: "1,3-5", // core's slide selection, counted from 1 (3, "1-3", [1, 3]); omitted: every slide that is not hidden (includeHidden: all)
636
+ raster: false, // PDF only: true draws each page as an image (also pass pdfLib: import * as pdfLib from "pdf-lib"); PNG and raster density: scale 1 to 4
637
+ zip: false, // PNG and SVG only: true packs the slides into one archive instead of one file per slide
638
+ name: "q3-review", // the files' base name; default the deck's filename, else its name
621
639
  fonts, // the renderer's fonts handle (loadFonts): measures, loads the faces the deck needs, supplies the faces to embed
622
- renderOptions, // catalogs, date, ...
623
- signal, onProgress, onDiagnostic,
640
+ renderOptions, // catalogs, date, ... (the renderer's ToSvgOptions)
641
+ signal, onProgress, onFinding,
624
642
  });
625
- // result.download = { name, type, bytes }: one file, or a ZIP of the slides; result.diagnostics lists what to review.
643
+ // files: [{ name, type, bytes, slide?, id?, width?, height?, pages?, slides?, entries? }]; findings: core's Finding[] to review.
626
644
  ```
627
645
 
628
- - **Same drawing as the preview.** The slides are drawn by `renderSvg` with the same fonts handle as the preview (its `textMeasurement`), so a PNG or SVG is the preview, and the PDF is converted from those SVGs rather than laid out again.
629
- - **Fonts.** The fonts handle loads the faces the deck needs before anything is drawn (a failure rejects with `fonts-unavailable`). Only faces the handle's 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`).
630
- - **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).
631
- - **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`.
646
+ - **Same drawing as the preview.** The slides are drawn by `toSvg` with the same fonts handle as the preview (its `textMeasurement`), so a PNG or SVG is the preview, and the PDF is converted from those SVGs rather than laid out again.
647
+ - **Subset faces and outlined text (opf-editor#141).** A browser fonts handle embeds whole faces in an SVG (hundreds of KB to MBs per slide) unless it was created with `subsetWasm`, and shapes outlined text with fontkit unless it has `shapeWasm`. Serve harfbuzzjs's two WASM files like the font files (the copies of the `harfbuzzjs` package the renderer depends on: `harfbuzzjs/dist/harfbuzz-subset.wasm`, 651 KB, and `harfbuzzjs/dist/harfbuzz.wasm`, 434 KB, both MIT) and create the handle you pass as `fonts` with them:
648
+
649
+ ```js
650
+ const fonts = await loadFonts({
651
+ faces, scriptBaseUrl, lazyFontsBaseUrl,
652
+ subsetWasm: "/vendor/harfbuzz-subset.wasm", // URL, bytes or a WebAssembly.Module; SVG downloads embed each face cut to the slide's glyphs
653
+ shapeWasm: "/vendor/harfbuzz.wasm", // outlined text (renderOptions.text: "paths") is shaped by HarfBuzz, as browsers shape text
654
+ });
655
+ ```
656
+
657
+ `convert` reads `fonts.subsets` and `fonts.outlines` from that handle, so nothing else changes: the same call writes SVG files with the faces cut to what each slide draws (the licence rule above still decides which faces may be embedded, and the renderer cuts only faces whose licence allows a modified version; the others stay whole) and, with `renderOptions: { text: "paths" }`, SVG and PNG files whose text is glyph outlines (no font needed). A PDF is unaffected: it embeds the registry's faces and cuts its own subsets, and it always draws selectable text. `renderOptions.subsetFonts: false` embeds whole faces. The canvas draws with `text: "system"`, which embeds nothing, so it never reads either. The playground does this: `npm run build:playground` copies both files next to the page.
658
+ - **Fonts.** The fonts handle loads the faces the deck needs before anything is drawn (a failure rejects with `fonts-unavailable`). Only faces the handle's 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 (`fonts/export-font-license`).
659
+ - **PDF** is the renderer's vector PDF (selectable text, vector shapes, embedded subsets, tagged structure), `raster: true` 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).
660
+ - **Files.** A PDF is one file (`pages`, and the one-based `slides` it shows). PNG and SVG give one file per slide, in slide order, each with its one-based `slide`, its `id` and its `width` and `height` in pixels, as core's `convert` does; with `zip: true` they are one archive (`createZip`, no dependency; `entries` names the files in it), even for one slide. `zip` with a PDF, or `raster` with PNG or SVG, rejects with `invalid-option`.
661
+ - **Names.** `exportFileName(deck, ext, suffix)` uses the deck's `filename` (a trailing .pptx/.pdf/.png/.svg dropped), else the slugified `name`, else `presentation`; the `name` option replaces both. Slides are numbered as core numbers them (`name-001.png`, more digits for a deck of 1000 slides or more), archives are `name.zip`.
662
+ - **Slides.** `slidesToConvert(deck, { slides, includeHidden })` gives the one-based slide numbers a conversion covers. A selection names exactly its slides, hidden or not; a malformed one, or a slide past the end, rejects with core's `invalid-option` (`OPFApiError`, from `parseSlideSelection`). A deck with no slide to convert rejects with `export-no-slides`.
632
663
  - **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.
633
- - **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`.
634
- - PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. The entry imports no PDF library (opf-render 0.16): the vector PDF, PNG and SVG need nothing else, and `pdfMode: "raster"` needs the `pdfLib` option. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
664
+ - **Findings.** Renderer and converter notes are core's `Finding` (finding.schema.json), as core's conversion reports them: `ruleId` `render/<code>` (the renderer), `pdf/<code>` (the PDF converter) or `fonts/export-font-license`, `category` the same word, `path` a JSON Pointer, `slide` (zero-based, as the Finding schema defines it) and `slideId`, and the note's facts (font family, weight, glyph count) in `measured`. `pdf/pdf-font-substituted`, `pdf/pdf-glyph-missing`, `pdf/pdf-raster-fallback` and the renderer's own are `warning`; `pdf/pdf-font-embedded` is `info`. A finding about a slide that is not converted is left out, and repeats are kept once.
665
+ - **Errors.** A rejection's `code` is `export-format` (no such format), `export-no-slides`, `export-aborted`, `export-unavailable`, `export-fonts-unlicensed`, `fonts-unavailable`, `invalid-option` or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
666
+ - PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. The entry imports no PDF library (opf-render 0.16): the vector PDF, PNG and SVG need nothing else, and `raster: true` needs the `pdfLib` option. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
635
667
 
636
668
  Catalog controls initialize an absent deck or slide `design` for known theme, color-scheme and font-scheme fields as one validated transaction. Undo removes a newly created parent; existing design fields are retained. DOM, React and Svelte catalog controls accept `onError(error)` and emit a bubbling `opferror` event on rejected edits, restore the committed selection and expose native validity feedback. General `editor.set` and JSON Patch require existing parents.
637
669
 
package/dist/assets.js CHANGED
@@ -115,7 +115,7 @@ export function prepareImageAsset(presentation, image, { alt, id } = {}) {
115
115
  /**
116
116
  * Add an uploaded image and use it in one undoable transaction. `build(reference, presentation)` returns the
117
117
  * prepared change that uses the image (any `prepare...` function of this package, for example
118
- * `(ref, doc) => prepareLogoVariant(doc, "light", ref)`); its patches run after the asset patch.
118
+ * `(ref, doc) => prepareOrganizationLogo(doc, "icon", ref, { background: "onDark" })`); its patches run after the asset patch.
119
119
  *
120
120
  * Options: `alt` (saved on the asset), `maxBytes`, `meta`, and `onAddAsset({ name, mediaType, bytes, size, alt, file })`
121
121
  * for a host that stores images itself: it returns the reference to use (a web address or an `asset:` id the
package/dist/canvas.d.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  import type { EditorSession } from "./index.js";
2
- import type { RenderSvgOptions } from "@openpresentation/opf-render";
2
+ import type { ToSvgOptions } 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
5
  import type { Catalog } from "@openpresentation/opf";
6
6
  /**
7
7
  * The fonts the canvas, the export and `whenFontsReady` take: the renderer's handle from `loadFonts()` (`@openpresentation/opf-render/fonts-browser`).
8
8
  * Only `textMeasurement` is required to measure; `pending` and `ensure` make the canvas load script and vendored faces before it draws.
9
+ * `subsets` and `outlines` are there when the handle was created with `loadFonts({ subsetWasm, shapeWasm })` (opf-editor#141): `convert` then
10
+ * writes SVG downloads with each face cut to the slide's glyphs and can draw `renderOptions.text: "paths"` with HarfBuzz-shaped outlines.
9
11
  */
10
- export type EditorFonts = Pick<BrowserFontsHandle, "textMeasurement"> & Partial<Pick<BrowserFontsHandle, "embeddedFonts" | "pending" | "ensure" | "registry">>;
12
+ export type EditorFonts = Pick<BrowserFontsHandle, "textMeasurement"> & Partial<Pick<BrowserFontsHandle, "embeddedFonts" | "pending" | "ensure" | "registry" | "subsets" | "outlines">>;
11
13
  export interface FontsReadyHandlers {
12
14
  /** Return false once the document was superseded; the late result is then dropped. */
13
15
  isCurrent?: () => boolean;
@@ -15,7 +17,7 @@ export interface FontsReadyHandlers {
15
17
  ready?: () => void;
16
18
  failed?: (error: Error) => void;
17
19
  /** The options the host renders with (`catalogs`, ...), passed to the fonts handle. */
18
- renderOptions?: RenderSvgOptions;
20
+ renderOptions?: ToSvgOptions;
19
21
  }
20
22
  export declare const FONTS_PENDING: "fonts-pending";
21
23
  export declare const FONTS_UNAVAILABLE: "fonts-unavailable";
@@ -41,7 +43,7 @@ export interface CanvasEditorOptions {
41
43
  /** Show keyboard-accessible dividers for resizing composition tracks. */
42
44
  layoutEditing?: boolean;
43
45
  /** Render options. The canvas draws the document as authored (`variables: false`: a template's `{{tokens}}` stay visible, so inline edits never overwrite them); pass `variables` to draw resolved values instead. */
44
- renderOptions?: RenderSvgOptions;
46
+ renderOptions?: ToSvgOptions;
45
47
  /**
46
48
  * The renderer's fonts handle (`loadFonts()` from `@openpresentation/opf-render/fonts-browser`). Its `textMeasurement` lays the slide out and
47
49
  * its faces draw it. With its `pending` and `ensure` the canvas never renders a document whose faces are still loading: it shows
@@ -117,7 +119,7 @@ export interface CanvasEditor {
117
119
  * notes, deck fields) or the slide is not drawn yet (fonts loading). Moves keyboard focus only with `focus: true`.
118
120
  */
119
121
  reveal(path: string, options?: { focus?: boolean }): string | null;
120
- setRenderOptions(options: RenderSvgOptions): boolean;
122
+ setRenderOptions(options: ToSvgOptions): boolean;
121
123
  destroy(): void;
122
124
  }
123
125
  export declare function allocatedSelectionBox(
package/dist/canvas.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  splitOpfPath,
8
8
  } from "./index.js";
9
9
  import {
10
- renderSlideSvg,
10
+ toSvg,
11
11
  resolvePresentation,
12
12
  } from "@openpresentation/opf-render/svg";
13
13
  import {
@@ -77,7 +77,7 @@ export function createCanvasEditor(container, options = {}) {
77
77
  const resolveOptions = () => ({ ...renderOptions, catalogs: mergeCatalogs(editor.catalogs, renderOptions.catalogs) });
78
78
  // RR-61: the handle measures, and the canvas embeds no face: the browser handle already added its faces to the page, so
79
79
  // `@font-face` data would repeat megabytes of base64 in every redraw. Exports keep the default and carry the faces they draw.
80
- const drawOptions = (extra) => ({ ...resolveOptions(), fonts: handle, embedFonts: false, ...extra });
80
+ const drawOptions = (extra) => ({ ...resolveOptions(), fonts: handle, text: "system", ...extra });
81
81
  let slideIndex = options.slideIndex ?? 0,
82
82
  // RR-32: the canvas edits the document as authored, so a template's {{tokens}} stay visible and an inline edit never
83
83
  // overwrites one with its resolved text. The Fill template panel previews the resolved deck. Pass `variables` to override.
@@ -197,7 +197,7 @@ export function createCanvasEditor(container, options = {}) {
197
197
  }
198
198
  function validateRender(presentation) {
199
199
  requireFonts(presentation);
200
- return renderSlideSvg(presentation, slideIndex, drawOptions());
200
+ return toSvg(presentation, slideIndex + 1, drawOptions());
201
201
  }
202
202
  function fontsState(kind, detail) {
203
203
  fontsShown = true;
@@ -247,13 +247,22 @@ export function createCanvasEditor(container, options = {}) {
247
247
  if (fonts?.pending(presentation ?? editor.presentation, resolveOptions()).length) show(presentation);
248
248
  else render(presentation);
249
249
  }
250
+ // The text an editing target draws: its outermost <text> elements, whitespace collapsed (the renderer's <title> and <desc> are not drawn).
251
+ function targetText(node) {
252
+ return [...node.querySelectorAll("text")]
253
+ .filter((text) => !text.parentNode.closest("text"))
254
+ .map((text) => text.textContent)
255
+ .join(" ")
256
+ .replace(/\s+/g, " ")
257
+ .trim();
258
+ }
250
259
  function render(presentation = editor.presentation) {
251
260
  if (disposed) return;
252
261
  requireFonts(presentation);
253
262
  showToken++;
254
263
  const slides = presentation.slides ?? [];
255
264
  slideIndex = Math.max(0, Math.min(slideIndex, slides.length - 1));
256
- const svgText = renderSlideSvg(presentation, slideIndex, drawOptions({ trace: true }));
265
+ const svgText = toSvg(presentation, slideIndex + 1, drawOptions({ trace: true }));
257
266
  preview.innerHTML = svgText;
258
267
  const svg = preview.querySelector("svg");
259
268
  svg.setAttribute("role", "group");
@@ -300,9 +309,12 @@ export function createCanvasEditor(container, options = {}) {
300
309
  }
301
310
  node.setAttribute("role", "button");
302
311
  node.setAttribute("tabindex", "0");
312
+ // WCAG 2.5.3 (label in name): the name carries every word the target draws, in drawing order, after "Edit <field>:", so the visible
313
+ // text is always part of the name. Content with no drawn text (a picture) falls back to the source text or "content properties".
314
+ const drawn = targetText(node);
303
315
  node.setAttribute(
304
316
  "aria-label",
305
- `Edit ${path.split(".").at(-1)}: ${["string", "number"].includes(typeof value) ? String(value).slice(0, 80) : "content properties"}`,
317
+ `Edit ${path.split(".").at(-1)}: ${drawn || (["string", "number"].includes(typeof value) ? String(value) : "content properties")}`,
306
318
  );
307
319
  if (node.matches("g")) {
308
320
  const bounds = allocatedSelectionBox(node, item);
package/dist/data-grid.js CHANGED
@@ -446,8 +446,9 @@ export function createDataGrid(container, options = {}) {
446
446
  updateFormatRow();
447
447
  const nameRows = count > 1 ? `${count} rows` : "row";
448
448
  buttons.get("delete-rows").textContent = count > 1 ? `Delete ${count} rows` : "Delete rows";
449
- buttons.get("insert-row-above").setAttribute("aria-label", `Insert ${nameRows === "row" ? "row" : nameRows} above`);
450
- buttons.get("insert-row-below").setAttribute("aria-label", `Insert ${nameRows === "row" ? "row" : nameRows} below`);
449
+ // WCAG 2.5.3 (label in name): the button says what it does in its text, so there is no aria-label to differ from it.
450
+ buttons.get("insert-row-above").textContent = `Insert ${nameRows} above`;
451
+ buttons.get("insert-row-below").textContent = `Insert ${nameRows} below`;
451
452
  buttons.get("delete-columns").textContent = columns > 1 ? `Delete ${columns} columns` : "Delete columns";
452
453
  }
453
454
 
package/dist/data.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export {parseTabularData, importData, OPFDataImportError} from "@openpresentation/opf/data";
2
- export type {TabularData, DataImportOptions, ImportDataOptions} from "@openpresentation/opf/data";
1
+ export {parseTabularData, ingest, OPFDataImportError} from "@openpresentation/opf/data";
2
+ export type {TabularData, DataImportOptions, IngestOptions} from "@openpresentation/opf/data";
3
3
  import type { JsonPatchOperation } from "./index.js";
4
4
 
5
5
  /** The id pattern of a top-level dataset (the assets id pattern). */
@@ -19,5 +19,5 @@ export interface DatasetImport {
19
19
  /** True when a dataset of that id existed and its columns and rows were replaced. */
20
20
  replaced: boolean;
21
21
  }
22
- /** Store imported data (the result of `importData`) as a shared dataset, as `opf import-data --dataset <id>` does. Throws `dataset-id-invalid`. */
22
+ /** Store imported data (the result of `ingest`) as a shared dataset, as `opf ingest --dataset <id>` does. Throws `dataset-id-invalid`. */
23
23
  export declare function prepareDatasetImport(presentation: unknown, content: { table: unknown } | { chart: { type: string; data: unknown } }, options: DatasetImportOptions): DatasetImport;
package/dist/data.js CHANGED
@@ -1,4 +1,4 @@
1
- export {parseTabularData, importData, OPFDataImportError} from "@openpresentation/opf/data";
1
+ export {parseTabularData, ingest, OPFDataImportError} from "@openpresentation/opf/data";
2
2
  import { opfPathToJsonPointer } from "./index.js";
3
3
  import { fail } from "./edit-helpers.js";
4
4
 
@@ -8,7 +8,7 @@ export const DATASET_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
8
8
  const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
9
9
 
10
10
  /**
11
- * Store imported data as a shared dataset (RR-54), as `opf import-data --dataset <id>` does. `content` is what `importData` returns
11
+ * Store imported data as a shared dataset (RR-54), as `opf ingest --dataset <id>` does. `content` is what `ingest` returns
12
12
  * (`{ table }` or `{ chart: { type, data } }`). Returns `{ id, content, patches, replaced }`: `content` is the same table or chart holding
13
13
  * `{ dataset: id }` instead of the rows, and `patches` write `datasets.<id>` (the imported columns and rows replace those of an existing
14
14
  * dataset of that id, which keeps its title, description and source; `source` is merged into its `source`). Apply `patches` and the patch that
@@ -18,7 +18,7 @@ export function prepareDatasetImport(presentation, content, options = {}) {
18
18
  const id = options.id;
19
19
  if (typeof id !== "string" || !DATASET_ID_PATTERN.test(id)) throw fail("dataset-id-invalid", 'A dataset id starts with a letter or digit and holds letters, digits, ".", "_" or "-".', { id });
20
20
  const isTable = isObject(content) && isObject(content.table);
21
- if (!isTable && !(isObject(content) && isObject(content.chart) && isObject(content.chart.data))) throw fail("dataset-content-invalid", "Give the table or chart that importData returned.", {});
21
+ if (!isTable && !(isObject(content) && isObject(content.chart) && isObject(content.chart.data))) throw fail("dataset-content-invalid", "Give the table or chart that ingest returned.", {});
22
22
  const data = isTable ? content.table : content.chart.data;
23
23
  const datasets = isObject(presentation) && isObject(presentation.datasets) ? presentation.datasets : undefined;
24
24
  const previous = datasets && Object.hasOwn(datasets, id) && isObject(datasets[id]) ? datasets[id] : undefined;