@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.
@@ -5,6 +5,7 @@
5
5
  // session form that commits it with `meta.source: "design-option"`.
6
6
  import { getValueAtPath, opfPathToJsonPointer } from "./index.js";
7
7
  import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
8
+ import { listBuiltinVariables } from "@openpresentation/opf";
8
9
  import { checkFormat } from "./checks.js";
9
10
 
10
11
  /** The `design.logo` variant slots of a LogoSet, in schema order. */
@@ -24,8 +25,30 @@ export const LOGO_VARIANTS = Object.freeze([
24
25
  ]);
25
26
  export const HEADER_FOOTER_ZONES = Object.freeze(["left", "center", "right"]);
26
27
  // Flag fields of a header/footer zone: false is stored as "absent" (so is a `date` of false).
27
- const ZONE_FLAGS = ["logo", "slideNumber", "organization", "speaker", "socials", "section"];
28
- export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "slideNumber", "slideNumberFormat", "date", "dateFormat", "organization", "speaker", "socials", "section"]);
28
+ const ZONE_FLAGS = ["logo", "socials"];
29
+ export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "date", "dateFormat", "socials"]);
30
+ // FA-31: generated values are `{{ }}` variables inside a zone's `text`. The keys 0.16 had for them are gone; say what to write instead.
31
+ const REMOVED_ZONE_FIELDS = Object.freeze({
32
+ organization: "Write {{organization.name}} in the text.",
33
+ speaker: "Write {{speaker.name}} in the text.",
34
+ section: "Write {{slide.section}} in the text.",
35
+ slideNumber: "Write {{slide.number}} in the text.",
36
+ slideNumberFormat: "Write {{slide.number}} and {{deck.slideCount}} in the text.",
37
+ });
38
+ /**
39
+ * The values the "Insert value" menu of a header or footer zone offers, in menu order. `token` is what lands in the zone's `text`.
40
+ * `{{slide.number}}`, `{{deck.slideCount}}` and `{{slide.section}}` vary per slide; the others come from the deck's own fields.
41
+ */
42
+ export const ZONE_VALUES = Object.freeze(
43
+ [
44
+ { name: "slide.number", label: "Slide number" },
45
+ { name: "deck.slideCount", label: "Slide count" },
46
+ { name: "slide.section", label: "Section" },
47
+ { name: "organization.name", label: "Organization" },
48
+ { name: "speaker.name", label: "Speaker" },
49
+ { name: "deck.name", label: "Deck name" },
50
+ ].map((entry) => Object.freeze({ ...entry, token: `{{${entry.name}}}` })),
51
+ );
29
52
  /** Date tokens a `dateFormat` understands (English names, independent of the host locale). */
30
53
  export const DATE_FORMAT_TOKENS = Object.freeze(["yyyy", "yy", "MMMM", "MMM", "MM", "M", "dd", "d", "EEEE", "EEE"]);
31
54
  const ISO_DATE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
@@ -36,7 +59,6 @@ function checkZoneField(key, value) {
36
59
  if (ZONE_FLAGS.includes(key) && typeof value !== "boolean") throw bad(`${key} is true or false.`);
37
60
  if (key === "text" && typeof value !== "string") throw bad("Text is a string.");
38
61
  if (key === "image" && typeof value !== "string" && !isObject(value)) throw bad("An image is a source, an asset reference or an asset object.");
39
- if (key === "slideNumberFormat" && (typeof value !== "string" || !value.includes("{current}"))) throw bad("The slide number format must contain {current}, for example Page {current} of {total}.");
40
62
  if (key === "date" && typeof value !== "boolean" && typeof value !== "string") throw bad("A date is true (the current date) or a fixed date.");
41
63
  if (key === "dateFormat" && (typeof value !== "string" || !value.trim())) throw bad(`A date format uses tokens such as ${DATE_FORMAT_TOKENS.join(", ")}.`);
42
64
  }
@@ -90,20 +112,23 @@ function primaryOrganization(presentation) {
90
112
  return list.find((entry) => entry?.role === "primary") ?? list[0];
91
113
  }
92
114
 
93
- function firstSpeaker(presentation) {
94
- const list = Array.isArray(presentation.speaker) ? presentation.speaker : presentation.speaker ? [presentation.speaker] : [];
95
- return list[0];
96
- }
97
-
98
115
  /** Whether a logo resolves for the slide: slide design, then deck design, then the primary organization. */
99
116
  export function hasResolvableLogo(presentation, slideIndex) {
100
117
  return Boolean(presentation.slides?.[slideIndex]?.design?.logo ?? presentation.design?.logo ?? primaryOrganization(presentation)?.logo);
101
118
  }
102
119
 
120
+ /** The variable names a zone's `text` uses (`{{name}}` and `{{name|format}}`); a token with a backslash before it is literal text. */
121
+ function zoneTextVariables(text) {
122
+ const names = [];
123
+ for (const match of text.matchAll(/(\\?)\{\{\s*([A-Za-z0-9_.-]+)\s*(?:\|[^{}]*)?\}\}/g)) if (!match[1]) names.push(match[2]);
124
+ return names;
125
+ }
126
+
103
127
  /**
104
- * Plain-language warnings for settings that need a logo the document does not have: a header or
105
- * footer zone with `logo: true`, or picture bullets. The renderers fall back to a glyph or report
106
- * unresolved content; the editor surfaces it before export.
128
+ * Plain-language warnings for settings that need content the document does not have: a logo (a header or footer
129
+ * zone with `logo: true`, picture bullets), the organization's social profiles, or a value a zone's `text` asks for
130
+ * with a variable (`{{organization.name}}` without an organization, `{{slide.section}}` on a slide without a section).
131
+ * The renderers fall back to a glyph or report unresolved content; the editor surfaces it before export.
107
132
  */
108
133
  export function designWarnings(presentation, slideIndex = 0) {
109
134
  const slide = presentation.slides?.[slideIndex];
@@ -115,15 +140,25 @@ export function designWarnings(presentation, slideIndex = 0) {
115
140
  if (!hasLogo && isObject(design[which]) && design[which][zone]?.logo === true)
116
141
  warnings.push({ code: "unresolved-logo", path: `design.${which}.${zone}.logo`, message: `The ${which} ${zone} zone shows the logo, but no logo is set. Add a logo or an organization logo.` });
117
142
  const organization = primaryOrganization(presentation);
143
+ let builtins;
118
144
  for (const which of ["header", "footer"])
119
145
  for (const zone of HEADER_FOOTER_ZONES) {
120
146
  const item = isObject(design[which]) ? design[which][zone] : undefined;
121
- if (item?.organization === true && !organization)
122
- warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.organization`, message: `The ${which} ${zone} zone shows the organization, but the presentation has none.` });
123
- if (item?.speaker === true && !firstSpeaker(presentation)?.name)
124
- warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.speaker`, message: `The ${which} ${zone} zone shows the speaker, but the presentation has no named speaker.` });
125
147
  if (item?.socials === true && !organization?.socials)
126
148
  warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.socials`, message: `The ${which} ${zone} zone shows social profiles, but the organization has none.` });
149
+ // FA-31: a value the zone's text asks for and the document cannot give (the core validator reports the same as variable-builtin-missing).
150
+ const path = `design.${which}.${zone}.text`;
151
+ for (const name of new Set(typeof item?.text === "string" ? zoneTextVariables(item.text) : [])) {
152
+ if (name === "slide.section") {
153
+ if (!(typeof slide?.section === "string" && slide.section !== ""))
154
+ warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{slide.section}}, but this slide has no section.` });
155
+ continue;
156
+ }
157
+ builtins ??= listBuiltinVariables(presentation);
158
+ const entry = builtins.find((candidate) => candidate.name === name);
159
+ if (entry?.scope === "deck" && !entry.available)
160
+ warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{${name}}}, but the presentation has no value for it.` });
161
+ }
127
162
  }
128
163
  if (!hasLogo && design.listBullet === "image")
129
164
  warnings.push({ code: "unresolved-logo", path: "design.listBullet", message: "Picture bullets use the logo, but no logo is set, so lists draw the character bullet. Add a logo or an organization logo." });
@@ -343,8 +378,9 @@ export function readLogoVariants(presentation, options = {}) {
343
378
 
344
379
  /**
345
380
  * Compute the patch that edits one header or footer zone. `fields` merges into the zone (logo, text,
346
- * image, slideNumber, slideNumberFormat, date, dateFormat, organization, speaker, socials, section); `null`,
347
- * `false` for a flag, or an empty string removes a field. A zone left empty is removed, then an empty
381
+ * image, date, dateFormat, socials); `null`, `false` for a flag, or an empty string removes a field.
382
+ * Generated values (slide number, slide count, section, organization, speaker, deck name) are `{{ }}`
383
+ * variables in `text` (`ZONE_VALUES`, `insertZoneValue`). A zone left empty is removed, then an empty
348
384
  * header or footer, so a slide never carries `{}`. A slide's own header or footer replaces the deck's
349
385
  * whole one, so the first edit on a slide that has none of its own starts from a copy of the deck's
350
386
  * (the other zones stay); a slide emptied that way hides the furniture (`false`) instead of
@@ -356,7 +392,7 @@ export function prepareHeaderFooterZone(presentation, which, zone, fields, optio
356
392
  if (!HEADER_FOOTER_ZONES.includes(zone)) throw fail("invalid-design-value", `Zone is one of ${HEADER_FOOTER_ZONES.join(", ")}.`, { zone });
357
393
  if (!isObject(fields)) throw fail("invalid-design-value", "Pass the zone fields to change.", { fields });
358
394
  const unknown = Object.keys(fields).filter((key) => !ZONE_FIELDS.includes(key));
359
- if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}.`, { fields });
395
+ if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}.${Object.hasOwn(REMOVED_ZONE_FIELDS, unknown[0]) ? ` ${REMOVED_ZONE_FIELDS[unknown[0]]}` : ""}`, { fields });
360
396
  const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
361
397
  const own = ownDesign(presentation, base)[which];
362
398
  const inherited = base.length ? presentation.design?.[which] : undefined;
@@ -387,6 +423,29 @@ export function setHeaderFooterZone(editor, which, zone, fields, options = {}) {
387
423
  const { meta, ...rest } = options;
388
424
  return apply(editor, prepareHeaderFooterZone(editor.presentation, which, zone, fields, rest), meta);
389
425
  }
426
+ /**
427
+ * Compute the patch that inserts a value's token (`ZONE_VALUES`, for example `slide.number` for `{{slide.number}}`) into a zone's
428
+ * `text` at UTF-16 offsets `start` and `end` (a selection is replaced; default: the end of the text). The text is the one the scope
429
+ * shows (the slide's own zone, else the deck's), or `options.text` when a panel holds edits it has not committed yet.
430
+ */
431
+ export function prepareZoneValue(presentation, which, zone, name, options = {}) {
432
+ const value = ZONE_VALUES.find((entry) => entry.name === name);
433
+ if (!value) throw fail("invalid-design-value", `Insert one of ${ZONE_VALUES.map((entry) => entry.name).join(", ")}.`, { value: name });
434
+ const { start, end, text: typed, ...rest } = options;
435
+ if (typed !== undefined && typeof typed !== "string") throw fail("invalid-design-value", "Text is a string.", { text: typed });
436
+ const current = typed ?? readHeaderFooterZone(presentation, which, zone, rest).text;
437
+ const text = typeof current === "string" ? current : "";
438
+ const from = start === undefined ? text.length : start;
439
+ const to = end === undefined ? from : end;
440
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to > text.length) throw fail("invalid-selection", "The selection is outside the text.", { start: from, end: to });
441
+ return prepareHeaderFooterZone(presentation, which, zone, { text: `${text.slice(0, from)}${value.token}${text.slice(to)}` }, rest);
442
+ }
443
+ /** Insert a value's token into one zone's `text` as a single undoable transaction. */
444
+ export function insertZoneValue(editor, which, zone, name, options = {}) {
445
+ checkEditor(editor);
446
+ const { meta, ...rest } = options;
447
+ return apply(editor, prepareZoneValue(editor.presentation, which, zone, name, rest), meta);
448
+ }
390
449
  /**
391
450
  * One header or footer zone's fields as they apply at a scope: the slide's own header (or footer) when it
392
451
  * has one, else the deck's (`{}` when absent or suppressed). `headerFooterState` says which.
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}`),
@@ -21,6 +21,11 @@ const REGION_KEY = /^(?:top|middle|bottom|left|center|right)(?:[+:]|$)/;
21
21
  const WORD_BEFORE = "(?<![\\p{L}\\p{N}_])";
22
22
  const WORD_AFTER = "(?![\\p{L}\\p{N}_])";
23
23
 
24
+ // A variable token (`{{customer}}`, `{{slide.number}}`, `{{ deck.slideCount }}`, `{{revenue|0.0}}`). It is an instruction, not text: a match inside it is
25
+ // never offered, because replacing part of a name would either break the variable or silently change which value it shows (FA-31). An
26
+ // escaped `\{{` is literal text and is searched like the rest.
27
+ const VARIABLE_TOKEN = /(?<!\\)\{\{\s*[A-Za-z][A-Za-z0-9_.-]*\s*(?:\|[^{}]*)?\}\}/g;
28
+
24
29
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
25
30
  const runText = (run) => (typeof run === "string" ? run : isObject(run) && typeof run.text === "string" ? run.text : "");
26
31
  const isRuns = (value) => Array.isArray(value) && value.length > 0 && value.every((run) => typeof run === "string" || (isObject(run) && typeof run.text === "string"));
@@ -107,6 +112,7 @@ function payloadFields(fields, state, base, payload, prefix = "") {
107
112
  }));
108
113
  }
109
114
  if (isObject(payload.chart)) field(fields, state, at("chart", "alt"), payload.chart.alt, `${prefix}Chart alt text`, "alt");
115
+ if (isObject(payload.table)) field(fields, state, at("table", "alt"), payload.table.alt, `${prefix}Table alt text`, "alt");
110
116
  const table = payload.table;
111
117
  if (isObject(table)) {
112
118
  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}`)));
@@ -204,7 +210,8 @@ function snippet(text, start, end) {
204
210
  * Search a document. Returns `{ matches, fieldCount, slideCount, truncated, error }`. A match is `{ id, pointer, path,
205
211
  * slideIndex, label, runs, start, end, text, groups, named, context: { before, match, after } }` where `start` and `end`
206
212
  * are offsets into the field's text. Options: those of `compileSearch`, plus `notes` and `slideIndex` of
207
- * `collectSearchFields`. Matches that are empty (a regular expression that matches nothing) are skipped.
213
+ * `collectSearchFields`. Matches that are empty (a regular expression that matches nothing) are skipped, and so is a match that
214
+ * touches a variable token (`{{slide.number}}`): the token is not text, and replacing part of it would break or change the variable.
208
215
  */
209
216
  export function findMatches(presentation, query, options = {}) {
210
217
  const compiled = compileSearch(query, options);
@@ -216,11 +223,13 @@ export function findMatches(presentation, query, options = {}) {
216
223
  compiled.regex.lastIndex = 0;
217
224
  let found;
218
225
  let hit = false;
226
+ const tokens = item.text.includes("{{") ? [...item.text.matchAll(VARIABLE_TOKEN)].map((token) => [token.index, token.index + token[0].length]) : [];
219
227
  while ((found = compiled.regex.exec(item.text))) {
220
228
  if (found[0] === "") {
221
229
  compiled.regex.lastIndex += 1;
222
230
  continue;
223
231
  }
232
+ if (tokens.some(([from, to]) => found.index < to && found.index + found[0].length > from)) continue;
224
233
  if (result.matches.length >= MAX_MATCHES) {
225
234
  result.truncated = true;
226
235
  break outer;
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
@@ -72,6 +72,7 @@ export interface EditorHistory { undo: EditorHistoryEntry[]; redo: EditorHistory
72
72
 
73
73
  export interface EditorSession {
74
74
  paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
75
+ /** Composes the slide with `{{slide.number}}`, `{{slide.section}}` and `{{deck.slideCount}}` substituted for `options.slideNumber` and `options.slideCount` (default: its place in the open deck and the deck's slide count); the document keeps the tokens. */
75
76
  composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
76
77
  setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
77
78
  readonly presentation: unknown;
@@ -162,6 +163,7 @@ export interface CatalogSelectOptions extends CatalogOptionsInput {
162
163
  path: string;
163
164
  catalogKind: string;
164
165
  label?: string;
166
+ onError?: (error: Error) => void;
165
167
  document?: { createElement(tagName: string): any };
166
168
  }
167
169
 
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";
@@ -31,12 +32,14 @@ export {
31
32
  // theme, engine default), with the session's registered catalogs, so the editor measures and composes what the renderer draws
32
33
  // and the exporter writes. `options.fonts` is the renderer's fonts handle (its `textMeasurement` measures); `options.catalogs`
33
34
  // adds catalogs for this call; `options.onDiagnostic` hears each diagnostic (`unresolved-reference`);
34
- // any other option overrides the resolved one (`layout`, ...).
35
+ // any other option overrides the resolved one (`layout`, ...). FA-31: `slide` is the slide to compose and draw, with `{{slide.number}}`,
36
+ // `{{slide.section}}` and `{{deck.slideCount}}` substituted for `slideNumber` and `slideCount` (default: its place in the open deck and the
37
+ // deck's length), the same numbers the options carry. Pagination takes the source slide instead and substitutes while it measures.
35
38
  function slideContext(presentation, slideIndex, catalogs, { fonts, catalogs: _extra, onDiagnostic, ...overrides } = {}) {
36
39
  if (!Number.isInteger(slideIndex) || !presentation.slides?.[slideIndex]) throw new OPFEditorError("slide-index-out-of-range", "Slide index is out of range.");
37
- const { options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs });
40
+ const { slide, options, diagnostics } = resolveSlideContext(presentation, slideIndex, { fonts, catalogs, slideNumber: overrides.slideNumber, slideCount: overrides.slideCount });
38
41
  for (const diagnostic of diagnostics) onDiagnostic?.(diagnostic);
39
- return { ...options, ...overrides };
42
+ return { slide, options: { ...options, ...overrides } };
40
43
  }
41
44
 
42
45
  export const packageName = "@openpresentation/opf-editor";
@@ -275,12 +278,13 @@ export function createEditorSession(input, options = {}) {
275
278
  });
276
279
  },
277
280
  composeSlide(slideIndex, options = {}) {
278
- return composeSlide(presentation.slides?.[slideIndex], slideContext(presentation, slideIndex, mergeCatalogs(catalogs, options.catalogs), options));
281
+ const { slide, options: resolved } = slideContext(presentation, slideIndex, mergeCatalogs(catalogs, options.catalogs), options);
282
+ return composeSlide(slide, resolved);
279
283
  },
280
284
  paginateSlide(slideIndex, options = {}, meta = {}) {
281
285
  const list = mergeCatalogs(catalogs, options.catalogs);
282
286
  // Core pagination reads the measurement from `fonts`, so the resolved context hands it over that way.
283
- const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options);
287
+ const { textMeasurement, ...resolved } = slideContext(presentation, slideIndex, list, options).options;
284
288
  const pagination = paginateSlide(presentation.slides[slideIndex], {
285
289
  ...resolved,
286
290
  catalogs: list,
@@ -523,8 +527,19 @@ export function setCatalogId(editor, path, catalogKind, id, meta = {}) {
523
527
  const current = editor.get(path);
524
528
  const value = catalogReferenceValue(current, catalogId);
525
529
  const { catalogs: _catalogs, presentation: _presentation, ...commitMeta } = meta;
526
- return editor.set(path, value, {
530
+ // Only documented design references may initialize their optional design parent.
531
+ // Ordinary set/applyPatch retain RFC 6902's existing-parent requirement.
532
+ const segments = splitOpfPath(path);
533
+ const designField = { themes: "theme", colorSchemes: "colorScheme", fontSchemes: "fontScheme" }[catalogKind];
534
+ const deckDesign = segments.length === 2 && segments[0] === "design";
535
+ 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));
536
+ const parent = segments.slice(0, -1);
537
+ const patches = designField === segments.at(-1) && (deckDesign || slideDesign) && !hasValueAtPath(editor.presentation, parent)
538
+ ? [{ op: "add", path: opfPathToJsonPointer(parent), value: { [designField]: value } }]
539
+ : createValuePatch(editor.presentation, path, value);
540
+ return editor.applyPatch(patches, {
527
541
  ...commitMeta,
542
+ rejectInvalid: true,
528
543
  catalogKind,
529
544
  catalogId,
530
545
  source: commitMeta.source ?? "catalog-control"
@@ -589,13 +604,13 @@ export function createCatalogSelect(editor, options) {
589
604
  const current = editor.get(path);
590
605
  select.value = typeof current === "object" && current ? current.id : current ?? "";
591
606
  };
592
- const onChange = () => {
593
- setCatalogId(editor, path, catalogKind, select.value, {
607
+ const onChange = () => commitCatalogControl(select, options?.onError, updateValue, () => {
608
+ return setCatalogId(editor, path, catalogKind, select.value, {
594
609
  catalogs: options?.catalogs,
595
610
  presentation: options?.presentation,
596
611
  source: "catalog-select"
597
612
  });
598
- };
613
+ });
599
614
  const unsubscribe = editor.subscribe(updateValue);
600
615
 
601
616
  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). */