@openpresentation/opf-editor 0.14.2 → 0.15.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.
@@ -1,6 +1,14 @@
1
1
  import type { ConvertOptions } from "@openpresentation/opf/convert";
2
+ import type { Catalog } from "@openpresentation/opf";
2
3
  import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
3
4
 
5
+ /** Display metadata for the engine vocabularies, in the shape of `catalogDisplay` (`@openpresentation/opf/catalog`): records by id, or a list of records with `id` (`bcp47` for a language). */
6
+ export interface SwitchVocabularies {
7
+ languages?: unknown;
8
+ chartTypes?: unknown;
9
+ socialPlatforms?: unknown;
10
+ }
11
+
4
12
  /** The schema's DimensionPreset values: the values of the slide-sizes switch. */
5
13
  export declare const SLIDE_SIZE_PRESETS: readonly ["16:9", "4:3", "16:10", "1:1", "4:5", "9:16", "letter", "a4", "widescreen", "standard"];
6
14
  export type SlideSizePreset = (typeof SLIDE_SIZE_PRESETS)[number];
@@ -29,14 +37,17 @@ export type SwitchDimension = (typeof SWITCH_DIMENSIONS)[number];
29
37
  export interface DimensionSwitchOptions {
30
38
  /** Required for layouts and charts; for design dimensions it scopes the switch to one slide (default: the deck). */
31
39
  slideIndex?: number;
32
- /** blocks: the complete block (`slides.0.blocks.1`) or one-payload slide/region to replace. charts: the block holding the chart (default: the slide's first chart). */
40
+ /** blocks: the complete block (`slides.0.blocks.1`) or one-payload slide/region to replace. charts: the block holding the chart (default: the slide's first chart). image-treatments: the image block. */
33
41
  path?: string | string[];
34
- /** A gallery item's catalog record, added inline in the same transaction when the id is not already defined. */
42
+ /** A gallery item's catalog record, embedded in the same transaction (under the group of `recordSource`, else `custom`) when it is not embedded yet. */
35
43
  record?: Record<string, unknown> & { id: string };
36
- /** Extra caller-loaded catalog records by kind. */
37
- catalogs?: Record<string, unknown>;
38
- catalogSources?: Record<string, unknown>;
39
- /** themes: also write the theme's color scheme, font scheme and background (and, for the deck, dimensions) to the design (default true). A slide's design cannot set dimensions. */
44
+ /** The catalog source of `record` (for example "https://www.pptx.gallery"). */
45
+ recordSource?: string;
46
+ /** Host catalogs (`Catalog[]`): references resolve in the document, then in these. */
47
+ catalogs?: readonly Catalog[];
48
+ /** Display metadata for languages, chart types and social platforms (labels in pickers). */
49
+ vocabularies?: SwitchVocabularies;
50
+ /** themes: the theme's own color scheme, font scheme and background (and, for the deck, dimensions) apply: overrides of those at the switched scope are removed; on a slide whose deck sets one, the theme's value is written on the slide (default true). */
40
51
  bundle?: boolean;
41
52
  /** Deck-scope design switches: remove slide-level values that would hide the switch. */
42
53
  clearSlideOverrides?: boolean;
@@ -55,12 +66,11 @@ export interface DimensionSwitchOptions {
55
66
 
56
67
  export type DimensionSwitchValue =
57
68
  | null /* backgrounds: remove it */
58
- | string /* slide-sizes: a SlideSizePreset. purposes: a catalog id or free-form goal text. */
59
- | string[] /* audiences: catalog ids */
69
+ | string /* slide-sizes: a SlideSizePreset. purposes: a catalog reference or free-form goal text. languages: a BCP-47 tag. Catalog dimensions: a reference, `id` or `name:id`. */
70
+ | string[] /* audiences: catalog references */
60
71
  | { platform: string; handle: string }
61
72
  | { header?: unknown; footer?: unknown }
62
- | { slideImage?: unknown; imageFill?: string | null }
63
- | Record<string, unknown>;
73
+ | Record<string, unknown> /* image-treatments: fit, focus, aspectRatio, shape, cornerRadius, border, opacity, recolor, overlay, placement (null removes) */;
64
74
 
65
75
  export interface PreparedDimensionSwitch {
66
76
  dimension: SwitchDimension;
@@ -92,18 +102,23 @@ export declare function prepareDimensionSwitch(presentation: unknown, dimension:
92
102
  export declare function switchDimension(editor: EditorSession, dimension: SwitchDimension, value: DimensionSwitchValue, options?: DimensionSwitchOptions): DimensionSwitchChange;
93
103
 
94
104
  export interface SwitchOption {
105
+ /** The value to switch to: for a catalog dimension the reference, `id` or `name:id`. */
95
106
  id: string;
96
107
  label: string;
97
108
  record?: Record<string, unknown>;
109
+ reference?: string;
110
+ group?: string;
111
+ source?: string;
112
+ origin?: "document" | "host";
98
113
  }
99
114
  export interface CompatibleChartType extends SwitchOption {
100
115
  /** True for the chart's present type, which is always listed. */
101
116
  current: boolean;
102
117
  }
103
- /** Values a picker can offer for a catalog-backed dimension (document inline records, caller catalogs, then the bundled catalog, without duplicates); `blocks` lists the content kinds and `slide-sizes` the presets (labelled with their inches). `purposes` lists the catalog; any other goal text is also a valid switch value. */
104
- export declare function listSwitchOptions(presentation: unknown, dimension: SwitchDimension, options?: Pick<DimensionSwitchOptions, "catalogs" | "catalogSources">): SwitchOption[];
105
- /** Chart types the chart's inline data can use as it is (data-shape compatibility, not an engine-support claim). `path` or `slideIndex` picks the chart. */
106
- export declare function compatibleChartTypes(presentation: unknown, options?: Pick<DimensionSwitchOptions, "slideIndex" | "path" | "catalogs" | "catalogSources">): CompatibleChartType[];
107
- /** The value a dimension currently has: `{ value, scope }`, with the catalog id for catalog dimensions. `slide-sizes` reads the deck's design.dimensions, else its theme's (a preset string, or the object for a custom size); `purposes` reads the goal text or Purpose id. */
108
- export declare function currentSwitchValue(presentation: unknown, dimension: SwitchDimension, options?: Pick<DimensionSwitchOptions, "slideIndex" | "path" | "owner" | "index">): { value: unknown; scope: "deck" | "slide" | "block" };
118
+ /** Values a picker can offer: a catalog dimension lists core's `catalogRecords` (embedded records, then `catalogs`); `charts` and `socials` list core's vocabularies, `languages` core's `LANGUAGES` tags; `blocks` lists the content kinds and `slide-sizes` the presets. `purposes` lists the catalogs; any other goal text is also a valid switch value. */
119
+ export declare function listSwitchOptions(presentation: unknown, dimension: SwitchDimension, options?: Pick<DimensionSwitchOptions, "catalogs" | "vocabularies">): SwitchOption[];
120
+ /** Chart types the chart's inline data can use as it is (data-shape compatibility from `vocabularies.chartTypes`, not an engine-support claim). `path` or `slideIndex` picks the chart. */
121
+ export declare function compatibleChartTypes(presentation: unknown, options?: Pick<DimensionSwitchOptions, "slideIndex" | "path" | "catalogs" | "vocabularies">): CompatibleChartType[];
122
+ /** The value a dimension currently has: `{ value, scope }`, with the reference (`id` or `name:id`) for catalog dimensions. `slide-sizes` reads the deck's design.dimensions, else its theme's (a preset string, or the object for a custom size); `purposes` reads the goal text or Purpose id. */
123
+ export declare function currentSwitchValue(presentation: unknown, dimension: SwitchDimension, options?: Pick<DimensionSwitchOptions, "slideIndex" | "path" | "owner" | "index" | "catalogs">): { value: unknown; scope: "deck" | "slide" | "block" };
109
124
  export { blockConversionTargets } from "./block-convert.js";
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
16
  import { 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,20 +256,22 @@ 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
276
  const types = Array.isArray(record.placeholders) ? record.placeholders.map((placeholder) => placeholder.type) : undefined;
231
277
  // Add the blank payloads the layout declares, as the JSON editor's layout choice does.
@@ -238,13 +284,13 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
238
284
  slideIndex = scopeIndex;
239
285
  slideAt(presentation, slideIndex, "a chart switch");
240
286
  scope = "slide";
241
- requireCatalogId(presentation, dimension, value, options);
287
+ 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
288
  const owner = options.path ? splitOpfPath(options.path) : findChartOwner(presentation, slideIndex);
243
289
  if (options.path && (owner[0] !== "slides" || owner[1] !== String(slideIndex)))
244
290
  throw fail("path-slide-mismatch", "options.path is not on the slide named by options.slideIndex.", { slideIndex, path: options.path });
245
291
  const chart = owner && getValueAtPath(presentation, [...owner, "chart"]);
246
292
  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)])];
293
+ patches = chart.type === value ? [] : [...createValuePatch(presentation, [...owner, "chart", "type"], value), ...staleMappingPatches(presentation, owner, chart, value), ...staleComboPatches(presentation, owner, chart, value)];
248
294
  } else if (dimension === "blocks") {
249
295
  if (options.path === undefined) throw fail("missing-path", "Choose the block to replace with options.path.");
250
296
  // convert: true moves the block's own text into the new kind (block-convert.js) instead of replacing it.
@@ -263,30 +309,58 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
263
309
  scope = "block";
264
310
  } else if (dimension === "socials") {
265
311
  patches = socialsPatches(presentation, value, options);
312
+ } else if (dimension === "image-treatments") {
313
+ // The treatments of one image block (FA-22): fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio, placement.
314
+ if (options.path === undefined) throw fail("missing-path", "Choose the image block to treat with options.path.");
315
+ patches = imageTreatmentPatches(presentation, options.path, value);
316
+ const parts = splitOpfPath(options.path);
317
+ if (parts[0] === "slides") slideIndex = Number(parts[1]);
318
+ scope = "block";
319
+ } else if (dimension === "languages") {
320
+ // A BCP-47 tag (en-US) or a language object ({ bcp47, ... }); the schema checks the tag. Language catalog ids are gone.
321
+ if (!(typeof value === "string" && value) && !(isObject(value) && typeof value.bcp47 === "string"))
322
+ throw fail("invalid-switch-value", "Switch languages to a BCP-47 tag such as en-US, or a language object with bcp47.", { value });
323
+ patches = rootPatch(presentation, "language", value);
266
324
  } else if (ROOT_FIELD[dimension]) {
267
325
  const field = ROOT_FIELD[dimension];
268
- let next = value;
326
+ let next;
269
327
  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)];
328
+ const list = Array.isArray(value) ? value : [value];
329
+ if (!list.length) throw fail("invalid-catalog-id", "Switch audiences to at least one catalog reference.", { value });
330
+ next = [];
331
+ for (const reference of list) {
332
+ const choice = catalogChoice(applyJsonPatch(presentation, patches), dimension, reference, options);
333
+ patches = [...patches, ...choice.patches];
334
+ next.push(choice.reference);
335
+ }
336
+ } else {
337
+ const choice = catalogChoice(presentation, dimension, value, options);
338
+ patches = choice.patches;
339
+ next = choice.reference;
340
+ }
341
+ patches = [...patches, ...rootPatch(presentation, field, next)];
275
342
  } else {
276
343
  // Design dimensions: deck scope by default, or one slide with options.slideIndex.
277
344
  let entries;
278
345
  if (dimension === "themes") {
279
- const record = requireCatalogId(presentation, dimension, value, options);
280
- entries = { theme: value };
346
+ const choice = catalogChoice(presentation, dimension, value, options);
347
+ const record = choice.record ?? {};
348
+ entries = { theme: choice.reference };
281
349
  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];
350
+ // An explicit theme choice brings the theme's own colour scheme, font scheme and background (and, for the deck, its
351
+ // slide size): an override of those at the same scope is removed so the theme's applies. Nothing is copied out of the
352
+ // record, except on a slide whose deck sets the key: there the theme's value is written on the slide (its references
353
+ // qualified with the theme's catalog group), because the deck's design would otherwise win over the slide's theme.
354
+ // A slide's design cannot set dimensions (a PPTX has one slide size), so only a deck-scope switch touches them.
355
+ for (const key of ["colorScheme", "fontScheme", "background", ...(scopeIndex === undefined ? ["dimensions"] : [])]) {
356
+ if (record[key] === undefined) continue;
357
+ entries[key] = scopeIndex !== undefined && presentation.design?.[key] !== undefined ? qualifiedValue(key, record[key], choice.group) : null;
358
+ }
286
359
  }
287
- patches = catalogRecordPatches(presentation, dimension, options);
360
+ patches = choice.patches;
288
361
  } else if (dimension === "color-schemes" || dimension === "font-schemes") {
289
- requireCatalogId(presentation, dimension, value, options);
362
+ const choice = catalogChoice(presentation, dimension, value, options);
363
+ value = choice.reference;
290
364
  entries = { [DESIGN_KEYS[dimension][0]]: value };
291
365
  if (dimension === "font-schemes") {
292
366
  // An accent font is its own choice, not part of the scheme being left: it stays (in the object form).
@@ -294,7 +368,7 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
294
368
  const own = getValueAtPath(presentation, [...scopeBase, "design", "fontScheme"]);
295
369
  if (own && typeof own === "object" && own.accent !== undefined) entries.fontScheme = { id: value, accent: structuredClone(own.accent) };
296
370
  }
297
- patches = catalogRecordPatches(presentation, dimension, options);
371
+ patches = choice.patches;
298
372
  } else {
299
373
  // A background is an object or a shorthand string (theme slot or hex color); the schema
300
374
  // validates the candidate document, so a string that is neither is rejected below.
@@ -342,7 +416,8 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
342
416
  }
343
417
 
344
418
  /**
345
- * Switch one dimension of the session's document as a single undoable transaction. Returns the
419
+ * Switch one dimension of the session's document as a single undoable transaction, resolving references with the session's
420
+ * registered catalogs (and `options.catalogs`). Returns the
346
421
  * editor change plus `dimension`, `scope`, `changed` and `shadowed` (slides whose own design
347
422
  * hides a deck-level switch). Switching to the current value commits nothing.
348
423
  */
@@ -350,7 +425,8 @@ export function switchDimension(editor, dimension, value, options = {}) {
350
425
  if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
351
426
  throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
352
427
  const { meta, ...switchOptions } = options;
353
- const prepared = prepareDimensionSwitch(editor.presentation, dimension, value, switchOptions);
428
+ // The session's registered catalogs first, then any the caller adds for this switch: the one list core resolves with.
429
+ const prepared = prepareDimensionSwitch(editor.presentation, dimension, value, { ...switchOptions, catalogs: mergeCatalogs(editor.catalogs, options.catalogs) });
354
430
  const { presentation, patches, ...summary } = prepared;
355
431
  void presentation;
356
432
  if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
@@ -360,28 +436,46 @@ export function switchDimension(editor, dimension, value, options = {}) {
360
436
 
361
437
  // --- options for pickers and current values ---------------------------------------------------
362
438
 
363
- const labelOf = (record) => record.name ?? record.id;
439
+ // Host display metadata for an engine vocabulary (`options.vocabularies[key]`, the shape of `catalogDisplay`): a list of records
440
+ // with `id` (or `bcp47` for a language), or an object keyed by id. Returns a Map id -> record.
441
+ function displayRecords(options, key) {
442
+ const source = options.vocabularies?.[key];
443
+ const out = new Map();
444
+ if (Array.isArray(source)) {
445
+ for (const record of source) if (isObject(record) && typeof (record.id ?? record.bcp47) === "string") out.set(record.id ?? record.bcp47, record);
446
+ } else if (isObject(source)) {
447
+ for (const [id, record] of Object.entries(source)) out.set(isObject(record) && typeof record.bcp47 === "string" ? record.bcp47 : id, isObject(record) ? record : {});
448
+ }
449
+ return out;
450
+ }
451
+ const displayLabel = (record, id) => (typeof record?.name === "string" && record.name ? record.name : typeof record?.label === "string" && record.label ? record.label : id);
364
452
 
365
453
  /**
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.
454
+ * The values a picker can offer for a dimension. A catalog-backed dimension lists core's `catalogRecords` (the document's embedded
455
+ * records first, then `options.catalogs`'), each `{ id, label, record, reference, group, source, origin }` with `id` the reference
456
+ * to write. `charts` lists core's `CHART_TYPES` and `socials` core's `SOCIAL_PLATFORMS`, labelled from `options.vocabularies`;
457
+ * `languages` lists core's `LANGUAGES` tags. `blocks` lists the content
458
+ * kinds. Use `compatibleChartTypes` to narrow `charts` to the types the chart's data can use.
370
459
  */
371
460
  export function listSwitchOptions(presentation, dimension, options = {}) {
372
461
  if (dimension === "blocks") return BLOCK_KINDS.map((kind) => ({ id: kind, label: kind[0].toUpperCase() + kind.slice(1) }));
373
462
  if (dimension === "slide-sizes") return SLIDE_SIZE_PRESETS.map((preset) => ({ id: preset, label: SLIDE_SIZE_LABELS[preset] }));
463
+ if (dimension === "charts") {
464
+ const display = displayRecords(options, VOCABULARY.charts);
465
+ return CHART_TYPES.map((id) => ({ id, label: displayLabel(display.get(id), id), record: display.get(id) ?? {} }));
466
+ }
467
+ if (dimension === "socials") {
468
+ const display = displayRecords(options, VOCABULARY.socials);
469
+ return Object.keys(SOCIAL_PLATFORMS).map((id) => ({ id, label: displayLabel(display.get(id), id), record: { ...SOCIAL_PLATFORMS[id], ...display.get(id) } }));
470
+ }
471
+ if (dimension === "languages") {
472
+ // Core's language table (the tags engines know the script, direction and default fonts of), labelled from the host's display metadata.
473
+ const display = displayRecords(options, VOCABULARY.languages);
474
+ return LANGUAGES.map((language) => ({ id: language.tag, label: displayLabel(display.get(language.tag), language.tag), record: { ...language, ...display.get(language.tag) } }));
475
+ }
374
476
  const kind = CATALOG_KIND[dimension];
375
477
  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;
478
+ return listCatalogRecords(presentation ?? {}, kind, { catalogs: catalogsOf(options) });
385
479
  }
386
480
 
387
481
  const SINGLE_SERIES_ONLY = new Set(["pieChart", "doughnutChart", "funnelChart", "treemapChart", "waterfallChart"]);
@@ -423,7 +517,7 @@ function chartDataShape(chart, presentation) {
423
517
  }
424
518
 
425
519
  /**
426
- * Chart types the chart's inline data can use as it is, from the chartTypes catalog: simple,
520
+ * 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
521
  * non-geographic, non-distribution types whose series count fits the data (the first column labels
428
522
  * the categories and each further column is a series; a type with N series needs exactly N value
429
523
  * columns, except that a stacked or percent-stacked type and a combination such as combo take N or more;
@@ -443,7 +537,8 @@ export function compatibleChartTypes(presentation, options = {}) {
443
537
  const record = option.record;
444
538
  const element = record.mappings?.openxml?.element;
445
539
  const current = option.id === chart.type;
446
- const simple = record.complexity === "simple" && !DISTRIBUTION_ELEMENTS.has(element);
540
+ // Without the host's display metadata for a type (complexity, series, mappings) it is offered: compatibility is unknown, not refused.
541
+ const simple = record.complexity === undefined ? !DISTRIBUTION_ELEMENTS.has(element) : record.complexity === "simple" && !DISTRIBUTION_ELEMENTS.has(element);
447
542
  // A combination (composition "mixed", the FA-15 combo chart) needs at least its series count, like a stacked type.
448
543
  const atLeast = STACKED_GROUPINGS.has(record.mappings?.openxml?.grouping) || record.mappings?.openxml?.composition === "mixed";
449
544
  const seriesOk =
@@ -462,8 +557,8 @@ export function compatibleChartTypes(presentation, options = {}) {
462
557
  /**
463
558
  * The value a dimension currently has, for pickers: `{ value, scope }` where scope is "slide" when
464
559
  * `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.
560
+ * dimension is unset. Catalog dimensions return the reference (`id` or `name:id`) even when the document holds an
561
+ * object form with overrides. `options.catalogs` resolves the theme's slide size.
467
562
  */
468
563
  export function currentSwitchValue(presentation, dimension, options = {}) {
469
564
  const idOf = (reference) => (reference && typeof reference === "object" && !Array.isArray(reference) ? reference.id : reference);
@@ -478,18 +573,24 @@ export function currentSwitchValue(presentation, dimension, options = {}) {
478
573
  return { value: owner ? getValueAtPath(presentation, [...owner, "chart", "type"]) : undefined, scope: "slide" };
479
574
  }
480
575
  if (dimension === "blocks") return { value: undefined, scope: "block" };
576
+ if (dimension === "image-treatments") {
577
+ // The treatments of the image block at `options.path` (FA-22); without a path there is nothing to read.
578
+ if (options.path === undefined) return { value: undefined, scope: "block" };
579
+ const state = readImageTreatments(presentation, options.path);
580
+ return { value: Object.fromEntries(IMAGE_TREATMENT_FIELDS.map((key) => [key, state[key]])), scope: "block" };
581
+ }
481
582
  if (dimension === "slide-sizes") {
482
583
  // The size the deck composes at: its own design.dimensions, else the theme's; a {preset} object reads as its preset.
483
584
  // A custom size (inches without a preset) reads as the object itself. Unset reads as undefined (composed as widescreen).
484
585
  let size = presentation.design?.dimensions;
485
586
  if (size === undefined) {
486
- const themeId = idOf(presentation.design?.theme);
487
- size = themeId ? findCatalogRecord(presentation, "themes", themeId, {})?.dimensions : undefined;
587
+ const theme = presentation.design?.theme;
588
+ size = typeof theme === "string" ? resolveRecord(presentation, "themes", theme, options)?.record?.dimensions : undefined;
488
589
  }
489
590
  return { value: size && typeof size === "object" && !Array.isArray(size) && Object.keys(size).length === 1 && size.preset ? size.preset : size, scope: "deck" };
490
591
  }
491
592
  if (dimension === "purposes") {
492
- // A catalog id or goal text reads as itself, a Purpose object as its id (else the object).
593
+ // A catalog reference or goal text reads as itself, a Purpose object as its id (else the object).
493
594
  const purpose = presentation.purpose;
494
595
  return { value: purpose && typeof purpose === "object" && !Array.isArray(purpose) ? (purpose.id ?? purpose) : purpose, scope: "deck" };
495
596
  }
@@ -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
+ };