@openpresentation/opf-editor 0.15.0 → 0.17.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
 
@@ -317,12 +336,13 @@ The playground's Content tab mounts the block-level and slide-structure actions
317
336
  `@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.
318
337
 
319
338
  ```js
320
- import { setDesignOption, setLogoVariant, setHeaderFooterZone, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
339
+ import { setDesignOption, setLogoVariant, setHeaderFooterZone, insertZoneValue, getDesignOption, designWarnings } from "@openpresentation/opf-editor/design-options";
321
340
 
322
341
  setDesignOption(editor, "titleAlignment", "center", { slideIndex: 2 }); // /slides/2/design/titleAlignment
323
342
  setDesignOption(editor, "watermark", { src: "asset:mark", opacity: 0.1 }); // merges into an existing watermark; false hides an inherited one
324
343
  setLogoVariant(editor, "light", "asset:logo-white"); // default stays a bare source; more variants make a LogoSet
325
- setHeaderFooterZone(editor, "footer", "right", { slideNumber: true, logo: true });
344
+ setHeaderFooterZone(editor, "footer", "right", { text: "{{slide.number}} / {{deck.slideCount}}", logo: true });
345
+ insertZoneValue(editor, "footer", "left", "organization.name", { start: 0, end: 0 }); // puts {{organization.name}} at the start of the left text
326
346
  ```
327
347
 
328
348
  | Option | Writes | Values |
@@ -337,9 +357,9 @@ setHeaderFooterZone(editor, "footer", "right", { slideNumber: true, logo: true }
337
357
  | `logo` | `design.logo` | a source, an Asset object or a LogoSet; `setLogoVariant` edits one of the 12 variants |
338
358
  | `organizationLogo` | `organization.logo` (deck only; `index` picks an organization) | a source or Asset object |
339
359
  | `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) |
340
- | header and footer zones | `design.header` / `design.footer` `.left/.center/.right` | `setHeaderFooterZone` merges every part a zone can hold (`text`, `logo`, `image`, `slideNumber` and `slideNumberFormat` (must contain `{current}`), `date` (true, or a fixed date) and `dateFormat`, `organization`, `speaker` (the first speaker's name and title), `socials`, `section`; `ZONE_FIELDS`) into one zone, checking each value; a removed field, an emptied zone and an emptied header are all deleted rather than left as `{}`. A slide's own header or footer replaces the deck's whole one, so the first edit on a slide starts from a copy of the deck's (its other zones stay), and a slide emptied that way hides the furniture (`false`) instead of inheriting it again |
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 |
341
361
 
342
- A deck-scope change reports `shadowed` slides whose own design hides it (`clearSlideOverrides: true` removes those values in the same transaction). Results carry `warnings`: a header or footer zone with `logo: true`, or picture bullets, with no logo to draw (no slide, deck or primary-organization logo) is reported as `unresolved-logo`, and a zone that shows the organization or its social profiles when there are none as `unresolved-content`, before export, as `designWarnings(presentation, slideIndex)` does for the current document. `DESIGN_OPTIONS` describes every option for a generic panel, and `getDesignOption` reads `{ value, scope, inherited }`.
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 }`.
343
363
 
344
364
  ### Image uploads (RR-06)
345
365
 
@@ -398,6 +418,8 @@ setTableCellStyle(editor, "slides.4.blocks.1.table", [{ section: "header", colum
398
418
 
399
419
  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
420
 
421
+ 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.
422
+
401
423
  ## Slide management (RR-21)
402
424
 
403
425
  `@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`).
@@ -478,7 +500,7 @@ const selectionControls = createDesignControls(contentPanel, { editor, getSlideI
478
500
  controls.refresh(); // when the slide or the selection changes
479
501
  ```
480
502
 
481
- Sections: `look` (theme, color scheme, font scheme, language, this slide's layout), `background` (type, theme slot, solid color, gradient with its stops, image with fit, focus, alt text, recolor and overlay, any of the 54 patterns, opacity; a draft until Apply, with Remove), `header-footer` (choose header or footer and a zone, then every part the zone supports: text, logo, image, organization, socials, section, slide number and format, current or fixed date and format, with a summary of the zones in use), `brand` (logo variants, organization logo, picture bullets, accent font, watermark), `layout-options` (alignment, direction, primary chart, content box, default image fit), `info` (narrative, tone, audience, socials), `selection` (content type with a loss report, list levels, group of metrics, group and ungroup, image to background or watermark, replacement, chart type), `image` (the selected image block's fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio and placement), `catalog` (reference findings and the explicit "Update from catalog"), `slide-content` (blocks to regions and back, design images back into the content) and `table` (style, merge, split, cell fill and alignment). The panel is native `details`, `fieldset`, `select`, checkbox and text controls with a label on each, so it works with the keyboard and with screen readers: groups open with Enter or Space, a select changes with the arrow keys, text fields commit on Enter or when you leave them, and results and refusals are announced in `role="status"` and `role="alert"` regions. A refused change (an invalid value, text a merge would hide) is explained, changes nothing, and puts the field back. "Applies to" switches the design controls between the whole presentation and the current slide, and a control shows when a slide value is "set on this slide" or "from the presentation". The panel offers only conversions that exist, shows what a conversion loses before you choose it, and lists why an unavailable one is unavailable.
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.
482
504
 
483
505
  In React (or Svelte, or anything else) mount it into a ref and destroy it on unmount; it needs no framework runtime:
484
506
 
@@ -540,13 +562,13 @@ const panel = createTemplatePanel(container, {
540
562
  });
541
563
  ```
542
564
 
543
- 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. A checkbox marks the document as a template.
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.
544
566
 
545
- 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, 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 playground adds a **Fill template** button.
567
+ 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.
546
568
 
547
569
  ## Find and replace (RR-25)
548
570
 
549
- `@openpresentation/opf-editor/find` is the headless model and `/find-panel` the DOM panel. The model lists every piece of text in a document (`collectSearchFields`: presentation name and description, header and footer text, and per slide the title, subtitle, tag, section, text and its runs, list items and bullets, code, string metrics and their label, unit and delta, quotes with attribution and source, timeline names, dates and events, chart column labels and string cells, table headers and cells including styled cells, image and video alt text, and speaker notes). Numbers, ids, asset references, URLs, colours and layout names are never searched or changed. `findMatches(presentation, query, { matchCase, wholeWord, regex, notes, slideIndex })` returns the matches with the field, offsets and a context snippet; an invalid regular expression comes back as `error` and an empty match is skipped. `replaceAll(editor, query, replacement, options)` replaces every match as **one** `applyPatch` (one undo step restores every field; a document the schema rejects changes nothing) and `replaceMatch(editor, match, query, replacement, options)` replaces one. In regular-expression mode the replacement reads `$&`, `$1` to `$99`, `$<name>`, `` $` ``, `$'` and `$$`; otherwise it is literal.
571
+ `@openpresentation/opf-editor/find` is the headless model and `/find-panel` the DOM panel. The model lists every piece of text in a document (`collectSearchFields`: presentation name and description, header and footer text, and per slide the title, subtitle, tag, section, text and its runs, list items and bullets, code, string metrics and their label, unit and delta, quotes with attribution and source, timeline names, dates and events, chart column labels and string cells, table headers and cells including styled cells, image and video alt text, and speaker notes). Numbers, ids, asset references, URLs, colours and layout names are never searched or changed. `findMatches(presentation, query, { matchCase, wholeWord, regex, notes, slideIndex })` returns the matches with the field, offsets and a context snippet; an invalid regular expression comes back as `error` and an empty match is skipped, and so is a match that touches a variable token such as `{{slide.number}}` (a token is not text, and replacing part of its name would break it or turn it into literal text). `replaceAll(editor, query, replacement, options)` replaces every match as **one** `applyPatch` (one undo step restores every field; a document the schema rejects changes nothing) and `replaceMatch(editor, match, query, replacement, options)` replaces one. In regular-expression mode the replacement reads `$&`, `$1` to `$99`, `$<name>`, `` $` ``, `$'` and `$$`; otherwise it is literal.
550
572
 
551
573
  **Rich text.** A run array is searched as its runs joined, so a phrase that crosses a bold/plain boundary is found. A match inside one run changes only that run and every run keeps its formatting. A match that spans several runs is replaced with the formatting of the run that holds the first matched character (the Word and Google Docs rule): the matched characters of the later runs are removed, text before and after keeps its own formatting, and a run left empty is dropped (a field always keeps at least one run). A run written as a plain string stays a plain string.
552
574
 
@@ -595,7 +617,7 @@ import { exportDeck } from "@openpresentation/opf-editor/export";
595
617
  const result = await exportDeck(editor.presentation, {
596
618
  format: "pdf", // "pdf" | "png" | "svg"
597
619
  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
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
599
621
  fonts, // the renderer's fonts handle (loadFonts): measures, loads the faces the deck needs, supplies the faces to embed
600
622
  renderOptions, // catalogs, date, ...
601
623
  signal, onProgress, onDiagnostic,
@@ -609,7 +631,11 @@ const result = await exportDeck(editor.presentation, {
609
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`.
610
632
  - **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
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`.
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.
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.
635
+
636
+ 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
+
638
+ 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
639
 
614
640
  ## Optional React Bindings
615
641
 
@@ -657,7 +683,7 @@ Public npm package publication is handled by `.github/workflows/release.yml` thr
657
683
 
658
684
  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
685
 
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.
686
+ 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
687
 
662
688
  ## Local interactive demo
663
689
 
@@ -669,7 +695,7 @@ Nested content groups expose their bounds through `editor.composeSlide(index).gr
669
695
 
670
696
  `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
697
 
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).
698
+ `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
699
 
674
700
 
675
701
  ## Copy, paste, and gallery imports
@@ -680,7 +706,7 @@ The `/galleries` export provides `loadOpfGallery` and `loadOpfGalleryItem`, acce
680
706
 
681
707
  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
708
 
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.
709
+ 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
710
 
685
711
  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
712
 
@@ -716,6 +742,8 @@ The canvas can show draggable, keyboard-accessible dividers for root and nested
716
742
 
717
743
  ### Move complete blocks
718
744
 
745
+ 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.
746
+
719
747
  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
748
 
721
749
  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
+ }
@@ -21,9 +21,11 @@ import {
21
21
  DATE_FORMAT_TOKENS,
22
22
  HEADER_FOOTER_ZONES,
23
23
  LOGO_VARIANTS,
24
+ ZONE_VALUES,
24
25
  designWarnings,
25
26
  getDesignOption,
26
27
  headerFooterState,
28
+ insertZoneValue,
27
29
  prepareHeaderFooterZone,
28
30
  prepareLogoVariant,
29
31
  prepareDesignOption,
@@ -36,7 +38,7 @@ import {
36
38
  import { createContentControls } from "./content-controls.js";
37
39
  import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
38
40
  import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
39
- import { describeTableCell, mergeTableCells, parseTableCellPath, readTableStyle, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
41
+ import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, readTableStyle, setTableAlt, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
40
42
 
41
43
  /** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
42
44
  export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
@@ -228,9 +230,12 @@ export function createDesignControls(container, options = {}) {
228
230
  return api;
229
231
  }
230
232
 
231
- function textField(name, label, { help, placeholder, list, onCommit, type = "text" } = {}) {
233
+ // `multiline` makes it a two-line box: Enter commits (like a one-line field), Shift+Enter starts a new line.
234
+ function textField(name, label, { help, placeholder, list, onCommit, type = "text", multiline = false } = {}) {
232
235
  const id = nextId(name);
233
- const input = h("input", { id, type, placeholder, list: list ? `${id}-list` : undefined, autocomplete: "off", spellcheck: "false" });
236
+ const input = multiline
237
+ ? h("textarea", { id, rows: 2, placeholder, autocomplete: "off", spellcheck: "false" })
238
+ : h("input", { id, type, placeholder, list: list ? `${id}-list` : undefined, autocomplete: "off", spellcheck: "false" });
234
239
  const datalist = list ? h("datalist", { id: `${id}-list` }) : undefined;
235
240
  const { wrap, note } = wrapField(id, label, input, { help, extra: datalist });
236
241
  const api = {
@@ -246,6 +251,13 @@ export function createDesignControls(container, options = {}) {
246
251
  },
247
252
  };
248
253
  if (onCommit) input.addEventListener("change", () => onCommit(input.value));
254
+ if (onCommit && multiline) {
255
+ input.addEventListener("keydown", (event) => {
256
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing) return;
257
+ event.preventDefault();
258
+ onCommit(input.value);
259
+ });
260
+ }
249
261
  return api;
250
262
  }
251
263
 
@@ -655,7 +667,7 @@ export function createDesignControls(container, options = {}) {
655
667
  { value: "footer", label: "Footer" },
656
668
  ]);
657
669
  furniture.set(pick.which);
658
- const zoneSelect = selectField("hf-zone", "Zone", { help: "Each zone stacks its parts top to bottom: logo, image, text, organization, speaker, socials, section, slide number, date." });
670
+ const zoneSelect = selectField("hf-zone", "Zone", { help: "Each zone stacks its parts top to bottom: logo, image, text, social profiles, date." });
659
671
  zoneSelect.setOptions(HEADER_FOOTER_ZONES.map((value) => ({ value, label: titleCase(value) })));
660
672
  zoneSelect.set(pick.zone);
661
673
  furniture.select.addEventListener("change", () => {
@@ -673,19 +685,47 @@ export function createDesignControls(container, options = {}) {
673
685
  });
674
686
  const edit = (fields, done) => run(() => setHeaderFooterZone(editor, which(), zone(), fields, scoped()), done);
675
687
  const where = () => `${titleCase(which())} ${zone()}`;
676
- const text = textField("hf-text", "Text", { help: "Literal text. Clear it to remove.", onCommit: (value) => edit({ text: value }, `${where()} text changed.`) });
688
+ const currentText = () => {
689
+ const value = readHeaderFooterZone(editor.presentation, which(), zone(), scoped()).text;
690
+ return typeof value === "string" ? value : "";
691
+ };
692
+ const text = textField("hf-text", "Text", {
693
+ multiline: true,
694
+ help: "Literal text, with values added by Insert value ({{slide.number}}, {{deck.slideCount}}, {{slide.section}}, {{organization.name}}, {{speaker.name}}, {{deck.name}}). Shift+Enter starts a new line. Clear it to remove.",
695
+ onCommit: (value) => {
696
+ if (value === currentText()) return;
697
+ edit({ text: value }, `${where()} text changed.`);
698
+ },
699
+ });
700
+ // The menu inserts a variable token at the caret (or over the selection) as one edit, together with any text typed but not yet committed.
701
+ const insertValue = selectField("hf-insert-value", "Insert value", {
702
+ empty: "Choose a value…",
703
+ help: "Adds a value to the text at the cursor. It is a {{ }} variable: slide number, slide count and section change on every slide.",
704
+ });
705
+ insertValue.setOptions(ZONE_VALUES.map((value) => ({ value: value.name, label: value.label, title: value.token })));
706
+ insertValue.select.addEventListener("change", () => {
707
+ const name = insertValue.select.value;
708
+ insertValue.select.value = "";
709
+ if (!name) return;
710
+ const box = text.input;
711
+ const base = box.value;
712
+ const start = Math.min(box.selectionStart ?? base.length, base.length);
713
+ const end = Math.min(box.selectionEnd ?? start, base.length);
714
+ const { token, label } = ZONE_VALUES.find((value) => value.name === name);
715
+ run(() => insertZoneValue(editor, which(), zone(), name, { ...scoped(), text: base, start, end }), `${where()} text now includes ${label.toLowerCase()} (${token}).`);
716
+ // Back in the text box after the token, ready to carry on typing. The box shows the document's text again first: a sync leaves a
717
+ // focused box alone, and a stale value would be committed over the insert when the box loses focus.
718
+ box.value = currentText();
719
+ const caret = start + token.length;
720
+ box.focus();
721
+ box.setSelectionRange?.(caret, caret);
722
+ });
677
723
  const logo = checkField("hf-logo", "Show the logo", { onChange: (checked) => edit({ logo: checked }, `${where()} logo ${checked ? "shown" : "removed"}.`) });
678
724
  const image = imageCluster("hf-image", "Image", {
679
725
  apply: (ref) => setHeaderFooterZone(editor, which(), zone(), { image: ref }, scoped()),
680
726
  build: (ref, doc) => prepareHeaderFooterZone(doc, which(), zone(), { image: ref }, scoped()),
681
727
  help: "A picture in this zone, such as a partner mark or badge. An asset reference, a web address or a data address. Press Enter to apply; clear the field to remove it.",
682
728
  });
683
- const number = checkField("hf-number", "Show the slide number", { onChange: (checked) => edit({ slideNumber: checked }, `${where()} slide number ${checked ? "shown" : "removed"}.`) });
684
- const numberFormat = textField("hf-number-format", "Slide number format", {
685
- placeholder: "Page {current} of {total}",
686
- help: "Must contain {current}; {total} is the number of slides. Clear it for the plain number.",
687
- onCommit: (value) => edit({ slideNumberFormat: value.trim() }, `${where()} slide number format changed.`),
688
- });
689
729
  const dateNow = checkField("hf-date-now", "Show the current date", {
690
730
  help: "Needs the host to supply today's date when it renders or exports; otherwise the part is reported as unresolved.",
691
731
  onChange: (checked) => edit({ date: checked }, `${where()} date ${checked ? "shown" : "removed"}.`),
@@ -700,13 +740,11 @@ export function createDesignControls(container, options = {}) {
700
740
  help: `Tokens: ${DATE_FORMAT_TOKENS.join(", ")}. Text in single quotes is literal. Clear it for the default (M/d/yyyy).`,
701
741
  onCommit: (value) => edit({ dateFormat: value.trim() }, `${where()} date format changed.`),
702
742
  });
703
- const organization = checkField("hf-organization", "Show the organization name", { onChange: (checked) => edit({ organization: checked }, `${where()} organization ${checked ? "shown" : "removed"}.`) });
704
- const speaker = checkField("hf-speaker", "Show the speaker name and title", { onChange: (checked) => edit({ speaker: checked }, `${where()} speaker ${checked ? "shown" : "removed"}.`) });
705
743
  const socials = checkField("hf-socials", "Show the organization's social profiles", { onChange: (checked) => edit({ socials: checked }, `${where()} social profiles ${checked ? "shown" : "removed"}.`) });
706
- const section = checkField("hf-section", "Show the section label", { onChange: (checked) => edit({ section: checked }, `${where()} section label ${checked ? "shown" : "removed"}.`) });
744
+ let shownContext;
707
745
  const summary = h("ul", { class: "opf-dc-summary", "aria-label": "Zones in use" });
708
746
  const warnings = h("ul", { class: "opf-dc-warnings", "aria-label": "Header and footer warnings" });
709
- const controls = [text, logo, image, number, numberFormat, dateNow, dateFixed, dateFormat, organization, speaker, socials, section];
747
+ const controls = [text, insertValue, logo, image, dateNow, dateFixed, dateFormat, socials];
710
748
  body.append(furniture.wrap, hide.wrap, zoneSelect.wrap, summary, ...controls.map((control) => control.wrap), warnings);
711
749
  const describe = (fields) =>
712
750
  Object.entries(fields)
@@ -718,19 +756,22 @@ export function createDesignControls(container, options = {}) {
718
756
  hide.wrap.hidden = scopeIndex() === undefined ? !state.hidden : state.own && !state.hidden;
719
757
  hide.set(state.hidden, state.inherited && !state.hidden ? "from the presentation" : "");
720
758
  const fields = readHeaderFooterZone(editor.presentation, which(), zone(), scoped());
721
- text.set(typeof fields.text === "string" ? fields.text : "");
759
+ // A box that has the focus keeps what is being typed, except when it now belongs to another header or footer, zone or scope.
760
+ const textValue = typeof fields.text === "string" ? fields.text : "";
761
+ const textContext = `${target()}:${which()}:${zone()}`;
762
+ if (textContext !== shownContext) {
763
+ shownContext = textContext;
764
+ text.input.value = textValue;
765
+ }
766
+ text.set(textValue);
722
767
  logo.set(fields.logo === true);
723
768
  image.set(stringOf(fields.image), "", `${target()}:${which()}:${zone()}`);
724
- number.set(fields.slideNumber === true);
725
- numberFormat.set(typeof fields.slideNumberFormat === "string" ? fields.slideNumberFormat : "");
726
769
  dateNow.set(fields.date === true);
727
770
  dateFixed.set(typeof fields.date === "string" ? fields.date : "");
728
771
  dateFormat.set(typeof fields.dateFormat === "string" ? fields.dateFormat : "");
729
- organization.set(fields.organization === true);
730
- speaker.set(fields.speaker === true);
731
772
  socials.set(fields.socials === true);
732
- section.set(fields.section === true);
733
- for (const control of controls) for (const input of control.wrap.querySelectorAll("input,select,button")) input.disabled = state.hidden;
773
+ insertValue.set("");
774
+ for (const control of controls) for (const input of control.wrap.querySelectorAll("input,select,textarea,button")) input.disabled = state.hidden;
734
775
  summary.replaceChildren(
735
776
  ...HEADER_FOOTER_ZONES.map((name) => {
736
777
  const zoneFields = readHeaderFooterZone(editor.presentation, which(), name, scoped());
@@ -1158,8 +1199,19 @@ export function createDesignControls(container, options = {}) {
1158
1199
  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
1200
  align.setOptions(["left", "center", "right"].map((value) => ({ value, label: titleCase(value) })));
1160
1201
  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 };
1202
+ // FA-27: the table's text alternative, like a chart's. It belongs to the table, so a dataset-backed table has it too.
1203
+ const alt = textField("table-alt", "Alt text", {
1204
+ 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.",
1205
+ placeholder: "What the table shows",
1206
+ onCommit: (value) => run(() => setTableAlt(editor, dynamic.tablePath, { alt: value }), "Table alt text changed."),
1207
+ });
1208
+ alt.input.setAttribute("aria-label", "Table alt text");
1209
+ const decorative = checkField("table-decorative", "Decorative (no alt text)", {
1210
+ onChange: (checked) => run(() => setTableAlt(editor, dynamic.tablePath, { decorative: checked }), checked ? "Table marked decorative." : "The table is no longer decorative."),
1211
+ });
1212
+ const styleControls = [headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox];
1213
+ body.append(summary, alt.wrap, decorative.wrap, ...styleControls);
1214
+ dynamic.table = { details, summary, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
1163
1215
  }
1164
1216
 
1165
1217
  // --- sync -----------------------------------------------------------------------------------------
@@ -1210,12 +1262,21 @@ export function createDesignControls(container, options = {}) {
1210
1262
  const tablePath = parsed?.tablePath ?? (content?.key === "table" ? `${blockPath}.table` : undefined);
1211
1263
  dynamic.tablePath = tablePath;
1212
1264
  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.
1265
+ // A table that shows a shared dataset (RR-54) has no cell styles or merges: the style and merge controls are for inline tables.
1266
+ // Its alt text (FA-27) belongs to the table and not to the data, so the panel still opens for it.
1214
1267
  const tableValue = tablePath ? editor.get(tablePath) : undefined;
1215
- tableUi.details.hidden = !tablePath || !Array.isArray(tableValue?.rows);
1268
+ const inline = Array.isArray(tableValue?.rows);
1269
+ tableUi.details.hidden = !tablePath || !(inline || typeof tableValue?.dataset === "string");
1216
1270
  if (tablePath && !tableUi.details.hidden) {
1217
- const table = tableValue;
1218
1271
  tableUi.summary.textContent = `Table (${tablePath})`;
1272
+ const textAlternative = readTableAlt(tableValue);
1273
+ tableUi.alt.set(textAlternative.alt);
1274
+ tableUi.alt.input.disabled = textAlternative.decorative;
1275
+ tableUi.decorative.set(textAlternative.decorative);
1276
+ for (const node of tableUi.styleControls) node.hidden = !inline;
1277
+ }
1278
+ if (tablePath && !tableUi.details.hidden && inline) {
1279
+ const table = tableValue;
1219
1280
  const style = readTableStyle(document_, tablePath);
1220
1281
  dynamic.tableStyle = style.preset === "custom" ? { header: "theme", banding: false, borders: "theme" } : { header: style.header, banding: style.banding, borders: style.borders };
1221
1282
  tableUi.headerStyle.set(style.header);
@@ -25,7 +25,15 @@ export declare const DESIGN_OPTIONS: readonly DesignOptionDescriptor[];
25
25
  export declare const LOGO_VARIANTS: readonly ["default", "light", "dark", "stacked", "stackedLight", "stackedDark", "icon", "iconLight", "iconDark", "wordmark", "wordmarkLight", "wordmarkDark"];
26
26
  export type LogoVariant = (typeof LOGO_VARIANTS)[number];
27
27
  /** Every field a header or footer zone can hold. */
28
- export declare const ZONE_FIELDS: readonly ["logo", "text", "image", "slideNumber", "slideNumberFormat", "date", "dateFormat", "organization", "speaker", "socials", "section"];
28
+ export declare const ZONE_FIELDS: readonly ["logo", "text", "image", "date", "dateFormat", "socials"];
29
+ /** One entry of a zone's "Insert value" menu: the built-in variable `name`, its menu `label` and the `token` (`{{name}}`) that lands in the zone's `text`. */
30
+ export interface ZoneValue {
31
+ readonly name: "slide.number" | "deck.slideCount" | "slide.section" | "organization.name" | "speaker.name" | "deck.name";
32
+ readonly label: string;
33
+ readonly token: string;
34
+ }
35
+ /** The values a zone's "Insert value" menu offers, in menu order: slide number, slide count, section, organization, speaker, deck name. */
36
+ export declare const ZONE_VALUES: readonly ZoneValue[];
29
37
  /** Date format tokens (English names). */
30
38
  export declare const DATE_FORMAT_TOKENS: readonly string[];
31
39
  export declare const HEADER_FOOTER_ZONES: readonly ["left", "center", "right"];
@@ -87,24 +95,29 @@ export interface HeaderFooterZoneFields {
87
95
  logo?: boolean | null;
88
96
  text?: string | null;
89
97
  image?: string | Record<string, unknown> | null;
90
- slideNumber?: boolean | null;
91
- slideNumberFormat?: string | null;
92
98
  date?: boolean | string | null;
93
99
  dateFormat?: string | null;
94
- organization?: boolean | null;
95
- /** The first speaker's name and title ("Ada Lovelace, CTO"). */
96
- speaker?: boolean | null;
97
100
  socials?: boolean | null;
98
- section?: boolean | null;
99
101
  }
100
102
  export declare function prepareHeaderFooterZone(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, fields: HeaderFooterZoneFields, options?: DesignOptionOptions): PreparedDesignOption;
101
- /** Merge fields into one header or footer zone; null, false (flags) or "" remove a field, an empty zone and header are removed. A slide's own header replaces the deck's whole one, so the first edit on a slide starts from a copy of the deck's and keeps its other zones. */
103
+ /** Merge fields into one header or footer zone; null, false (flags) or "" remove a field, an empty zone and header are removed. Generated values are `{{ }}` variables in `text` (`ZONE_VALUES`). A slide's own header replaces the deck's whole one, so the first edit on a slide starts from a copy of the deck's and keeps its other zones. */
102
104
  export declare function setHeaderFooterZone(editor: EditorSession, which: "header" | "footer", zone: HeaderFooterZone, fields: HeaderFooterZoneFields, options?: DesignOptionOptions): DesignOptionChange;
105
+ export interface ZoneValueOptions extends DesignOptionOptions {
106
+ /** UTF-16 offsets into the zone's text; a selection is replaced. Default: the end. */
107
+ start?: number;
108
+ end?: number;
109
+ /** The text the offsets refer to, when it is not the document's yet (a text box with uncommitted typing). Default: the zone's text at the scope. */
110
+ text?: string;
111
+ }
112
+ /** The patch that inserts a value's token (`ZONE_VALUES` name, for example `slide.number`) into a zone's `text`, at `start` and `end` (default: the end). */
113
+ export declare function prepareZoneValue(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, name: ZoneValue["name"], options?: ZoneValueOptions): PreparedDesignOption;
114
+ /** Insert a value's token into one zone's `text` as a single undoable transaction. */
115
+ export declare function insertZoneValue(editor: EditorSession, which: "header" | "footer", zone: HeaderFooterZone, name: ZoneValue["name"], options?: ZoneValueOptions): DesignOptionChange;
103
116
  /** One zone's fields as they apply at a scope: the slide's own header or footer when it has one, else the deck's. */
104
117
  export declare function readHeaderFooterZone(presentation: unknown, which: "header" | "footer", zone: HeaderFooterZone, options?: Pick<DesignOptionOptions, "slideIndex">): HeaderFooterZoneFields;
105
118
  /** Whether the scope sets the header or footer itself (`own`), shows the deck's (`inherited`) or hides it with `false` (`hidden`). */
106
119
  export declare function headerFooterState(presentation: unknown, which: "header" | "footer", options?: Pick<DesignOptionOptions, "slideIndex">): { own: boolean; inherited: boolean; hidden: boolean };
107
120
  /** Whether a logo resolves for the slide: slide design, deck design, then the primary organization. */
108
121
  export declare function hasResolvableLogo(presentation: unknown, slideIndex: number): boolean;
109
- /** Settings that need content the document does not have: a logo (zones with `logo: true`, picture bullets), an organization or its social profiles, or a named speaker, for zones that show them. */
122
+ /** Settings that need content the document does not have: a logo (zones with `logo: true`, picture bullets), the organization's social profiles, or a value a zone's `text` asks for (`{{organization.name}}` without an organization, `{{slide.section}}` on a slide without a section). */
110
123
  export declare function designWarnings(presentation: unknown, slideIndex?: number): DesignWarning[];