@openpresentation/opf-editor 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,25 @@
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
+ ## Using the editor with opf-render 0.16: what to install (RR-63, unreleased)
6
+
7
+ `@openpresentation/opf-render` is an optional peer of the editor, and from render 0.16 its heavy pieces are optional peers too, so install only what you use. The editor itself draws with `@openpresentation/opf-render/svg` and loads fonts with `/fonts-browser`, which need nothing else. Add:
8
+
9
+ | You use | Also install |
10
+ | --- | --- |
11
+ | The canvas, the preview, SVG downloads in a browser (the host serves its own font files) | nothing |
12
+ | `loadFonts` from `/fonts-node` (a server, a test, a build script such as `npm run build:playground`) | the font packages of the pack: `@expo-google-fonts/roboto` and `roboto-mono` for `base`; also `arimo`, `caladea`, `cousine`, `gelasio`, `tinos` and `noto-sans` for `office`; a script face (`scripts: 'auto'`) needs its `@expo-google-fonts/noto-*` package. A missing one fails with `font-resource-unavailable` and names it |
13
+ | `svgToPng` / `svgToPdf` from the Node entries (`/png`, `/pdf`) | `@resvg/resvg-js` (and `sharp`, `pdf-lib` as the renderer's README says); a missing one fails with `converter-missing` |
14
+ | `exportDeck` PDF (vector), PNG, SVG | nothing: the browser entry `@openpresentation/opf-render/export-browser` imports no converter |
15
+ | `exportDeck` raster PDF (`pdfMode: "raster"`) | `pdf-lib`, imported by the page and passed as the `pdfLib` option (`import * as pdfLib from "pdf-lib"`); without it the export rejects with `converter-missing` |
16
+
17
+ ```js
18
+ const pdfLib = await import("pdf-lib");
19
+ await exportDeck(editor.presentation, { format: "pdf", pdfMode: "raster", pdfLib, fonts });
20
+ ```
21
+
22
+ The playground imports `pdf-lib` for you when its raster PDF choice is used.
23
+
5
24
  ## OPF 0.15: catalogs from the host, backgrounds and image blocks (FA-23)
6
25
 
7
26
  The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec: no migration). See `changes/fa-23-opf-0-15.md`.
@@ -24,7 +43,7 @@ The release after 0.14.2 moves the editor onto core 0.15 (a breaking, clean spec
24
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`.
25
44
  - **Backgrounds** take the flat image form `{ type: "image", src, alt, fit, focus, opacity, recolor, overlay }` (fits `cover`, `contain`, `stretch`, `tile`) and the image-source shorthand, on the deck and on a slide. **Image blocks** carry their own fit, focus, treatments and placement (`/image-options`, the panel's `image` section): a full-bleed photo is a background, a picture beside the content is a placed image block. `design.imageFit` is the default fit of image blocks.
26
45
 
27
- The release after 0.12.1 (RR-55) moves the editor onto core 0.14, renderer 0.14 and PPTX 0.14, which ship together: core's one `validate` and `Finding` format, `resolveSlideContext`, and the renderer's `loadFonts()` handle as the `fonts` option of the canvas, the export and `composeSlide`/`paginateSlide`. See `changes/rr-55-short-verb-api.md` for what was renamed or removed (the font gate, `validateOpfDocument`, `resolveSlideFonts`, `runAudit`, the `*Available` checks). Version 0.11.2 adds the slide-size and purpose switches (additive; same requirements as 0.11.0). Version 0.11.0 requires `@openpresentation/opf` ^0.12.0 (composed font sizes on PowerPoint's 0.01 pt grid, hanging wrap whitespace, promoted regions in reading order, right-to-left decks composed mirrored; install it with renderer 0.12.0 and PPTX 0.12.0 so the canvas, preview and export resolve the same core) and ships the new entry points listed in the changelog. Version 0.10.6 changes no editor source: it requires `@openpresentation/opf` ^0.11.4 (the design fields compose, cover tag and subtitle follow the title alignment, picture bullets and furniture images change geometry; install it with renderer 0.11.9 and PPTX 0.11.7 so the canvas, preview and export resolve the same core). Version 0.10.5 changes no editor source: its playground example loads its base faces through the renderer's `extraLazyFonts` (renderer 0.11.7 or later; the editor package itself runs with the optional peer ^0.11.0 as before). Version 0.10.4 changes no editor behavior: it requires `@openpresentation/opf` ^0.11.3 (the 70 legacy gallery layout ids and their geometry; install it with renderer 0.11.6 and PPTX 0.11.4 so the canvas, preview and export resolve the same core). Version 0.10.3 passes the host's render options (`catalogs`) through the font gate to the registry, so layouts and font schemes that only the host's catalogs know load their faces, and asks the script and vendored-face loaders separately; it keeps the 0.10.2 requirements and gates nothing new on renderers before 0.11.5. Version 0.10.2 adds pointer caret entry on the canvas (one click puts the caret where you click; new `textEntry` option; keyboard entry still selects all) and keeps the 0.10.1 and 0.10.0 requirements. Version 0.10.1 adds the opt-in font gate (`createFontGate`, FF-41) and keeps the 0.10.0 requirements; develop and test it against renderer 0.11.2 and PPTX 0.11.1. Version 0.10.0 requires `@openpresentation/opf` ^0.11.2, renderer ^0.11.0, and PPTX ^0.11.0 (install them together so cover centering resolves the same core everywhere; renderer 0.11.0 also activates the playground's automatic script fonts and lazy Intos fonts). Version 0.9.0 required `@openpresentation/opf` ^0.11.1, renderer ^0.10.0, and PPTX ^0.10.0. Composition and slide transfer fall back to the shared `aptos` font scheme and report unknown scheme ids through `onDiagnostic`; gallery apply keeps every font-scheme role.
46
+ Editor 0.15.0 uses core `^0.15.1` and the optional renderer peer `^0.15.0`. Install renderer and PPTX 0.15 together for the coordinated canvas and export workflow. The shared APIs are core's `validate`, `Finding`, `resolveSlideContext` and the catalog helpers above, and the renderer's `loadFonts()` handle passed as `fonts` to the canvas, export, composition and pagination. Historical version changes are recorded in `CHANGELOG.md`.
28
47
 
29
48
  Version 0.8.0 required `@openpresentation/opf` ^0.11.0, renderer ^0.9.0, and PPTX ^0.9.0. Named ColorRefs (scheme slots, roles, and `var:<id>`) paint through the published renderer and hex-resolve on export. Payload ids share the slide id namespace for pagination and transfer. Native `schemeClr`, theme write, and `p:hf` remain out of scope.
30
49
 
@@ -398,6 +417,8 @@ setTableCellStyle(editor, "slides.4.blocks.1.table", [{ section: "header", colum
398
417
 
399
418
  Styles use scheme roles, so they follow the color scheme, and the renderers keep the text readable on any fill. A style sets the header fill (`theme`, `plain`, `accent`), banded rows and borders (`theme`, `none`, `horizontal`, `grid`); named presets are `theme`, `banded`, `grid`, `minimal` and `open`, and `theme` removes a previous style. A style owns only the fill and border fields of a cell: text color, alignment, padding, values and merges are kept. Merging never hides text: covered cells that hold text refuse with `merge-would-lose-content` unless you pass `join: true`, which joins the words into the anchor with a space and keeps run formatting. Other refusals are `merge-overlap` (the region crosses another merge), `invalid-table-span` (outside the table, or a header cell spanning into the body) and `table-cell-covered`. Splitting leaves the formerly covered cells as empty text. `parseTableCellPath` maps a canvas selection to a table and cell, and `describeTableCell` and `readTableStyle` read the current state.
400
419
 
420
+ Table alt text (FA-27): `setTableAlt(editor, tablePath, { alt: "North America leads EMEA in Q4." })` writes the table's text alternative (`Table.alt`) as one undoable patch, like a chart's. `alt` is trimmed and an empty string or `null` removes it; `{ decorative: true }` writes the empty alt (decorative, and it wins over `alt`) and `{ decorative: false }` removes an empty alt. It works on a table that shows a shared dataset too, since the alt belongs to the table and not to its data. `readTableAlt(table)` gives `{ alt, decorative }` for a form and `prepareTableAlt` is the headless patch. The table section of the design controls has an "Alt text" field and a "Decorative (no alt text)" box, and find and replace searches `table.alt`. `validate` does not ask for a table alt, so the field is optional.
421
+
401
422
  ## Slide management (RR-21)
402
423
 
403
424
  `@openpresentation/opf-editor/slides` adds, duplicates, deletes, reorders, hides and sections slides. Every function has a `prepare…` form that returns the JSON Patch for a document without touching a session (`{ presentation, patches, changed, selection }`), and an apply form that commits it as ONE validated, undoable change, so a single Undo restores the deck exactly and the preview, thumbnails and PPTX export follow from the document. An operation that changes nothing commits nothing (`changed: false`).
@@ -595,7 +616,7 @@ import { exportDeck } from "@openpresentation/opf-editor/export";
595
616
  const result = await exportDeck(editor.presentation, {
596
617
  format: "pdf", // "pdf" | "png" | "svg"
597
618
  slides: "all", // or "current" with slideIndex, or [slide numbers]; hidden slides only with includeHidden
598
- pdfMode: "vector", // or "raster" (an image per slide); PNG and raster density: scale 1 to 4
619
+ pdfMode: "vector", // or "raster" (an image per slide; also pass pdfLib: import * as pdfLib from "pdf-lib"); PNG and raster density: scale 1 to 4
599
620
  fonts, // the renderer's fonts handle (loadFonts): measures, loads the faces the deck needs, supplies the faces to embed
600
621
  renderOptions, // catalogs, date, ...
601
622
  signal, onProgress, onDiagnostic,
@@ -609,7 +630,11 @@ const result = await exportDeck(editor.presentation, {
609
630
  - **Names.** `exportFileName(deck, ext, suffix)` uses the deck's `filename` (a trailing .pptx/.pdf/.png/.svg dropped), else the slugified `name`, else `presentation`; slides are `name-01.png`, archives `name-png.zip`.
610
631
  - **Progress and cancel.** `onProgress({ stage, done, total, message })` reports fonts, drawing, per-page conversion and packing; an aborted `signal` rejects with `export-aborted` between pages and slides.
611
632
  - **Diagnostics.** `describeDiagnostic` normalises renderer and converter notes into `{ code, severity, message, slide? }`: `pdf-font-substituted`, `pdf-glyph-missing`, `pdf-raster-fallback` and the renderer's own are `warning`; `pdf-font-embedded` is `info`.
612
- - PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
633
+ - PDF and PNG need `@openpresentation/opf-render` with its `export-browser` entry (RR-23, opf-render#105); without it they reject with `export-unavailable` and SVG still works. The entry imports no PDF library (opf-render 0.16): the vector PDF, PNG and SVG need nothing else, and `pdfMode: "raster"` needs the `pdfLib` option. Verified in Chromium; Safari and Firefox are not exercised in CI. The playground bundle grows by the PDF writer, fontkit shaping and the bidi algorithm.
634
+
635
+ Catalog controls initialize an absent deck or slide `design` for known theme, color-scheme and font-scheme fields as one validated transaction. Undo removes a newly created parent; existing design fields are retained. DOM, React and Svelte catalog controls accept `onError(error)` and emit a bubbling `opferror` event on rejected edits, restore the committed selection and expose native validity feedback. General `editor.set` and JSON Patch require existing parents.
636
+
637
+ Optional downloads can be deferred until requested: dynamically import `@openpresentation/opf-editor/export` inside the download handler. For a PowerPoint download, dynamically import `@openpresentation/opf-pptx` inside that handler too; the `/export` helper covers PDF, PNG and SVG. This defers conversion code, while canvas rendering and font measurement still have their own startup costs.
613
638
 
614
639
  ## Optional React Bindings
615
640
 
@@ -657,7 +682,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
657
682
 
658
683
  The current checkout uses `@openpresentation/opf/composition` for portable geometry. Slides can select `auto`, `row`, `column`, or `grid`, set weighted tracks, and request path-specific overflow diagnostics. See the sibling OPF repo's `docs/dynamic-composition.md` for the complete contract.
659
684
 
660
- Version 0.11.0 requires `@openpresentation/opf@^0.12.0` and the optional renderer peer `@openpresentation/opf-render@^0.12.0`, with development and coordinated playground export on renderer 0.12.0 and `@openpresentation/opf-pptx@^0.12.0`; 0.10.6 requires `@openpresentation/opf@^0.11.4`; 0.10.5 and 0.10.4 require ^0.11.3 (0.10.3 and earlier: ^0.11.2). The optional renderer peer requires `@openpresentation/opf-render@^0.11.0` (the font gate gates nothing on a registry without the lazy loaders). Development and coordinated playground export use renderer 0.11.9 (design fields, picture bullets; 0.11.8 tag colour; the playground example needs 0.11.7 for `extraLazyFonts`; 0.11.6 chart label rotation; 0.11.5 face-level lazy fonts; the gate passes render options, which older renderers ignore) and `@openpresentation/opf-pptx@^0.11.7`. Clean registry installs support the composition APIs without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
685
+ The current editor requires core `^0.15.1`; canvas rendering uses the optional renderer peer `^0.15.0`, and playground PPTX import/export uses `@openpresentation/opf-pptx@^0.15.0`. Clean registry installs support composition without sibling checkouts. For coordinated source development, build OPF and run `node scripts/link-ecosystem.mjs` there; `pnpm test:ecosystem` verifies shared geometry and import/export behavior.
661
686
 
662
687
  ## Local interactive demo
663
688
 
@@ -669,7 +694,7 @@ Nested content groups expose their bounds through `editor.composeSlide(index).gr
669
694
 
670
695
  `editor.paginateSlide(index, {minFontSize:24})` splits a crowded draft into ordinary OPF slides as one undoable transaction. It returns the change and source mappings; failed pagination leaves the document unchanged. The playground’s Split overflow action demonstrates the workflow.
671
696
 
672
- `composeSlide(index, { fonts })` and `paginateSlide(index, { fonts })` accept the same fonts handle as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions. The font a user selects (for example Calibri or Aptos) is the source of truth and stays in the document; because license-restricted (proprietary) fonts are never bundled, the canvas draws an open look-alike (a metric-compatible one such as Carlito for Calibri where it exists, a visual-only fallback for Aptos today) and the substitution report says so. Release caveat: the published editor depends on opf-pptx `^0.9.0`, which resolves 0.9.1 and still writes the substitute into the PPTX; selected-name export arrives with the next opf-pptx release. See the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31).
697
+ `composeSlide(index, { fonts })` and `paginateSlide(index, { fonts })` accept the same fonts handle as preview and export. The local playground now uses bundled, embedded fonts and reports substitutions. The font a user selects (for example Calibri or Aptos) is the source of truth and stays in the document; because license-restricted (proprietary) fonts are never bundled, the canvas draws an open look-alike (a metric-compatible one such as Carlito for Calibri where it exists, the metric-compatible Intos family for Aptos) and the substitution report says so. Since 0.14, PPTX export names the selected font, while preview substitutions remain visible in the font report. See the [OPF font policy](https://github.com/OpenPresentation/opf/blob/main/docs/font-fidelity.md#font-policy-ff-31).
673
698
 
674
699
 
675
700
  ## Copy, paste, and gallery imports
@@ -680,7 +705,7 @@ The `/galleries` export provides `loadOpfGallery` and `loadOpfGalleryItem`, acce
680
705
 
681
706
  The playground includes Copy OPF and Import dialogs with paste, file/drop, gallery search, and URL tabs. Files accept OPF JSON and PowerPoint `.pptx` up to 20 MB. PPTX conversion runs locally using `@openpresentation/opf-pptx`; review the converted preview and diagnostics before inserting slides or opening a presentation. Applying an import is one undoable edit. Custom registries persist in local storage; defaults are configured in `examples/galleries.json`. Copy permissions may require using the Select all fallback. Pasting within text fields keeps normal text editing. Cmd/Ctrl+Shift+C opens copying and Cmd/Ctrl+O opens file import.
682
707
 
683
- The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. With an opf-pptx that includes FF-31 (after 0.9.1), the download names the font the user selected, never the preview look-alike, so PowerPoint shows that font when it is installed or available as a Microsoft 365 cloud font; opf-pptx 0.9.1, which the published editor resolves today, still writes the substitute. The download does not embed font binaries, and license-restricted (proprietary) fonts are never embedded. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
708
+ The **PowerPoint** button commits the current canvas draft and prepares an editable `.pptx` download from that snapshot, using the preview's text measurements. Conversion diagnostics remain visible in the download dialog. Missing/remote images require an explicit host resolver or embedded data; export does not silently fetch them or omit them. Save OPF separately to retain the complete original source. Since 0.14, PPTX export names the font the user selected, so PowerPoint uses it when installed or available as a Microsoft 365 cloud font. The download does not embed font binaries, and license-restricted (proprietary) fonts are never embedded. Arbitrary native positions and unsupported PowerPoint features can change during reimport.
684
709
 
685
710
  Run `npm ci`, `npm run build:playground`, then serve `artifacts/playground` with a local static server. This example uses the published core/render/PPTX packages and this checkout's editor source; it does not require an account, AI provider or paid service. `npm run test:playground` builds and executes a real browser flow (local Edge on Windows, installed Chromium in CI): author JSON, inline edit, export a native merged table, validate/reimport the actual downloaded file, undo/redo, save OPF and reject malformed input without changing the document. It is browser behavior evidence, not a claim of native PowerPoint raster equivalence.
686
711
 
@@ -716,6 +741,8 @@ The canvas can show draggable, keyboard-accessible dividers for root and nested
716
741
 
717
742
  ### Move complete blocks
718
743
 
744
+ When the slide's layout record has placeholder groups (FA-26, `{ "type": "group", "composition", "placeholders" }`), Arrange also outlines every slot of the record at its cell (`geometry.slots`): groups, filled regions and labelled empty regions. The slots belong to the record, so they are shown, not edited; the slide's content in them keeps its own selection and handles. Layout pickers and the JSON field menu describe such a record as `title, column (text, text), chart` and compare layouts by their leaf regions.
745
+
719
746
  Arrange mode also shows numbered block handles. Drag to reorder siblings, use arrow keys for earlier/later, or click a handle to choose an existing destination group/slide and insertion position. The move preserves the whole block, including rich text, table/chart data, and nested groups. Each move is validated, preflighted by the renderer, and undoable. Parent weights stay with layout positions; moves that leave an empty container or create a containment cycle are rejected.
720
747
 
721
748
  Agents can import `prepareBlockMove` and `listBlockContainers` from `@openpresentation/opf-editor/layout`. The prepared result includes a full candidate document, guarded atomic patches, the new block path, and a changed flag. Destination indexes refer to the pre-removal document. Use `canvas.openBlockMenu(path)` for the corresponding browser controls.
package/dist/canvas.js CHANGED
@@ -23,7 +23,7 @@ import { createRichTextInput } from "./rich-text-input.js";
23
23
  import { textInputOffsetAtPoint } from "./text-pointer.js";
24
24
  import { createRichTextToolbar } from "./rich-text-toolbar.js";
25
25
  import { createImageCropper } from "./image-cropper.js";
26
- import { FONTS_PENDING, fontGate, fontsPendingError, previewFonts, whenFontsReady } from "./font-gate.js";
26
+ import { FONTS_PENDING, fontGate, fontsPendingError, whenFontsReady } from "./font-gate.js";
27
27
  import { checkFormat, firstErrorMessage } from "./checks.js";
28
28
  export { getEditableFields } from "./canvas-fields.js";
29
29
  export { whenFontsReady, FONTS_PENDING, FONTS_UNAVAILABLE } from "./font-gate.js";
@@ -75,7 +75,9 @@ export function createCanvasEditor(container, options = {}) {
75
75
  const handle = options.fonts, fonts = fontGate(handle);
76
76
  // The renderer draws with the session's registered catalogs (plus any `renderOptions.catalogs`): the same list core resolves with.
77
77
  const resolveOptions = () => ({ ...renderOptions, catalogs: mergeCatalogs(editor.catalogs, renderOptions.catalogs) });
78
- const drawOptions = (extra) => ({ ...resolveOptions(), fonts: previewFonts(handle), ...extra });
78
+ // RR-61: the handle measures, and the canvas embeds no face: the browser handle already added its faces to the page, so
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 });
79
81
  let slideIndex = options.slideIndex ?? 0,
80
82
  // RR-32: the canvas edits the document as authored, so a template's {{tokens}} stay visible and an inline edit never
81
83
  // overwrites one with its resolved text. The Fill template panel previews the resolved deck. Pass `variables` to override.
@@ -256,6 +258,9 @@ export function createCanvasEditor(container, options = {}) {
256
258
  const svg = preview.querySelector("svg");
257
259
  svg.setAttribute("role", "group");
258
260
  svg.setAttribute("aria-label", "Editable slide");
261
+ // FA-30: the renderer labels its slide root a "slide" group named by the title; the canvas is named for editing instead, so it drops
262
+ // the roledescription rather than announce "Editable slide, slide".
263
+ svg.removeAttribute("aria-roledescription");
259
264
  svg.removeAttribute("aria-labelledby");
260
265
  const geometry = resolvePresentation(presentation, drawOptions()).slides[
261
266
  slideIndex
@@ -286,6 +291,13 @@ export function createCanvasEditor(container, options = {}) {
286
291
  continue;
287
292
  seen.add(path);
288
293
  node.setAttribute("data-canvas-target", "");
294
+ // FA-30: the renderer hides decorative drawing and a picture or chart with an empty alt (aria-hidden), and names a chart with
295
+ // alt as an image group. An editing target is focusable, so neither it nor a group around it may be hidden or presentational:
296
+ // an ancestor image group (the chart's alt) stays named but becomes a plain group so the targets inside it are reachable.
297
+ for (let up = node; up && up !== svg; up = up.parentNode) {
298
+ up.removeAttribute("aria-hidden");
299
+ if (up !== node && up.getAttribute("role") === "img") up.setAttribute("role", "group");
300
+ }
289
301
  node.setAttribute("role", "button");
290
302
  node.setAttribute("tabindex", "0");
291
303
  node.setAttribute(
@@ -0,0 +1,21 @@
1
+ // Framework-neutral event handling: rejected edits restore the committed value
2
+ // and reach the host callback and a bubbling opferror event.
3
+ export function commitCatalogControl(node, onError, sync, commit) {
4
+ try {
5
+ commit();
6
+ node.setCustomValidity?.("");
7
+ node.removeAttribute?.("aria-invalid");
8
+ node.removeAttribute?.("data-opf-error");
9
+ return true;
10
+ } catch (error) {
11
+ sync();
12
+ node.setCustomValidity?.(error.message);
13
+ node.setAttribute?.("aria-invalid", "true");
14
+ node.setAttribute?.("data-opf-error", error.message);
15
+ onError?.(error);
16
+ const Event = node.ownerDocument?.defaultView?.CustomEvent;
17
+ if (Event) node.dispatchEvent?.(new Event("opferror", { detail: error, bubbles: true }));
18
+ node.reportValidity?.();
19
+ return false;
20
+ }
21
+ }
@@ -36,7 +36,7 @@ import {
36
36
  import { createContentControls } from "./content-controls.js";
37
37
  import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
38
38
  import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
39
- import { describeTableCell, mergeTableCells, parseTableCellPath, readTableStyle, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
39
+ import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, readTableStyle, setTableAlt, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
40
40
 
41
41
  /** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
42
42
  export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
@@ -1158,8 +1158,19 @@ export function createDesignControls(container, options = {}) {
1158
1158
  const align = selectField("cell-align", "Cell text alignment", { empty: "Default", onChange: (value) => run(() => setTableCellStyle(editor, dynamic.tablePath, dynamic.cell, { align: value === "" ? null : value }), "Cell alignment changed.") });
1159
1159
  align.setOptions(["left", "center", "right"].map((value) => ({ value, label: titleCase(value) })));
1160
1160
  const cellBox = h("div", { class: "opf-dc-cell" }, cellLabel, columns.wrap, rows.wrap, join.wrap, h("div", { class: "opf-dc-actions" }, merge, split), fill.wrap, align.wrap);
1161
- body.append(summary, headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox);
1162
- dynamic.table = { details, summary, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
1161
+ // FA-27: the table's text alternative, like a chart's. It belongs to the table, so a dataset-backed table has it too.
1162
+ const alt = textField("table-alt", "Alt text", {
1163
+ help: "What the table shows: its point and the key numbers, not \"a table\". Optional, because a screen reader reads a table cell by cell. Press Enter to apply.",
1164
+ placeholder: "What the table shows",
1165
+ onCommit: (value) => run(() => setTableAlt(editor, dynamic.tablePath, { alt: value }), "Table alt text changed."),
1166
+ });
1167
+ alt.input.setAttribute("aria-label", "Table alt text");
1168
+ const decorative = checkField("table-decorative", "Decorative (no alt text)", {
1169
+ onChange: (checked) => run(() => setTableAlt(editor, dynamic.tablePath, { decorative: checked }), checked ? "Table marked decorative." : "The table is no longer decorative."),
1170
+ });
1171
+ const styleControls = [headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox];
1172
+ body.append(summary, alt.wrap, decorative.wrap, ...styleControls);
1173
+ dynamic.table = { details, summary, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
1163
1174
  }
1164
1175
 
1165
1176
  // --- sync -----------------------------------------------------------------------------------------
@@ -1210,12 +1221,21 @@ export function createDesignControls(container, options = {}) {
1210
1221
  const tablePath = parsed?.tablePath ?? (content?.key === "table" ? `${blockPath}.table` : undefined);
1211
1222
  dynamic.tablePath = tablePath;
1212
1223
  dynamic.cell = parsed?.cell;
1213
- // A table that shows a shared dataset (RR-54) has no cell styles or merges: the table panel is for inline tables.
1224
+ // A table that shows a shared dataset (RR-54) has no cell styles or merges: the style and merge controls are for inline tables.
1225
+ // Its alt text (FA-27) belongs to the table and not to the data, so the panel still opens for it.
1214
1226
  const tableValue = tablePath ? editor.get(tablePath) : undefined;
1215
- tableUi.details.hidden = !tablePath || !Array.isArray(tableValue?.rows);
1227
+ const inline = Array.isArray(tableValue?.rows);
1228
+ tableUi.details.hidden = !tablePath || !(inline || typeof tableValue?.dataset === "string");
1216
1229
  if (tablePath && !tableUi.details.hidden) {
1217
- const table = tableValue;
1218
1230
  tableUi.summary.textContent = `Table (${tablePath})`;
1231
+ const textAlternative = readTableAlt(tableValue);
1232
+ tableUi.alt.set(textAlternative.alt);
1233
+ tableUi.alt.input.disabled = textAlternative.decorative;
1234
+ tableUi.decorative.set(textAlternative.decorative);
1235
+ for (const node of tableUi.styleControls) node.hidden = !inline;
1236
+ }
1237
+ if (tablePath && !tableUi.details.hidden && inline) {
1238
+ const table = tableValue;
1219
1239
  const style = readTableStyle(document_, tablePath);
1220
1240
  dynamic.tableStyle = style.preset === "custom" ? { header: "theme", banding: false, borders: "theme" } : { header: style.header, banding: style.banding, borders: style.borders };
1221
1241
  tableUi.headerStyle.set(style.header);
package/dist/export.d.ts CHANGED
@@ -42,6 +42,11 @@ export interface ExportOptions {
42
42
  includeHidden?: boolean;
43
43
  /** "vector" (default): selectable text and vector shapes; "raster": an image per slide. */
44
44
  pdfMode?: "vector" | "raster";
45
+ /**
46
+ * Raster PDF only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry imports no PDF library (opf-render 0.16), so the host passes it;
47
+ * without it a `pdfMode: "raster"` PDF rejects with the renderer's `converter-missing`. Vector PDF, PNG and SVG need no option.
48
+ */
49
+ pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
45
50
  /** PNG pixel density (and the raster PDF's), 1 to 4; default 2. */
46
51
  scale?: number;
47
52
  /** The options the host draws its preview with (`catalogs`, `date`, ...); the fonts go in `fonts`. */
@@ -72,6 +77,6 @@ export interface ExportResult {
72
77
  }
73
78
  /**
74
79
  * Draw the deck with the renderer the preview uses and convert it. Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed` (the license rule left the PDF converter no face for some text), `export-no-slides`,
75
- * `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable` or a renderer code.
80
+ * `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable` or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
76
81
  */
77
82
  export declare function exportDeck(deck: unknown, options: ExportOptions): Promise<ExportResult>;
package/dist/export.js CHANGED
@@ -124,6 +124,9 @@ async function loadConverters(options) {
124
124
  * - `format`: "pdf", "png" or "svg" (required).
125
125
  * - `slides`: "current" (with `slideIndex`), "all" (default; hidden slides only with `includeHidden`) or an array of slide numbers.
126
126
  * - `pdfMode`: "vector" (default: selectable text, vector shapes) or "raster" (each slide an image).
127
+ * - `pdfLib`: for `pdfMode: "raster"` only, the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry imports no PDF library (RR-63, opf-render 0.16),
128
+ * so a host that offers the raster PDF imports pdf-lib itself; without it a raster PDF rejects with the renderer's `converter-missing` (its `details.install` names the package).
129
+ * Vector PDF, PNG and SVG need no option.
127
130
  * - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
128
131
  * - `renderOptions`: the render options the host draws with (`catalogs`, `date`, ...), the same as its preview.
129
132
  * - `catalogs`: the host's registered catalogs (`Catalog[]`, merged with `renderOptions.catalogs`). The deck is embedded first (core's
@@ -137,7 +140,7 @@ async function loadConverters(options) {
137
140
  * `export-fonts-unlicensed` (the converter's error is its `cause`).
138
141
  * - `signal`, `onProgress({ stage, done, total, message })`, `onDiagnostic(diagnostic)`.
139
142
  * Resolves `{ download, files, diagnostics, slides }`. Rejects with an error whose `code` is `export-aborted`,
140
- * `export-fonts-unlicensed`, `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code.
143
+ * `export-fonts-unlicensed`, `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
141
144
  */
142
145
  export async function exportDeck(input, options = {}) {
143
146
  const format = EXPORT_FORMATS[options.format];
@@ -213,6 +216,8 @@ export async function exportDeck(input, options = {}) {
213
216
  metadata,
214
217
  signal,
215
218
  fontData: pdfFontData(embeddedFonts),
219
+ // RR-63: the renderer's browser entry imports no pdf-lib; raster mode takes the module the host imported.
220
+ ...(options.pdfMode === "raster" && options.pdfLib ? { pdfLib: options.pdfLib } : {}),
216
221
  ...(options.fallbackFamily ? { defaultFontFamily: options.fallbackFamily } : {}),
217
222
  onDiagnostic: (diagnostic) => { const described = describeDiagnostic(diagnostic, "pdf"); if (keep(described)) note(described); },
218
223
  onProgress: ({ page, pages }) => progress("convert", page, pages, `Page ${page} of ${pages}`),
@@ -107,6 +107,7 @@ function payloadFields(fields, state, base, payload, prefix = "") {
107
107
  }));
108
108
  }
109
109
  if (isObject(payload.chart)) field(fields, state, at("chart", "alt"), payload.chart.alt, `${prefix}Chart alt text`, "alt");
110
+ if (isObject(payload.table)) field(fields, state, at("table", "alt"), payload.table.alt, `${prefix}Table alt text`, "alt");
110
111
  const table = payload.table;
111
112
  if (isObject(table)) {
112
113
  if (Array.isArray(table.columns)) table.columns.forEach((cell, c) => (isObject(cell) && !("value" in cell) ? field(fields, state, at("table", "columns", String(c), "name"), cell.name, `${prefix}Table header ${c + 1}`, "table") : cellFields(fields, state, at("table", "columns", String(c)), cell, `${prefix}Table header ${c + 1}`)));
package/dist/font-gate.js CHANGED
@@ -13,17 +13,6 @@ export const FONTS_UNAVAILABLE = "fonts-unavailable";
13
13
 
14
14
  const GATE = Symbol("opf-editor.fontGate");
15
15
 
16
- /**
17
- * The fonts a preview draws with: the handle's text measurement and nothing else. A full handle also lists `embeddedFonts`, which
18
- * the renderer writes into every SVG it returns (an export needs that, so the SVG carries its faces); a preview inside a page
19
- * that already loaded the faces would repeat megabytes of base64 in every canvas, thumbnail and panel preview. The measurement
20
- * is read from the handle at each use, so faces it loads later still measure.
21
- */
22
- export function previewFonts(fonts) {
23
- if (!fonts || typeof fonts !== "object") return undefined;
24
- return { get textMeasurement() { return fonts.textMeasurement; } };
25
- }
26
-
27
16
  /**
28
17
  * The gate over a fonts handle: the two calls the canvas and the export need, with the editor's error contract.
29
18
  *
package/dist/index.d.ts CHANGED
@@ -162,6 +162,7 @@ export interface CatalogSelectOptions extends CatalogOptionsInput {
162
162
  path: string;
163
163
  catalogKind: string;
164
164
  label?: string;
165
+ onError?: (error: Error) => void;
165
166
  document?: { createElement(tagName: string): any };
166
167
  }
167
168
 
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commitCatalogControl } from "./catalog-control.js";
1
2
  import { paginateSlide } from "@openpresentation/opf/pagination";
2
3
  import { OPFPatchError, applyPatch as applyCorePatch, applyPatchWithInverse, formatPointer, invertPatch, jsonEqual, parsePointer, readPointer } from "@openpresentation/opf/patch";
3
4
  import { collectReservedPresentationIds } from "./presentation-ids.js";
@@ -523,8 +524,19 @@ export function setCatalogId(editor, path, catalogKind, id, meta = {}) {
523
524
  const current = editor.get(path);
524
525
  const value = catalogReferenceValue(current, catalogId);
525
526
  const { catalogs: _catalogs, presentation: _presentation, ...commitMeta } = meta;
526
- return editor.set(path, value, {
527
+ // Only documented design references may initialize their optional design parent.
528
+ // Ordinary set/applyPatch retain RFC 6902's existing-parent requirement.
529
+ const segments = splitOpfPath(path);
530
+ const designField = { themes: "theme", colorSchemes: "colorScheme", fontSchemes: "fontScheme" }[catalogKind];
531
+ const deckDesign = segments.length === 2 && segments[0] === "design";
532
+ const slideDesign = segments.length === 4 && segments[0] === "slides" && /^(0|[1-9][0-9]*)$/.test(segments[1]) && segments[2] === "design" && editor.get(segments.slice(0, 2));
533
+ const parent = segments.slice(0, -1);
534
+ const patches = designField === segments.at(-1) && (deckDesign || slideDesign) && !hasValueAtPath(editor.presentation, parent)
535
+ ? [{ op: "add", path: opfPathToJsonPointer(parent), value: { [designField]: value } }]
536
+ : createValuePatch(editor.presentation, path, value);
537
+ return editor.applyPatch(patches, {
527
538
  ...commitMeta,
539
+ rejectInvalid: true,
528
540
  catalogKind,
529
541
  catalogId,
530
542
  source: commitMeta.source ?? "catalog-control"
@@ -589,13 +601,13 @@ export function createCatalogSelect(editor, options) {
589
601
  const current = editor.get(path);
590
602
  select.value = typeof current === "object" && current ? current.id : current ?? "";
591
603
  };
592
- const onChange = () => {
593
- setCatalogId(editor, path, catalogKind, select.value, {
604
+ const onChange = () => commitCatalogControl(select, options?.onError, updateValue, () => {
605
+ return setCatalogId(editor, path, catalogKind, select.value, {
594
606
  catalogs: options?.catalogs,
595
607
  presentation: options?.presentation,
596
608
  source: "catalog-select"
597
609
  });
598
- };
610
+ });
599
611
  const unsubscribe = editor.subscribe(updateValue);
600
612
 
601
613
  select.addEventListener?.("change", onChange);
@@ -2,7 +2,7 @@ import { checkFormat, firstErrorMessage } from "./checks.js";
2
2
  import { listCatalogRecords } from "./catalogs.js";
3
3
  import { schemaAtPath, schemaVariants } from "./schema.js";
4
4
  import { applyEdits, findNodeAtLocation, findNodeAtOffset, getNodePath, modify, parseTree } from "jsonc-parser";
5
- import { populateLayoutPlaceholders } from "./layout-placeholders.js";
5
+ import { layoutSlotSummary, layoutSlotTypes, populateLayoutPlaceholders } from "./layout-placeholders.js";
6
6
  // The catalog kind a field references (OPF 0.15: every content kind is `id` or `name:id`), read from where the field sits.
7
7
  const ROOT_REFERENCES = { narrative: "narratives", audience: "audiences", purpose: "purposes", tone: "tones" };
8
8
  const DESIGN_REFERENCES = { theme: "themes", colorScheme: "colorSchemes", fontScheme: "fontSchemes" };
@@ -21,10 +21,9 @@ function catalogAtPath(path) {
21
21
  }
22
22
  // Compare declared placeholders, including multiplicity. This describes a layout
23
23
  // contract, not a claim that arbitrary slide content will render without overflow.
24
+ // FA-26: a record's placeholder groups compare by their leaf regions, the slots its content fills.
24
25
  function layoutTypes(record) {
25
- if (!Array.isArray(record?.placeholders))
26
- return undefined;
27
- return record.placeholders.map(p => p.type).sort();
26
+ return layoutSlotTypes(record)?.sort();
28
27
  }
29
28
  function placeholderSummary(types) {
30
29
  if (!types)
@@ -57,8 +56,10 @@ function describeOptions(choices, records, current, catalog) {
57
56
  if (catalog !== "layouts")
58
57
  continue;
59
58
  const types = layoutTypes(option.value === current ? currentRecord : records.get(String(option.value)));
60
- option.placeholderTypes = records.get(String(option.value))?.placeholders?.map(placeholder => placeholder.type);
61
- option.placeholders = placeholderSummary(types);
59
+ const record = records.get(String(option.value));
60
+ option.placeholderTypes = layoutSlotTypes(record);
61
+ // A record with placeholder groups also shows how its slots nest.
62
+ option.placeholders = placeholderSummary(types) + (record?.placeholders?.some?.(entry => entry?.type === "group") ? ` · ${layoutSlotSummary(record)}` : "");
62
63
  const same = types && currentTypes && signature(types) === signature(currentTypes);
63
64
  const equivalent = types && currentTypes && signature(compatible(types)) === signature(compatible(currentTypes));
64
65
  const sameKinds = types && currentTypes && signature([...new Set(compatible(types))]) === signature([...new Set(compatible(currentTypes))]);
@@ -60,6 +60,19 @@ export function createLayoutHandles(root, options) {
60
60
  lastGeometry=geometry;
61
61
  layer.replaceChildren();
62
62
  if(!enabled||options.isTextEditing())return;
63
+ // FA-26: the slots of a layout record with placeholder groups, drawn under the handles while arranging: each group and
64
+ // region as a dashed outline at its cell, deeper groups inset, an empty region labelled with its kind. They are the
65
+ // record's, not the document's, so they are not editable here; the content in a slot keeps its own selection.
66
+ for(const slot of geometry.slots??[]){
67
+ const node=doc.createElement('div');
68
+ node.dataset.layoutSlot=slot.path;node.dataset.layoutSlotType=slot.type;node.setAttribute('aria-hidden','true');
69
+ const group=slot.type==='group',empty=!group&&!slot.content;
70
+ node.title=group?`Layout group (${slot.path})`:`${empty?'Empty ':''}${slot.type} slot (${slot.path})`;
71
+ const inset=group?Math.min(4,slot.depth*2):0;
72
+ node.style.cssText=`position:absolute;pointer-events:none;box-sizing:border-box;left:calc(${slot.box.x/geometry.width*100}% + ${inset}px);top:calc(${slot.box.y/geometry.height*100}% + ${inset}px);width:calc(${slot.box.width/geometry.width*100}% - ${2*inset}px);height:calc(${slot.box.height/geometry.height*100}% - ${2*inset}px);border:1px dashed ${group?'#8170d5':'#8a78d7aa'};border-radius:4px;${empty?'background:#8a78d714;':''}`;
73
+ if(empty){const label=doc.createElement('span');label.textContent=`Empty ${slot.type} slot`;label.style.cssText='position:absolute;left:6px;top:4px;font:11px system-ui;color:#675394';node.append(label);}
74
+ layer.append(node);
75
+ }
63
76
  for(const flow of geometry.flows??[]){
64
77
  const {axis,size,tracks}=trackData(flow);
65
78
  if(tracks.length<2||tracks.length>12||flow.itemCount!==flow.slotCount)continue;
@@ -1,5 +1,20 @@
1
+ import {layoutLeaves,layoutStructure} from '@openpresentation/opf/composition';
1
2
  import {listBlockContainers,prepareBlockInsert} from './blocks.js';
2
3
 
4
+ /**
5
+ * The slot kinds a layout record declares, in reading order: its headings and the leaf regions of its placeholder groups
6
+ * (FA-26), the kinds a slide's flat content fills. Undefined when the record states no placeholders.
7
+ */
8
+ export function layoutSlotTypes(record){
9
+ if(!Array.isArray(record?.placeholders))return undefined;
10
+ try{return layoutLeaves(record).map(leaf=>leaf.type);}catch{return record.placeholders.filter(entry=>entry?.type!=='group').map(entry=>entry?.type);}
11
+ }
12
+ /** A one-line description of a record's slots, nested groups in parentheses: `title, column (text, text), chart`. */
13
+ export function layoutSlotSummary(record){
14
+ if(!Array.isArray(record?.placeholders))return '';
15
+ try{return layoutStructure(record);}catch{return layoutSlotTypes(record).join(', ');}
16
+ }
17
+
3
18
  const payloadFields=['text','items','bullets','image','video','chart','table','code','metric','quote','timeline'];
4
19
  const headingFields=new Set(['title','subtitle','tag']);
5
20
  const fields={text:'text',list:'items',image:'image',video:'video',chart:'chart',table:'table',code:'code',metric:'metric',quote:'quote',timeline:'timeline'};
package/dist/react.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface OPFCatalogSelectProps {
14
14
  path: string;
15
15
  catalogKind: string;
16
16
  label?: string;
17
+ onError?: (error: Error) => void;
17
18
  catalogOptions?: CatalogOption[];
18
19
  selectProps?: Record<string, unknown>;
19
20
  [key: string]: unknown;
package/dist/react.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commitCatalogControl } from "./catalog-control.js";
1
2
  import {
2
3
  getCatalogOptions,
3
4
  getValueAtPath,
@@ -56,6 +57,7 @@ export function createOPFReactComponents(React) {
56
57
  catalogKind,
57
58
  label,
58
59
  catalogOptions,
60
+ onError,
59
61
  selectProps = {},
60
62
  ...props
61
63
  }) {
@@ -78,7 +80,9 @@ export function createOPFReactComponents(React) {
78
80
  selectProps.onChange?.(event);
79
81
  props.onChange?.(event);
80
82
  if (!event.defaultPrevented) {
81
- setCatalogId(editor, path, catalogKind, event.currentTarget.value, { source: "react-catalog-select" });
83
+ const node = event.currentTarget;
84
+ commitCatalogControl(node, onError, () => { node.value = currentId ?? ""; }, () =>
85
+ setCatalogId(editor, path, catalogKind, node.value, { source: "react-catalog-select" }));
82
86
  }
83
87
  }
84
88
  },
@@ -7,6 +7,7 @@ import {
7
7
  addSection, addSlide, duplicateSlides, hasSections, listSections, moveSection, moveSlides, moveSlidesBy, removeSection, removeSlides, renameSection, setHidden, setSection, slideTitle,
8
8
  } from "./slides.js";
9
9
  import { listSwitchOptions } from "./switches.js";
10
+ import { layoutSlotSummary } from "./layout-placeholders.js";
10
11
 
11
12
  let instances = 0;
12
13
  const SVG_NS = "http://www.w3.org/2000/svg";
@@ -503,7 +504,8 @@ export function createSlideManager(container, options) {
503
504
  };
504
505
  const describe = () => {
505
506
  const choice = choices.find((entry) => entry.id === select.value);
506
- const slots = Array.isArray(choice?.record?.placeholders) ? choice.record.placeholders.map((slot) => slot.type).join(", ") : "";
507
+ // FA-26: nested placeholder groups show with their mode, for example `title, column (text, text), chart`.
508
+ const slots = layoutSlotSummary(choice?.record);
507
509
  detail.textContent = choice ? `${choice.record?.description ?? ""}${slots ? ` Slots: ${slots}.` : choice.record?.placeholders ? " An empty slide." : ""}`.trim() : "No layout matches.";
508
510
  };
509
511
  const form = el("form", { method: "dialog" },
package/dist/svelte.d.ts CHANGED
@@ -11,6 +11,7 @@ export interface OPFCatalogSelectActionParams extends CatalogOptionsInput {
11
11
  path: string;
12
12
  catalogKind: string;
13
13
  label?: string;
14
+ onError?: (error: Error) => void;
14
15
  }
15
16
 
16
17
  export interface OPFTraceActionParams extends SvgTraceBindingOptions {
package/dist/svelte.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { commitCatalogControl } from "./catalog-control.js";
1
2
  import {
2
3
  createSvgTraceBinding,
3
4
  getCatalogOptions,
@@ -59,11 +60,11 @@ export function opfCatalogSelect(node, params) {
59
60
  const value = editor.get(path);
60
61
  node.value = value && typeof value === "object" ? value.id : value ?? "";
61
62
  };
62
- const onChange = () => setCatalogId(editor, path, catalogKind, node.value, {
63
+ const onChange = () => commitCatalogControl(node, nextParams.onError, sync, () => setCatalogId(editor, path, catalogKind, node.value, {
63
64
  catalogs: nextParams.catalogs,
64
65
  presentation: nextParams.presentation,
65
66
  source: "svelte-catalog-select"
66
- });
67
+ }));
67
68
  const unsubscribe = editor.subscribe(sync);
68
69
 
69
70
  node.addEventListener?.("change", onChange);
package/dist/switches.js CHANGED
@@ -13,7 +13,7 @@ import { chartOptionTarget } from "@openpresentation/opf/composition";
13
13
  import { createValuePatch, applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
14
14
  import { createContentBlock, prepareBlockReplace } from "./blocks.js";
15
15
  import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
16
- import { populateLayoutPlaceholders } from "./layout-placeholders.js";
16
+ import { layoutSlotTypes, populateLayoutPlaceholders } from "./layout-placeholders.js";
17
17
  import { blockConversionTargets, prepareBlockConversion } from "./block-convert.js";
18
18
  import { checkFormat } from "./checks.js";
19
19
  import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
@@ -273,7 +273,8 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
273
273
  if (withRecord.slides[slideIndex].layout !== choice.reference) {
274
274
  const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], choice.reference);
275
275
  const swapped = applyJsonPatch(withRecord, layoutOps);
276
- const types = Array.isArray(record.placeholders) ? record.placeholders.map((placeholder) => placeholder.type) : undefined;
276
+ // FA-26: a record's placeholder groups contribute their leaf regions, in reading order.
277
+ const types = layoutSlotTypes(record);
277
278
  // Add the blank payloads the layout declares, as the JSON editor's layout choice does.
278
279
  const populated = types ? populateLayoutPlaceholders(swapped, slideIndex, types) : swapped;
279
280
  patches = same(populated.slides[slideIndex], swapped.slides[slideIndex])
@@ -37,7 +37,7 @@ export interface TableCellState extends TableCellAddress {
37
37
  rows: number;
38
38
  }
39
39
  export interface PreparedTableChange {
40
- action: "merge" | "split" | "style" | "table-style";
40
+ action: "merge" | "split" | "style" | "table-style" | "table-alt";
41
41
  tablePath: string;
42
42
  presentation: unknown;
43
43
  patches: JsonPatchOperation[];
@@ -72,6 +72,11 @@ export declare function mergeTableCells(editor: EditorSession, tablePath: string
72
72
  export declare function splitTableCell(editor: EditorSession, tablePath: string, cell: TableCellAddress, meta?: Record<string, unknown>): TableChange;
73
73
  export declare function setTableCellStyle(editor: EditorSession, tablePath: string, cells: TableCellAddress | TableCellAddress[], style: Record<string, unknown> | null, meta?: Record<string, unknown>): TableChange;
74
74
  export declare function setTableStyle(editor: EditorSession, tablePath: string, preset: keyof typeof TABLE_STYLE_PRESETS | Partial<TableStyle>, meta?: Record<string, unknown>): TableChange;
75
+ /** FA-27: the table's text alternative as form state: `alt` (empty when absent or decorative) and `decorative` (the empty alt). */
76
+ export declare function readTableAlt(table: unknown): { alt: string; decorative: boolean };
77
+ /** FA-27: change the table's text alternative. `alt` is trimmed and an empty string or null removes it; `decorative: true` writes the empty alt and wins over `alt`; `decorative: false` removes an empty alt. Works on inline and dataset-backed tables. */
78
+ export declare function prepareTableAlt(presentation: unknown, tablePath: string, change: { alt?: string | null; decorative?: boolean }): PreparedTableChange;
79
+ export declare function setTableAlt(editor: EditorSession, tablePath: string, change: { alt?: string | null; decorative?: boolean }, meta?: Record<string, unknown>): TableChange;
75
80
  /** {@link readTableStyle} for a table object. */
76
81
  export declare function readTableStyleOfTable(table: unknown): (TableStyle | { header: "custom"; banding: "custom"; borders: "custom" }) & { preset: keyof typeof TABLE_STYLE_PRESETS | "custom" };
77
82
  /** Apply a table style to a table object in place (the structure operations use it to keep banding, header fill and borders correct). */
@@ -394,6 +394,44 @@ export function readTableStyleOfTable(table) {
394
394
  // Table structure (RR-24): insert, delete, move and sort rows and columns, the header row, cell values.
395
395
  export * from "./table-structure.js";
396
396
 
397
+ // --- text alternative (FA-27) -----------------------------------------------------------------
398
+
399
+ /**
400
+ * The table's text alternative as form state, like a chart's: `alt` is the text (empty when absent or decorative) and `decorative`
401
+ * is the empty alt, a reviewed choice and not a missing one.
402
+ */
403
+ export function readTableAlt(table) {
404
+ return { alt: typeof table?.alt === "string" ? table.alt : "", decorative: table?.alt === "" };
405
+ }
406
+
407
+ /**
408
+ * Compute the patch that changes the table's text alternative. `change` is `{ alt?, decorative? }`: `alt` is trimmed and an empty string
409
+ * or `null` removes it, `decorative: true` writes the empty alt (and wins over `alt`), `decorative: false` removes an empty alt. It works
410
+ * on an inline table and on a table that shows a shared dataset, because `alt` belongs to the table and not to its data. The document
411
+ * is not modified.
412
+ */
413
+ export function prepareTableAlt(presentation, tablePath, change) {
414
+ const before = checkFormat(presentation);
415
+ const parts = splitOpfPath(tablePath);
416
+ const table = getValueAtPath(presentation, parts);
417
+ if (!isObject(table) || (!Array.isArray(table.rows) && typeof table.dataset !== "string")) throw fail("table-not-found", "Choose a table (a path ending in .table).", { tablePath });
418
+ if (!isObject(change)) throw fail("invalid-table-option", "A table alt change is { alt?, decorative? }.", { change });
419
+ let wanted;
420
+ if (change.decorative === true) wanted = "";
421
+ else if (change.alt !== undefined) wanted = typeof change.alt === "string" && change.alt.trim() ? change.alt.trim() : undefined;
422
+ else if (change.decorative === false && table.alt === "") wanted = undefined;
423
+ else wanted = table.alt;
424
+ const pointer = opfPathToJsonPointer([...parts, "alt"]);
425
+ const present = Object.hasOwn(table, "alt");
426
+ const patches = [];
427
+ if (wanted === undefined) {
428
+ if (present) patches.push({ op: "remove", path: pointer });
429
+ } else if (!present) patches.push({ op: "add", path: pointer, value: wanted });
430
+ else if (table.alt !== wanted) patches.push({ op: "replace", path: pointer, value: wanted });
431
+ const next = checkedDocument(presentation, patches, before);
432
+ return { action: "table-alt", tablePath: parts.join("."), changed: patches.length > 0, patches, presentation: clone(next) };
433
+ }
434
+
397
435
  // --- session forms ----------------------------------------------------------------------------
398
436
 
399
437
  function commit(editor, prepared, meta = {}) {
@@ -423,6 +461,11 @@ export function setTableCellStyle(editor, tablePath, cells, style, meta = {}) {
423
461
  checkEditor(editor);
424
462
  return commit(editor, prepareTableCellStyle(editor.presentation, tablePath, cells, style), meta);
425
463
  }
464
+ /** Change the table's text alternative as one undoable transaction. See {@link prepareTableAlt}. */
465
+ export function setTableAlt(editor, tablePath, change, meta = {}) {
466
+ checkEditor(editor);
467
+ return commit(editor, prepareTableAlt(editor.presentation, tablePath, change), meta);
468
+ }
426
469
  /** Apply a table style as one undoable transaction. See {@link prepareTableStyle}. */
427
470
  export function setTableStyle(editor, tablePath, preset, meta = {}) {
428
471
  checkEditor(editor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -226,6 +226,7 @@
226
226
  "test:code-browser": "node test/code-browser.mjs",
227
227
  "test:selection-browser": "node test/selection-browser.mjs",
228
228
  "test:click-entry-browser": "node test/click-entry-browser.mjs",
229
+ "test:canvas-a11y-browser": "node test/canvas-a11y-browser.mjs",
229
230
  "test:metric-browser": "node test/metric-browser.mjs",
230
231
  "test:rich-input-browser": "node test/rich-input-browser.mjs",
231
232
  "test:json": "npm run build && node test/json-source.mjs && node test/json-options.mjs",
@@ -257,11 +258,11 @@
257
258
  "@codemirror/state": "6.7.6",
258
259
  "@codemirror/view": "6.43.13",
259
260
  "@lezer/highlight": "1.2.4",
260
- "@openpresentation/opf": "^0.15.1",
261
+ "@openpresentation/opf": "^0.16.0",
261
262
  "jsonc-parser": "3.3.1"
262
263
  },
263
264
  "peerDependencies": {
264
- "@openpresentation/opf-render": "^0.15.0"
265
+ "@openpresentation/opf-render": "^0.16.0"
265
266
  },
266
267
  "peerDependenciesMeta": {
267
268
  "@openpresentation/opf-render": {
@@ -269,6 +270,12 @@
269
270
  }
270
271
  },
271
272
  "devDependencies": {
273
+ "@expo-google-fonts/arimo": "0.4.3",
274
+ "@expo-google-fonts/caladea": "0.4.2",
275
+ "@expo-google-fonts/cousine": "0.4.3",
276
+ "@expo-google-fonts/gelasio": "0.4.1",
277
+ "@expo-google-fonts/noto-color-emoji": "0.4.6",
278
+ "@expo-google-fonts/noto-emoji": "0.4.7",
272
279
  "@expo-google-fonts/noto-naskh-arabic": "0.4.5",
273
280
  "@expo-google-fonts/noto-nastaliq-urdu": "0.4.2",
274
281
  "@expo-google-fonts/noto-sans": "0.4.2",
@@ -303,15 +310,17 @@
303
310
  "@expo-google-fonts/noto-sans-thai": "0.4.2",
304
311
  "@expo-google-fonts/noto-serif-hebrew": "0.4.2",
305
312
  "@expo-google-fonts/noto-serif-tibetan": "0.4.2",
306
- "@openpresentation/opf-pptx": "^0.15.0",
307
- "@openpresentation/opf-render": "^0.15.0",
313
+ "@expo-google-fonts/roboto": "0.4.3",
314
+ "@expo-google-fonts/roboto-mono": "0.4.2",
315
+ "@expo-google-fonts/stix-two-math": "0.4.0",
316
+ "@expo-google-fonts/tinos": "0.4.2",
317
+ "@openpresentation/opf-pptx": "^0.16.0",
318
+ "@openpresentation/opf-render": "^0.16.0",
308
319
  "axe-core": "4.13.0",
309
320
  "esbuild": "0.28.2",
310
321
  "jszip": "3.10.2",
322
+ "pdf-lib": "1.17.1",
311
323
  "pdfjs-dist": "6.3.289",
312
- "playwright": "1.63.0",
313
- "@expo-google-fonts/noto-color-emoji": "0.4.6",
314
- "@expo-google-fonts/noto-emoji": "0.4.7",
315
- "@expo-google-fonts/stix-two-math": "0.4.0"
324
+ "playwright": "1.63.0"
316
325
  }
317
326
  }