@openpresentation/opf-editor 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +93 -35
  2. package/dist/assets.js +1 -1
  3. package/dist/background-options.d.ts +10 -3
  4. package/dist/background-options.js +38 -13
  5. package/dist/canvas.d.ts +8 -1
  6. package/dist/canvas.js +25 -10
  7. package/dist/catalog-control.js +21 -0
  8. package/dist/catalogs.d.ts +48 -0
  9. package/dist/catalogs.js +217 -0
  10. package/dist/checks.js +9 -7
  11. package/dist/content-actions.d.ts +2 -2
  12. package/dist/content-actions.js +3 -3
  13. package/dist/content-controls.js +9 -9
  14. package/dist/design-controls.d.ts +12 -5
  15. package/dist/design-controls.js +286 -82
  16. package/dist/design-options.d.ts +4 -4
  17. package/dist/design-options.js +6 -26
  18. package/dist/export.d.ts +8 -1
  19. package/dist/export.js +14 -4
  20. package/dist/find-replace.js +1 -0
  21. package/dist/font-gate.js +0 -11
  22. package/dist/galleries.js +32 -67
  23. package/dist/image-crop.js +8 -9
  24. package/dist/image-options.d.ts +45 -0
  25. package/dist/image-options.js +195 -0
  26. package/dist/index.d.ts +26 -8
  27. package/dist/index.js +107 -51
  28. package/dist/json-editor.js +2 -2
  29. package/dist/json-menu.js +2 -2
  30. package/dist/json-options.d.ts +3 -3
  31. package/dist/json-options.js +37 -47
  32. package/dist/layout-handles.js +13 -0
  33. package/dist/layout-placeholders.js +15 -0
  34. package/dist/react.d.ts +1 -0
  35. package/dist/react.js +6 -2
  36. package/dist/review-panel.js +8 -2
  37. package/dist/schema-inspector.js +1 -1
  38. package/dist/schema.js +1 -1
  39. package/dist/slide-manager.js +4 -2
  40. package/dist/slides.d.ts +1 -1
  41. package/dist/slides.js +6 -5
  42. package/dist/svelte.d.ts +1 -0
  43. package/dist/svelte.js +4 -4
  44. package/dist/switches.d.ts +31 -16
  45. package/dist/switches.js +194 -92
  46. package/dist/table-options.d.ts +6 -1
  47. package/dist/table-options.js +43 -0
  48. package/dist/transfer.d.ts +12 -1
  49. package/dist/transfer.js +87 -131
  50. package/package.json +22 -9
package/dist/switches.js CHANGED
@@ -2,14 +2,22 @@
2
2
  // dimension that turns "switch this dimension to X" into a validated JSON Patch, and applies
3
3
  // it to an editor session as one undoable transaction. The preview recomposes and the PPTX
4
4
  // export follows from the same document, so no dimension needs a special refresh path.
5
- import { catalogSchemaNames, catalogs as bundledCatalogs, isXYChartType, resolveChartData, schemas } from "@openpresentation/opf";
5
+ //
6
+ // OPF 0.15 (FA-23): the catalog-backed dimensions (layouts, themes, colour and font schemes, narratives, audiences, purposes,
7
+ // tones) resolve through core with the host's registered catalogs (`options.catalogs`, `Catalog[]`) and write the reference core
8
+ // gives (`id` or `name:id`). Languages, chart types and social platforms are engine vocabularies, checked against core's
9
+ // tables; their labels come from the host's display metadata (`options.vocabularies`, the shape of `catalogDisplay` from
10
+ // `@openpresentation/opf/catalog`), never from data the editor ships.
11
+ import { CHART_TYPES, LANGUAGES, SOCIAL_PLATFORMS, isXYChartType, parseReference, resolveChartData, resolveReference } from "@openpresentation/opf";
6
12
  import { chartOptionTarget } from "@openpresentation/opf/composition";
7
- import { OPFEditorError, createValuePatch, applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
13
+ import { createValuePatch, applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
8
14
  import { createContentBlock, prepareBlockReplace } from "./blocks.js";
9
15
  import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
10
- import { populateLayoutPlaceholders } from "./layout-placeholders.js";
16
+ import { layoutSlotTypes, populateLayoutPlaceholders } from "./layout-placeholders.js";
11
17
  import { blockConversionTargets, prepareBlockConversion } from "./block-convert.js";
12
18
  import { checkFormat } from "./checks.js";
19
+ import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
20
+ import { IMAGE_TREATMENT_FIELDS, imageTreatmentPatches, readImageTreatments } from "./image-options.js";
13
21
 
14
22
  /** The schema's DimensionPreset values (RR-41, FA-13: with the social-feed ratios), the values of the slide-sizes switch. */
15
23
  export const SLIDE_SIZE_PRESETS = Object.freeze(["16:9", "4:3", "16:10", "1:1", "4:5", "9:16", "letter", "a4", "widescreen", "standard"]);
@@ -52,20 +60,19 @@ export const SWITCH_DIMENSIONS = Object.freeze([
52
60
  "purposes",
53
61
  ]);
54
62
 
55
- // Catalog kind behind each catalog-backed dimension.
63
+ // Catalog kind behind each catalog-backed dimension (core's content kinds).
56
64
  const CATALOG_KIND = Object.freeze({
57
65
  layouts: "layouts",
58
66
  "color-schemes": "colorSchemes",
59
67
  "font-schemes": "fontSchemes",
60
- languages: "languages",
61
68
  narratives: "narratives",
62
69
  themes: "themes",
63
70
  audiences: "audiences",
64
71
  tones: "tones",
65
- socials: "socialPlatforms",
66
- charts: "chartTypes",
67
72
  purposes: "purposes",
68
73
  });
74
+ // Display metadata key (`options.vocabularies`) behind each engine-vocabulary dimension.
75
+ const VOCABULARY = Object.freeze({ languages: "languages", charts: "chartTypes", socials: "socialPlatforms" });
69
76
  // Top-level document field for the simple metadata dimensions.
70
77
  const ROOT_FIELD = Object.freeze({ languages: "language", narratives: "narrative", tones: "tone", audiences: "audience" });
71
78
  // `design` keys for the design dimensions. A key set to `null` in the value is removed.
@@ -74,49 +81,80 @@ const DESIGN_KEYS = Object.freeze({
74
81
  "font-schemes": ["fontScheme"],
75
82
  backgrounds: ["background"],
76
83
  "headers-footers": ["header", "footer"],
77
- "image-treatments": ["slideImage", "imageFill"],
78
84
  });
79
85
  const REGION = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
80
86
  const BLOCK_KINDS = ["text", "list", "chart", "table", "metric", "quote", "code", "timeline", "group", "image", "video"];
87
+ const RESERVED_GROUPS = new Set(["default", "custom"]);
88
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
81
89
 
82
- function recordList(source) {
83
- const records = Array.isArray(source) ? source : source?.records;
84
- return Array.isArray(records) ? records.filter((record) => record && typeof record.id === "string") : [];
90
+ const catalogsOf = (options) => mergeCatalogs(options.catalogs);
91
+
92
+ // A reference resolved the one way every engine resolves it (core's `resolveReference`, with the registered catalogs).
93
+ function resolveRecord(presentation, kind, reference, options) {
94
+ if (typeof reference !== "string" || !reference) return undefined;
95
+ return resolveReference(presentation, kind, reference, { catalogs: catalogsOf(options) });
85
96
  }
86
97
 
87
- // Inline document records first (they override), then caller-loaded records, then the
88
- // bundled catalog. Unlike getCatalogRecords, an inline catalog never hides bundled ids.
89
- function findCatalogRecord(presentation, kind, id, options) {
90
- return (
91
- recordList(presentation.catalogs?.[kind]).find((record) => record.id === id) ??
92
- recordList(options.catalogs?.[kind] ?? options.catalogSources?.[kind]).find((record) => record.id === id) ??
93
- (options.record?.id === id ? options.record : undefined) ??
94
- bundledCatalogs[kind]?.find((record) => record.id === id)
95
- );
98
+ /**
99
+ * A record the caller supplies with the switch (a gallery item carries its own): it is embedded in the same transaction, in
100
+ * the group whose `source` is `options.recordSource` (`default` when the document has no default group yet), else under `custom`,
101
+ * with no `$schema`, `id` or `x-*` display fields. Returns the patches and the reference to write.
102
+ */
103
+ function suppliedRecord(presentation, kind, options) {
104
+ const record = options.record;
105
+ if (!record) return undefined;
106
+ const id = record.id;
107
+ const groups = isObject(presentation.catalogs) ? presentation.catalogs : undefined;
108
+ let group = "custom";
109
+ if (typeof options.recordSource === "string" && options.recordSource) {
110
+ const named = groups && Object.entries(groups).find(([, value]) => isObject(value) && value.source === options.recordSource);
111
+ if (named) group = named[0];
112
+ else if (!groups || groups.default === undefined) group = "default";
113
+ }
114
+ const reference = RESERVED_GROUPS.has(group) ? id : `${group}:${id}`;
115
+ const { $schema: _schema, id: _id, ...fields } = record;
116
+ const value = Object.fromEntries(Object.entries(structuredClone(fields)).filter(([key]) => !key.startsWith("x-")));
117
+ if (isObject(groups?.[group]?.[kind]) && Object.hasOwn(groups[group][kind], id)) return { patches: [], reference, record: groups[group][kind][id], group };
118
+ const path = ["catalogs", group, kind, id];
119
+ let patches;
120
+ if (!groups) patches = [{ op: "add", path: "/catalogs", value: { [group]: { ...(group === "default" ? { source: options.recordSource } : {}), [kind]: { [id]: value } } } }];
121
+ else if (!isObject(groups[group])) patches = [{ op: "add", path: opfPathToJsonPointer(path.slice(0, 2)), value: { ...(group === "default" ? { source: options.recordSource } : {}), [kind]: { [id]: value } } }];
122
+ else if (!isObject(groups[group][kind])) patches = [{ op: "add", path: opfPathToJsonPointer(path.slice(0, 3)), value: { [id]: value } }];
123
+ else patches = [{ op: "add", path: opfPathToJsonPointer(path), value }];
124
+ return { patches, reference, record: value, group };
96
125
  }
97
126
 
98
- function requireCatalogId(presentation, dimension, id, options) {
127
+ // The record a switched reference names: a supplied record, else core's resolution. Unknown references are refused.
128
+ function requireRecord(presentation, dimension, reference, options) {
99
129
  const kind = CATALOG_KIND[dimension];
100
- if (typeof id !== "string" || id.length === 0)
101
- throw fail("invalid-catalog-id", `Switch ${dimension} to a non-empty catalog id.`, { dimension, id });
102
- const record = findCatalogRecord(presentation, kind, id, options);
103
- if (!record) throw fail("unknown-catalog-id", `Unknown ${kind} catalog ID: ${id}.`, { dimension, catalogKind: kind, id });
104
- return record;
130
+ if (typeof reference !== "string" || reference.length === 0)
131
+ throw fail("invalid-catalog-id", `Switch ${dimension} to a non-empty catalog reference.`, { dimension, id: reference });
132
+ const resolved = resolveRecord(presentation, kind, reference, options);
133
+ if (!resolved) throw fail("unknown-catalog-id", `Unknown ${kind} catalog reference: ${reference}. Register the catalog that holds it, or add the record to the document.`, { dimension, catalogKind: kind, id: reference });
134
+ return resolved;
105
135
  }
106
136
 
107
- // A gallery item supplies its own record; add it inline in the same transaction when the
108
- // document and the bundled catalog do not already define the id.
109
- function catalogRecordPatches(presentation, dimension, options) {
110
- const record = options.record;
111
- if (!record) return [];
112
- const kind = CATALOG_KIND[dimension];
113
- if (recordList(presentation.catalogs?.[kind]).some((entry) => entry.id === record.id) || bundledCatalogs[kind]?.some((entry) => entry.id === record.id)) return [];
114
- const value = { $schema: schemas[catalogSchemaNames[kind]].$id, ...record };
115
- if (!presentation.catalogs || typeof presentation.catalogs !== "object") return [{ op: "add", path: "/catalogs", value: { [kind]: { records: [value] } } }];
116
- if (!presentation.catalogs[kind] || typeof presentation.catalogs[kind] !== "object")
117
- return [{ op: "add", path: `/catalogs/${kind}`, value: { records: [value] } }];
118
- if (!Array.isArray(presentation.catalogs[kind].records)) return [{ op: "add", path: `/catalogs/${kind}/records`, value: [value] }];
119
- return [{ op: "add", path: `/catalogs/${kind}/records/-`, value }];
137
+ // A switch to a supplied record writes the reference it was embedded under; otherwise the reference must resolve.
138
+ function catalogChoice(presentation, dimension, reference, options) {
139
+ const supplied = options.record && parseReference(reference)?.id === options.record.id ? suppliedRecord(presentation, CATALOG_KIND[dimension], options) : undefined;
140
+ if (supplied) return { patches: supplied.patches, reference: supplied.reference, record: supplied.record, group: supplied.group };
141
+ const resolved = requireRecord(presentation, dimension, reference, options);
142
+ return { patches: [], reference, record: resolved.record, group: resolved.group };
143
+ }
144
+
145
+ // A reference copied out of a record of group `group` into the document's design keeps naming that group's record.
146
+ function qualify(reference, group) {
147
+ if (typeof reference !== "string") return reference;
148
+ const parsed = parseReference(reference);
149
+ if (!parsed || parsed.group || RESERVED_GROUPS.has(group)) return reference;
150
+ return `${group}:${parsed.id}`;
151
+ }
152
+ function qualifiedValue(key, value, group) {
153
+ if (key === "colorScheme" || key === "fontScheme") {
154
+ if (typeof value === "string") return qualify(value, group);
155
+ if (isObject(value) && typeof value.id === "string") return { ...structuredClone(value), id: qualify(value.id, group) };
156
+ }
157
+ return structuredClone(value);
120
158
  }
121
159
 
122
160
  function slideAt(presentation, slideIndex, what = "this switch") {
@@ -158,7 +196,8 @@ function socialsPatches(presentation, value, options) {
158
196
  if (!["speaker", "organization"].includes(owner)) throw fail("invalid-switch-value", "Socials owner must be 'speaker' or 'organization'.", { owner });
159
197
  if (!value || typeof value !== "object" || typeof value.platform !== "string" || typeof value.handle !== "string" || !value.handle)
160
198
  throw fail("invalid-switch-value", "Switch socials to { platform, handle } with a non-empty handle.", { value });
161
- requireCatalogId(presentation, "socials", value.platform, options);
199
+ if (!Object.hasOwn(SOCIAL_PLATFORMS, value.platform))
200
+ throw fail("unknown-catalog-id", `Unknown social platform: ${value.platform}. Use one of ${Object.keys(SOCIAL_PLATFORMS).join(", ")}.`, { dimension: "socials", id: value.platform });
162
201
  const host = presentation[owner];
163
202
  const index = Array.isArray(host) ? (options.index ?? 0) : undefined;
164
203
  const target = Array.isArray(host) ? host[index] : host;
@@ -166,12 +205,17 @@ function socialsPatches(presentation, value, options) {
166
205
  throw fail("missing-owner", `Add the ${owner} to the document before setting their socials.`, { owner });
167
206
  const base = Array.isArray(host) ? [owner, String(index)] : [owner];
168
207
  const socials = target.socials;
169
- const patches = catalogRecordPatches(presentation, "socials", options);
208
+ const patches = [];
170
209
  if (!socials || typeof socials !== "object") return [...patches, { op: "add", path: opfPathToJsonPointer([...base, "socials"]), value: { [value.platform]: value.handle } }];
171
210
  if (socials[value.platform] === value.handle) return patches;
172
211
  return [...patches, { op: Object.hasOwn(socials, value.platform) ? "replace" : "add", path: opfPathToJsonPointer([...base, "socials", value.platform]), value: value.handle }];
173
212
  }
174
213
 
214
+ // Purposes take free goal text, so only a supplied record is embedded; any other string is written as it is.
215
+ function catalogChoiceIfSupplied(presentation, dimension, value, options) {
216
+ return options.record ? catalogChoice(presentation, dimension, value, options) : undefined;
217
+ }
218
+
175
219
  function blockValue(value, options) {
176
220
  if (typeof value === "string") {
177
221
  if (!BLOCK_KINDS.includes(value)) throw fail("invalid-switch-value", `Unknown content block kind: ${value}.`, { value });
@@ -190,9 +234,9 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
190
234
  throw fail("unknown-dimension", `Unknown dimension: ${dimension}. Use one of ${SWITCH_DIMENSIONS.join(", ")}.`, { dimension });
191
235
  if (!presentation || typeof presentation !== "object") throw fail("invalid-input", "Switch requires an OPF document object.");
192
236
  if (options.record) {
193
- const ids = dimension === "audiences" ? [value].flat() : dimension === "socials" ? [value?.platform] : [value];
194
- if (!CATALOG_KIND[dimension] || !ids.includes(options.record.id))
195
- throw fail("record-id-mismatch", `The supplied record id must equal a switched ${dimension} id.`, { dimension, id: options.record.id });
237
+ const ids = (dimension === "audiences" ? [value].flat() : [value]).map((entry) => (typeof entry === "string" ? parseReference(entry)?.id : undefined));
238
+ if (!CATALOG_KIND[dimension] || typeof options.record?.id !== "string" || !ids.includes(options.record.id))
239
+ throw fail("record-id-mismatch", `The supplied record id must equal a switched ${dimension} id.`, { dimension, id: options.record?.id });
196
240
  }
197
241
  const before = checkFormat(presentation);
198
242
  const scopeIndex = options.slideIndex;
@@ -212,22 +256,25 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
212
256
  // A slide's design cannot set dimensions (FA-07), so no slide can shadow the deck's size.
213
257
  patches = sameSize ? [] : designPatches(presentation, [], { dimensions: value });
214
258
  } else if (dimension === "purposes") {
215
- // A catalog id, free-form goal text (no id check: any goal is valid) or an inline Purpose object; the schema validates it.
259
+ // A catalog reference, free-form goal text (no reference check: any goal is valid) or an inline Purpose object; the schema validates it.
216
260
  if (typeof value !== "string" && !(value && typeof value === "object" && !Array.isArray(value)))
217
- throw fail("invalid-switch-value", "Switch purposes to a catalog id, a goal string or a purpose object.", { value });
261
+ throw fail("invalid-switch-value", "Switch purposes to a catalog reference, a goal string or a purpose object.", { value });
218
262
  if (typeof value === "string" && value.length === 0) throw fail("invalid-switch-value", "Switch purposes to a non-empty goal.", { value });
219
- patches = [...catalogRecordPatches(presentation, dimension, options), ...rootPatch(presentation, "purpose", value)];
263
+ const supplied = typeof value === "string" ? catalogChoiceIfSupplied(presentation, dimension, value, options) : undefined;
264
+ patches = [...(supplied?.patches ?? []), ...rootPatch(presentation, "purpose", supplied?.reference ?? value)];
220
265
  } else if (dimension === "layouts") {
221
266
  slideIndex = scopeIndex;
222
267
  slideAt(presentation, slideIndex, "a layout switch");
223
268
  scope = "slide";
224
- const record = requireCatalogId(presentation, dimension, value, options);
225
- patches = catalogRecordPatches(presentation, dimension, options);
269
+ const choice = catalogChoice(presentation, dimension, value, options);
270
+ const record = choice.record;
271
+ patches = choice.patches;
226
272
  const withRecord = patches.length ? applyJsonPatch(presentation, patches) : presentation;
227
- if (withRecord.slides[slideIndex].layout !== value) {
228
- const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], value);
273
+ if (withRecord.slides[slideIndex].layout !== choice.reference) {
274
+ const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], choice.reference);
229
275
  const swapped = applyJsonPatch(withRecord, layoutOps);
230
- 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);
231
278
  // Add the blank payloads the layout declares, as the JSON editor's layout choice does.
232
279
  const populated = types ? populateLayoutPlaceholders(swapped, slideIndex, types) : swapped;
233
280
  patches = same(populated.slides[slideIndex], swapped.slides[slideIndex])
@@ -238,13 +285,13 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
238
285
  slideIndex = scopeIndex;
239
286
  slideAt(presentation, slideIndex, "a chart switch");
240
287
  scope = "slide";
241
- requireCatalogId(presentation, dimension, value, options);
288
+ if (typeof value !== "string" || !CHART_TYPES.includes(value)) throw fail("unknown-catalog-id", `Unknown chart type: ${value}. Use one of ${CHART_TYPES.join(", ")}.`, { dimension, id: value });
242
289
  const owner = options.path ? splitOpfPath(options.path) : findChartOwner(presentation, slideIndex);
243
290
  if (options.path && (owner[0] !== "slides" || owner[1] !== String(slideIndex)))
244
291
  throw fail("path-slide-mismatch", "options.path is not on the slide named by options.slideIndex.", { slideIndex, path: options.path });
245
292
  const chart = owner && getValueAtPath(presentation, [...owner, "chart"]);
246
293
  if (!chart || typeof chart !== "object") throw fail("chart-not-found", "This slide has no chart to switch. Insert a chart block first.", { slideIndex, path: options.path });
247
- patches = [...catalogRecordPatches(presentation, dimension, options), ...(chart.type === value ? [] : [...createValuePatch(presentation, [...owner, "chart", "type"], value), ...staleMappingPatches(presentation, owner, chart, value), ...staleComboPatches(presentation, owner, chart, value)])];
294
+ patches = chart.type === value ? [] : [...createValuePatch(presentation, [...owner, "chart", "type"], value), ...staleMappingPatches(presentation, owner, chart, value), ...staleComboPatches(presentation, owner, chart, value)];
248
295
  } else if (dimension === "blocks") {
249
296
  if (options.path === undefined) throw fail("missing-path", "Choose the block to replace with options.path.");
250
297
  // convert: true moves the block's own text into the new kind (block-convert.js) instead of replacing it.
@@ -263,30 +310,58 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
263
310
  scope = "block";
264
311
  } else if (dimension === "socials") {
265
312
  patches = socialsPatches(presentation, value, options);
313
+ } else if (dimension === "image-treatments") {
314
+ // The treatments of one image block (FA-22): fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio, placement.
315
+ if (options.path === undefined) throw fail("missing-path", "Choose the image block to treat with options.path.");
316
+ patches = imageTreatmentPatches(presentation, options.path, value);
317
+ const parts = splitOpfPath(options.path);
318
+ if (parts[0] === "slides") slideIndex = Number(parts[1]);
319
+ scope = "block";
320
+ } else if (dimension === "languages") {
321
+ // A BCP-47 tag (en-US) or a language object ({ bcp47, ... }); the schema checks the tag. Language catalog ids are gone.
322
+ if (!(typeof value === "string" && value) && !(isObject(value) && typeof value.bcp47 === "string"))
323
+ throw fail("invalid-switch-value", "Switch languages to a BCP-47 tag such as en-US, or a language object with bcp47.", { value });
324
+ patches = rootPatch(presentation, "language", value);
266
325
  } else if (ROOT_FIELD[dimension]) {
267
326
  const field = ROOT_FIELD[dimension];
268
- let next = value;
327
+ let next;
269
328
  if (dimension === "audiences") {
270
- next = Array.isArray(value) ? value : [value];
271
- if (!next.length) throw fail("invalid-catalog-id", "Switch audiences to at least one catalog id.", { value });
272
- for (const id of next) requireCatalogId(presentation, dimension, id, options);
273
- } else requireCatalogId(presentation, dimension, value, options);
274
- patches = [...catalogRecordPatches(presentation, dimension, options), ...rootPatch(presentation, field, next)];
329
+ const list = Array.isArray(value) ? value : [value];
330
+ if (!list.length) throw fail("invalid-catalog-id", "Switch audiences to at least one catalog reference.", { value });
331
+ next = [];
332
+ for (const reference of list) {
333
+ const choice = catalogChoice(applyJsonPatch(presentation, patches), dimension, reference, options);
334
+ patches = [...patches, ...choice.patches];
335
+ next.push(choice.reference);
336
+ }
337
+ } else {
338
+ const choice = catalogChoice(presentation, dimension, value, options);
339
+ patches = choice.patches;
340
+ next = choice.reference;
341
+ }
342
+ patches = [...patches, ...rootPatch(presentation, field, next)];
275
343
  } else {
276
344
  // Design dimensions: deck scope by default, or one slide with options.slideIndex.
277
345
  let entries;
278
346
  if (dimension === "themes") {
279
- const record = requireCatalogId(presentation, dimension, value, options);
280
- entries = { theme: value };
347
+ const choice = catalogChoice(presentation, dimension, value, options);
348
+ const record = choice.record ?? {};
349
+ entries = { theme: choice.reference };
281
350
  if (options.bundle !== false) {
282
- // The gallery's theme snippet writes the whole bundle, so an explicit deck choice
283
- // does not keep the previous theme's fonts, colors or background.
284
- // A slide's design cannot set dimensions (a PPTX has one slide size), so only a deck-scope switch writes the theme's.
285
- for (const key of ["colorScheme", "fontScheme", "background", ...(scopeIndex === undefined ? ["dimensions"] : [])]) if (record[key] !== undefined) entries[key] = record[key];
351
+ // An explicit theme choice brings the theme's own colour scheme, font scheme and background (and, for the deck, its
352
+ // slide size): an override of those at the same scope is removed so the theme's applies. Nothing is copied out of the
353
+ // record, except on a slide whose deck sets the key: there the theme's value is written on the slide (its references
354
+ // qualified with the theme's catalog group), because the deck's design would otherwise win over the slide's theme.
355
+ // A slide's design cannot set dimensions (a PPTX has one slide size), so only a deck-scope switch touches them.
356
+ for (const key of ["colorScheme", "fontScheme", "background", ...(scopeIndex === undefined ? ["dimensions"] : [])]) {
357
+ if (record[key] === undefined) continue;
358
+ entries[key] = scopeIndex !== undefined && presentation.design?.[key] !== undefined ? qualifiedValue(key, record[key], choice.group) : null;
359
+ }
286
360
  }
287
- patches = catalogRecordPatches(presentation, dimension, options);
361
+ patches = choice.patches;
288
362
  } else if (dimension === "color-schemes" || dimension === "font-schemes") {
289
- requireCatalogId(presentation, dimension, value, options);
363
+ const choice = catalogChoice(presentation, dimension, value, options);
364
+ value = choice.reference;
290
365
  entries = { [DESIGN_KEYS[dimension][0]]: value };
291
366
  if (dimension === "font-schemes") {
292
367
  // An accent font is its own choice, not part of the scheme being left: it stays (in the object form).
@@ -294,7 +369,7 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
294
369
  const own = getValueAtPath(presentation, [...scopeBase, "design", "fontScheme"]);
295
370
  if (own && typeof own === "object" && own.accent !== undefined) entries.fontScheme = { id: value, accent: structuredClone(own.accent) };
296
371
  }
297
- patches = catalogRecordPatches(presentation, dimension, options);
372
+ patches = choice.patches;
298
373
  } else {
299
374
  // A background is an object or a shorthand string (theme slot or hex color); the schema
300
375
  // validates the candidate document, so a string that is neither is rejected below.
@@ -342,7 +417,8 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
342
417
  }
343
418
 
344
419
  /**
345
- * Switch one dimension of the session's document as a single undoable transaction. Returns the
420
+ * Switch one dimension of the session's document as a single undoable transaction, resolving references with the session's
421
+ * registered catalogs (and `options.catalogs`). Returns the
346
422
  * editor change plus `dimension`, `scope`, `changed` and `shadowed` (slides whose own design
347
423
  * hides a deck-level switch). Switching to the current value commits nothing.
348
424
  */
@@ -350,7 +426,8 @@ export function switchDimension(editor, dimension, value, options = {}) {
350
426
  if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
351
427
  throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
352
428
  const { meta, ...switchOptions } = options;
353
- const prepared = prepareDimensionSwitch(editor.presentation, dimension, value, switchOptions);
429
+ // The session's registered catalogs first, then any the caller adds for this switch: the one list core resolves with.
430
+ const prepared = prepareDimensionSwitch(editor.presentation, dimension, value, { ...switchOptions, catalogs: mergeCatalogs(editor.catalogs, options.catalogs) });
354
431
  const { presentation, patches, ...summary } = prepared;
355
432
  void presentation;
356
433
  if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
@@ -360,28 +437,46 @@ export function switchDimension(editor, dimension, value, options = {}) {
360
437
 
361
438
  // --- options for pickers and current values ---------------------------------------------------
362
439
 
363
- const labelOf = (record) => record.name ?? record.id;
440
+ // Host display metadata for an engine vocabulary (`options.vocabularies[key]`, the shape of `catalogDisplay`): a list of records
441
+ // with `id` (or `bcp47` for a language), or an object keyed by id. Returns a Map id -> record.
442
+ function displayRecords(options, key) {
443
+ const source = options.vocabularies?.[key];
444
+ const out = new Map();
445
+ if (Array.isArray(source)) {
446
+ for (const record of source) if (isObject(record) && typeof (record.id ?? record.bcp47) === "string") out.set(record.id ?? record.bcp47, record);
447
+ } else if (isObject(source)) {
448
+ for (const [id, record] of Object.entries(source)) out.set(isObject(record) && typeof record.bcp47 === "string" ? record.bcp47 : id, isObject(record) ? record : {});
449
+ }
450
+ return out;
451
+ }
452
+ const displayLabel = (record, id) => (typeof record?.name === "string" && record.name ? record.name : typeof record?.label === "string" && record.label ? record.label : id);
364
453
 
365
454
  /**
366
- * The values a picker can offer for a catalog-backed dimension, in document order: the document's
367
- * inline records first (they override), then caller-loaded records, then the bundled catalog,
368
- * without duplicates. `blocks` lists the content kinds. `charts` lists every chart type; use
369
- * `compatibleChartTypes` to narrow it to the types the chart's data can use.
455
+ * The values a picker can offer for a dimension. A catalog-backed dimension lists core's `catalogRecords` (the document's embedded
456
+ * records first, then `options.catalogs`'), each `{ id, label, record, reference, group, source, origin }` with `id` the reference
457
+ * to write. `charts` lists core's `CHART_TYPES` and `socials` core's `SOCIAL_PLATFORMS`, labelled from `options.vocabularies`;
458
+ * `languages` lists core's `LANGUAGES` tags. `blocks` lists the content
459
+ * kinds. Use `compatibleChartTypes` to narrow `charts` to the types the chart's data can use.
370
460
  */
371
461
  export function listSwitchOptions(presentation, dimension, options = {}) {
372
462
  if (dimension === "blocks") return BLOCK_KINDS.map((kind) => ({ id: kind, label: kind[0].toUpperCase() + kind.slice(1) }));
373
463
  if (dimension === "slide-sizes") return SLIDE_SIZE_PRESETS.map((preset) => ({ id: preset, label: SLIDE_SIZE_LABELS[preset] }));
464
+ if (dimension === "charts") {
465
+ const display = displayRecords(options, VOCABULARY.charts);
466
+ return CHART_TYPES.map((id) => ({ id, label: displayLabel(display.get(id), id), record: display.get(id) ?? {} }));
467
+ }
468
+ if (dimension === "socials") {
469
+ const display = displayRecords(options, VOCABULARY.socials);
470
+ return Object.keys(SOCIAL_PLATFORMS).map((id) => ({ id, label: displayLabel(display.get(id), id), record: { ...SOCIAL_PLATFORMS[id], ...display.get(id) } }));
471
+ }
472
+ if (dimension === "languages") {
473
+ // Core's language table (the tags engines know the script, direction and default fonts of), labelled from the host's display metadata.
474
+ const display = displayRecords(options, VOCABULARY.languages);
475
+ return LANGUAGES.map((language) => ({ id: language.tag, label: displayLabel(display.get(language.tag), language.tag), record: { ...language, ...display.get(language.tag) } }));
476
+ }
374
477
  const kind = CATALOG_KIND[dimension];
375
478
  if (!kind) return [];
376
- const seen = new Set();
377
- const out = [];
378
- for (const source of [presentation?.catalogs?.[kind], options.catalogs?.[kind] ?? options.catalogSources?.[kind], bundledCatalogs[kind]])
379
- for (const record of recordList(source)) {
380
- if (seen.has(record.id)) continue;
381
- seen.add(record.id);
382
- out.push({ id: record.id, label: labelOf(record), record });
383
- }
384
- return out;
479
+ return listCatalogRecords(presentation ?? {}, kind, { catalogs: catalogsOf(options) });
385
480
  }
386
481
 
387
482
  const SINGLE_SERIES_ONLY = new Set(["pieChart", "doughnutChart", "funnelChart", "treemapChart", "waterfallChart"]);
@@ -423,7 +518,7 @@ function chartDataShape(chart, presentation) {
423
518
  }
424
519
 
425
520
  /**
426
- * Chart types the chart's inline data can use as it is, from the chartTypes catalog: simple,
521
+ * Chart types the chart's inline data can use as it is, from core's chart types and the host's chart display metadata: simple,
427
522
  * non-geographic, non-distribution types whose series count fits the data (the first column labels
428
523
  * the categories and each further column is a series; a type with N series needs exactly N value
429
524
  * columns, except that a stacked or percent-stacked type and a combination such as combo take N or more;
@@ -443,7 +538,8 @@ export function compatibleChartTypes(presentation, options = {}) {
443
538
  const record = option.record;
444
539
  const element = record.mappings?.openxml?.element;
445
540
  const current = option.id === chart.type;
446
- const simple = record.complexity === "simple" && !DISTRIBUTION_ELEMENTS.has(element);
541
+ // Without the host's display metadata for a type (complexity, series, mappings) it is offered: compatibility is unknown, not refused.
542
+ const simple = record.complexity === undefined ? !DISTRIBUTION_ELEMENTS.has(element) : record.complexity === "simple" && !DISTRIBUTION_ELEMENTS.has(element);
447
543
  // A combination (composition "mixed", the FA-15 combo chart) needs at least its series count, like a stacked type.
448
544
  const atLeast = STACKED_GROUPINGS.has(record.mappings?.openxml?.grouping) || record.mappings?.openxml?.composition === "mixed";
449
545
  const seriesOk =
@@ -462,8 +558,8 @@ export function compatibleChartTypes(presentation, options = {}) {
462
558
  /**
463
559
  * The value a dimension currently has, for pickers: `{ value, scope }` where scope is "slide" when
464
560
  * `slideIndex` names a slide whose own design sets it, else "deck". `value` is undefined when the
465
- * dimension is unset. Catalog dimensions return the catalog id even when the document holds an
466
- * inline object.
561
+ * dimension is unset. Catalog dimensions return the reference (`id` or `name:id`) even when the document holds an
562
+ * object form with overrides. `options.catalogs` resolves the theme's slide size.
467
563
  */
468
564
  export function currentSwitchValue(presentation, dimension, options = {}) {
469
565
  const idOf = (reference) => (reference && typeof reference === "object" && !Array.isArray(reference) ? reference.id : reference);
@@ -478,18 +574,24 @@ export function currentSwitchValue(presentation, dimension, options = {}) {
478
574
  return { value: owner ? getValueAtPath(presentation, [...owner, "chart", "type"]) : undefined, scope: "slide" };
479
575
  }
480
576
  if (dimension === "blocks") return { value: undefined, scope: "block" };
577
+ if (dimension === "image-treatments") {
578
+ // The treatments of the image block at `options.path` (FA-22); without a path there is nothing to read.
579
+ if (options.path === undefined) return { value: undefined, scope: "block" };
580
+ const state = readImageTreatments(presentation, options.path);
581
+ return { value: Object.fromEntries(IMAGE_TREATMENT_FIELDS.map((key) => [key, state[key]])), scope: "block" };
582
+ }
481
583
  if (dimension === "slide-sizes") {
482
584
  // The size the deck composes at: its own design.dimensions, else the theme's; a {preset} object reads as its preset.
483
585
  // A custom size (inches without a preset) reads as the object itself. Unset reads as undefined (composed as widescreen).
484
586
  let size = presentation.design?.dimensions;
485
587
  if (size === undefined) {
486
- const themeId = idOf(presentation.design?.theme);
487
- size = themeId ? findCatalogRecord(presentation, "themes", themeId, {})?.dimensions : undefined;
588
+ const theme = presentation.design?.theme;
589
+ size = typeof theme === "string" ? resolveRecord(presentation, "themes", theme, options)?.record?.dimensions : undefined;
488
590
  }
489
591
  return { value: size && typeof size === "object" && !Array.isArray(size) && Object.keys(size).length === 1 && size.preset ? size.preset : size, scope: "deck" };
490
592
  }
491
593
  if (dimension === "purposes") {
492
- // A catalog id or goal text reads as itself, a Purpose object as its id (else the object).
594
+ // A catalog reference or goal text reads as itself, a Purpose object as its id (else the object).
493
595
  const purpose = presentation.purpose;
494
596
  return { value: purpose && typeof purpose === "object" && !Array.isArray(purpose) ? (purpose.id ?? purpose) : purpose, scope: "deck" };
495
597
  }
@@ -37,7 +37,7 @@ export interface TableCellState extends TableCellAddress {
37
37
  rows: number;
38
38
  }
39
39
  export interface PreparedTableChange {
40
- action: "merge" | "split" | "style" | "table-style";
40
+ action: "merge" | "split" | "style" | "table-style" | "table-alt";
41
41
  tablePath: string;
42
42
  presentation: unknown;
43
43
  patches: JsonPatchOperation[];
@@ -72,6 +72,11 @@ export declare function mergeTableCells(editor: EditorSession, tablePath: string
72
72
  export declare function splitTableCell(editor: EditorSession, tablePath: string, cell: TableCellAddress, meta?: Record<string, unknown>): TableChange;
73
73
  export declare function setTableCellStyle(editor: EditorSession, tablePath: string, cells: TableCellAddress | TableCellAddress[], style: Record<string, unknown> | null, meta?: Record<string, unknown>): TableChange;
74
74
  export declare function setTableStyle(editor: EditorSession, tablePath: string, preset: keyof typeof TABLE_STYLE_PRESETS | Partial<TableStyle>, meta?: Record<string, unknown>): TableChange;
75
+ /** FA-27: the table's text alternative as form state: `alt` (empty when absent or decorative) and `decorative` (the empty alt). */
76
+ export declare function readTableAlt(table: unknown): { alt: string; decorative: boolean };
77
+ /** FA-27: change the table's text alternative. `alt` is trimmed and an empty string or null removes it; `decorative: true` writes the empty alt and wins over `alt`; `decorative: false` removes an empty alt. Works on inline and dataset-backed tables. */
78
+ export declare function prepareTableAlt(presentation: unknown, tablePath: string, change: { alt?: string | null; decorative?: boolean }): PreparedTableChange;
79
+ export declare function setTableAlt(editor: EditorSession, tablePath: string, change: { alt?: string | null; decorative?: boolean }, meta?: Record<string, unknown>): TableChange;
75
80
  /** {@link readTableStyle} for a table object. */
76
81
  export declare function readTableStyleOfTable(table: unknown): (TableStyle | { header: "custom"; banding: "custom"; borders: "custom" }) & { preset: keyof typeof TABLE_STYLE_PRESETS | "custom" };
77
82
  /** Apply a table style to a table object in place (the structure operations use it to keep banding, header fill and borders correct). */
@@ -394,6 +394,44 @@ export function readTableStyleOfTable(table) {
394
394
  // Table structure (RR-24): insert, delete, move and sort rows and columns, the header row, cell values.
395
395
  export * from "./table-structure.js";
396
396
 
397
+ // --- text alternative (FA-27) -----------------------------------------------------------------
398
+
399
+ /**
400
+ * The table's text alternative as form state, like a chart's: `alt` is the text (empty when absent or decorative) and `decorative`
401
+ * is the empty alt, a reviewed choice and not a missing one.
402
+ */
403
+ export function readTableAlt(table) {
404
+ return { alt: typeof table?.alt === "string" ? table.alt : "", decorative: table?.alt === "" };
405
+ }
406
+
407
+ /**
408
+ * Compute the patch that changes the table's text alternative. `change` is `{ alt?, decorative? }`: `alt` is trimmed and an empty string
409
+ * or `null` removes it, `decorative: true` writes the empty alt (and wins over `alt`), `decorative: false` removes an empty alt. It works
410
+ * on an inline table and on a table that shows a shared dataset, because `alt` belongs to the table and not to its data. The document
411
+ * is not modified.
412
+ */
413
+ export function prepareTableAlt(presentation, tablePath, change) {
414
+ const before = checkFormat(presentation);
415
+ const parts = splitOpfPath(tablePath);
416
+ const table = getValueAtPath(presentation, parts);
417
+ if (!isObject(table) || (!Array.isArray(table.rows) && typeof table.dataset !== "string")) throw fail("table-not-found", "Choose a table (a path ending in .table).", { tablePath });
418
+ if (!isObject(change)) throw fail("invalid-table-option", "A table alt change is { alt?, decorative? }.", { change });
419
+ let wanted;
420
+ if (change.decorative === true) wanted = "";
421
+ else if (change.alt !== undefined) wanted = typeof change.alt === "string" && change.alt.trim() ? change.alt.trim() : undefined;
422
+ else if (change.decorative === false && table.alt === "") wanted = undefined;
423
+ else wanted = table.alt;
424
+ const pointer = opfPathToJsonPointer([...parts, "alt"]);
425
+ const present = Object.hasOwn(table, "alt");
426
+ const patches = [];
427
+ if (wanted === undefined) {
428
+ if (present) patches.push({ op: "remove", path: pointer });
429
+ } else if (!present) patches.push({ op: "add", path: pointer, value: wanted });
430
+ else if (table.alt !== wanted) patches.push({ op: "replace", path: pointer, value: wanted });
431
+ const next = checkedDocument(presentation, patches, before);
432
+ return { action: "table-alt", tablePath: parts.join("."), changed: patches.length > 0, patches, presentation: clone(next) };
433
+ }
434
+
397
435
  // --- session forms ----------------------------------------------------------------------------
398
436
 
399
437
  function commit(editor, prepared, meta = {}) {
@@ -423,6 +461,11 @@ export function setTableCellStyle(editor, tablePath, cells, style, meta = {}) {
423
461
  checkEditor(editor);
424
462
  return commit(editor, prepareTableCellStyle(editor.presentation, tablePath, cells, style), meta);
425
463
  }
464
+ /** Change the table's text alternative as one undoable transaction. See {@link prepareTableAlt}. */
465
+ export function setTableAlt(editor, tablePath, change, meta = {}) {
466
+ checkEditor(editor);
467
+ return commit(editor, prepareTableAlt(editor.presentation, tablePath, change), meta);
468
+ }
426
469
  /** Apply a table style as one undoable transaction. See {@link prepareTableStyle}. */
427
470
  export function setTableStyle(editor, tablePath, preset, meta = {}) {
428
471
  checkEditor(editor);
@@ -1,3 +1,4 @@
1
+ import type { Catalog, CopiedRecordRename } from "@openpresentation/opf";
1
2
  export declare const MAX_OPF_BYTES: number;
2
3
  export interface OpfTransfer {
3
4
  kind: "presentation" | "slides" | "selection";
@@ -14,6 +15,8 @@ export declare function serializeOpfTransfer(
14
15
  slideIndex?: number;
15
16
  path?: string;
16
17
  format?: "pretty" | "compact" | "markdown";
18
+ /** Host catalogs: a presentation or slide is embedded (core `embed`) so it renders the same where none is registered. */
19
+ catalogs?: readonly Catalog[];
17
20
  },
18
21
  ): string;
19
22
  export declare function prepareOpfImport(
@@ -23,5 +26,13 @@ export declare function prepareOpfImport(
23
26
  mode?: "insert" | "replace" | "selection";
24
27
  slideIndex?: number;
25
28
  path?: string;
29
+ /** Host catalogs, for resolving and copying the inserted slides' records (core `copySlides`). */
30
+ catalogs?: readonly Catalog[];
26
31
  },
27
- ): { presentation: any; slideIndex: number };
32
+ ): {
33
+ presentation: any;
34
+ slideIndex: number;
35
+ /** Records that did not keep their reference while inserting: tell the user. */
36
+ renamed: CopiedRecordRename[];
37
+ addedGroups: { name: string; source?: string }[];
38
+ };