@openpresentation/opf-editor 0.12.1 → 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.
Files changed (103) hide show
  1. package/README.md +122 -80
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +16 -16
  6. package/dist/background-options.d.ts +12 -5
  7. package/dist/background-options.js +44 -19
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +22 -21
  11. package/dist/blocks.d.ts +8 -8
  12. package/dist/blocks.js +41 -40
  13. package/dist/canvas-fields.js +35 -12
  14. package/dist/canvas.d.ts +30 -31
  15. package/dist/canvas.js +64 -68
  16. package/dist/catalogs.d.ts +48 -0
  17. package/dist/catalogs.js +217 -0
  18. package/dist/chart-data.d.ts +10 -10
  19. package/dist/chart-data.js +21 -21
  20. package/dist/chart-options-panel.d.ts +2 -2
  21. package/dist/chart-options-panel.js +82 -8
  22. package/dist/chart-options.d.ts +35 -9
  23. package/dist/chart-options.js +133 -40
  24. package/dist/checks.js +23 -0
  25. package/dist/color-authoring.js +1 -1
  26. package/dist/content-actions.d.ts +17 -17
  27. package/dist/content-actions.js +74 -73
  28. package/dist/content-controls.js +21 -21
  29. package/dist/data-grid.js +7 -8
  30. package/dist/data.d.ts +4 -4
  31. package/dist/data.js +5 -5
  32. package/dist/dataset-refs.d.ts +2 -2
  33. package/dist/dataset-refs.js +5 -5
  34. package/dist/design-controls.d.ts +12 -5
  35. package/dist/design-controls.js +312 -111
  36. package/dist/design-options.d.ts +20 -18
  37. package/dist/design-options.js +91 -99
  38. package/dist/edit-helpers.js +8 -7
  39. package/dist/exact-source.js +5 -5
  40. package/dist/export.d.ts +7 -7
  41. package/dist/export.js +48 -17
  42. package/dist/find-panel.js +5 -5
  43. package/dist/find-replace.d.ts +3 -3
  44. package/dist/find-replace.js +23 -20
  45. package/dist/font-gate.js +49 -58
  46. package/dist/galleries.js +50 -101
  47. package/dist/grid-model.d.ts +25 -27
  48. package/dist/grid-model.js +92 -102
  49. package/dist/image-crop.d.ts +4 -4
  50. package/dist/image-crop.js +28 -29
  51. package/dist/image-cropper.js +3 -3
  52. package/dist/image-options.d.ts +45 -0
  53. package/dist/image-options.js +195 -0
  54. package/dist/index.d.ts +48 -31
  55. package/dist/index.js +154 -126
  56. package/dist/json-editor.js +2 -2
  57. package/dist/json-menu.js +2 -2
  58. package/dist/json-options.d.ts +3 -3
  59. package/dist/json-options.js +44 -54
  60. package/dist/layout-handles.js +5 -5
  61. package/dist/layout-placeholders.js +4 -4
  62. package/dist/layout.d.ts +2 -2
  63. package/dist/layout.js +8 -7
  64. package/dist/numbering-panel.js +3 -5
  65. package/dist/numbering.d.ts +3 -5
  66. package/dist/numbering.js +14 -26
  67. package/dist/outline-view.js +24 -24
  68. package/dist/outline.d.ts +12 -12
  69. package/dist/outline.js +52 -50
  70. package/dist/persistence.d.ts +1 -1
  71. package/dist/persistence.js +19 -18
  72. package/dist/presentation-ids.js +2 -2
  73. package/dist/react.js +3 -3
  74. package/dist/review-panel.d.ts +30 -13
  75. package/dist/review-panel.js +160 -53
  76. package/dist/review.d.ts +36 -43
  77. package/dist/review.js +92 -53
  78. package/dist/rich-text-input.js +4 -3
  79. package/dist/rich-text-toolbar.js +5 -3
  80. package/dist/rich-text.js +5 -2
  81. package/dist/schema-inspector.d.ts +2 -2
  82. package/dist/schema-inspector.js +16 -11
  83. package/dist/schema.d.ts +1 -1
  84. package/dist/schema.js +26 -6
  85. package/dist/slide-manager.d.ts +2 -2
  86. package/dist/slide-manager.js +19 -19
  87. package/dist/slides.d.ts +21 -21
  88. package/dist/slides.js +104 -101
  89. package/dist/svelte.js +2 -3
  90. package/dist/switches.d.ts +37 -22
  91. package/dist/switches.js +282 -171
  92. package/dist/table-options.d.ts +8 -8
  93. package/dist/table-options.js +28 -27
  94. package/dist/table-structure.d.ts +10 -10
  95. package/dist/table-structure.js +21 -21
  96. package/dist/template-panel.d.ts +3 -3
  97. package/dist/template-panel.js +37 -13
  98. package/dist/templates.d.ts +22 -11
  99. package/dist/templates.js +48 -55
  100. package/dist/transfer.d.ts +15 -4
  101. package/dist/transfer.js +107 -161
  102. package/package.json +12 -6
  103. package/dist/font-defaults.js +0 -25
package/dist/switches.js CHANGED
@@ -2,30 +2,34 @@
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 * as core from "@openpresentation/opf";
6
- import { catalogSchemaNames, catalogs as bundledCatalogs, schemas } from "@openpresentation/opf";
7
- import {
8
- OPFEditorError,
9
- createValuePatch,
10
- applyJsonPatch,
11
- getValueAtPath,
12
- opfPathToJsonPointer,
13
- splitOpfPath,
14
- validateOpfDocument,
15
- } from "./index.js";
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";
12
+ import { chartOptionTarget } from "@openpresentation/opf/composition";
13
+ import { createValuePatch, applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
16
14
  import { createContentBlock, prepareBlockReplace } from "./blocks.js";
17
15
  import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
18
16
  import { populateLayoutPlaceholders } from "./layout-placeholders.js";
19
17
  import { blockConversionTargets, prepareBlockConversion } from "./block-convert.js";
18
+ import { checkFormat } from "./checks.js";
19
+ import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
20
+ import { IMAGE_TREATMENT_FIELDS, imageTreatmentPatches, readImageTreatments } from "./image-options.js";
20
21
 
21
- /** The schema's DimensionPreset values (RR-41), the values of the slide-sizes switch. */
22
- export const SLIDE_SIZE_PRESETS = Object.freeze(["16:9", "4:3", "16:10", "letter", "a4", "widescreen", "standard"]);
22
+ /** The schema's DimensionPreset values (RR-41, FA-13: with the social-feed ratios), the values of the slide-sizes switch. */
23
+ export const SLIDE_SIZE_PRESETS = Object.freeze(["16:9", "4:3", "16:10", "1:1", "4:5", "9:16", "letter", "a4", "widescreen", "standard"]);
23
24
 
24
25
  // Slide size in inches per preset, as core's resolveCanvasDimensions composes it (for picker labels).
25
26
  const SLIDE_SIZE_LABELS = Object.freeze({
26
27
  "16:9": "16:9 (13.33 x 7.5 in)",
27
28
  "4:3": "4:3 (10 x 7.5 in)",
28
29
  "16:10": "16:10 (10 x 6.25 in)",
30
+ "1:1": "1:1 square (7.5 x 7.5 in)",
31
+ "4:5": "4:5 portrait (7.5 x 9.375 in)",
32
+ "9:16": "9:16 portrait (7.5 x 13.33 in)",
29
33
  letter: "Letter (11 x 8.5 in)",
30
34
  a4: "A4 (11.69 x 8.27 in)",
31
35
  widescreen: "Widescreen (13.33 x 7.5 in)",
@@ -56,20 +60,19 @@ export const SWITCH_DIMENSIONS = Object.freeze([
56
60
  "purposes",
57
61
  ]);
58
62
 
59
- // Catalog kind behind each catalog-backed dimension.
63
+ // Catalog kind behind each catalog-backed dimension (core's content kinds).
60
64
  const CATALOG_KIND = Object.freeze({
61
65
  layouts: "layouts",
62
66
  "color-schemes": "colorSchemes",
63
67
  "font-schemes": "fontSchemes",
64
- languages: "languages",
65
68
  narratives: "narratives",
66
69
  themes: "themes",
67
70
  audiences: "audiences",
68
71
  tones: "tones",
69
- socials: "socialPlatforms",
70
- charts: "chartTypes",
71
72
  purposes: "purposes",
72
73
  });
74
+ // Display metadata key (`options.vocabularies`) behind each engine-vocabulary dimension.
75
+ const VOCABULARY = Object.freeze({ languages: "languages", charts: "chartTypes", socials: "socialPlatforms" });
73
76
  // Top-level document field for the simple metadata dimensions.
74
77
  const ROOT_FIELD = Object.freeze({ languages: "language", narratives: "narrative", tones: "tone", audiences: "audience" });
75
78
  // `design` keys for the design dimensions. A key set to `null` in the value is removed.
@@ -78,67 +81,98 @@ const DESIGN_KEYS = Object.freeze({
78
81
  "font-schemes": ["fontScheme"],
79
82
  backgrounds: ["background"],
80
83
  "headers-footers": ["header", "footer"],
81
- "image-treatments": ["slideImage", "imageFill"],
82
84
  });
83
85
  const REGION = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
84
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);
85
89
 
86
- function recordList(source) {
87
- const records = Array.isArray(source) ? source : source?.records;
88
- 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) });
89
96
  }
90
97
 
91
- // Inline document records first (they override), then caller-loaded records, then the
92
- // bundled catalog. Unlike getCatalogRecords, an inline catalog never hides bundled ids.
93
- function findCatalogRecord(document, kind, id, options) {
94
- return (
95
- recordList(document.catalogs?.[kind]).find((record) => record.id === id) ??
96
- recordList(options.catalogs?.[kind] ?? options.catalogSources?.[kind]).find((record) => record.id === id) ??
97
- (options.record?.id === id ? options.record : undefined) ??
98
- bundledCatalogs[kind]?.find((record) => record.id === id)
99
- );
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 };
100
125
  }
101
126
 
102
- function requireCatalogId(document, 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) {
103
129
  const kind = CATALOG_KIND[dimension];
104
- if (typeof id !== "string" || id.length === 0)
105
- throw fail("invalid-catalog-id", `Switch ${dimension} to a non-empty catalog id.`, { dimension, id });
106
- const record = findCatalogRecord(document, kind, id, options);
107
- if (!record) throw fail("unknown-catalog-id", `Unknown ${kind} catalog ID: ${id}.`, { dimension, catalogKind: kind, id });
108
- 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;
109
135
  }
110
136
 
111
- // A gallery item supplies its own record; add it inline in the same transaction when the
112
- // document and the bundled catalog do not already define the id.
113
- function catalogRecordPatches(document, dimension, options) {
114
- const record = options.record;
115
- if (!record) return [];
116
- const kind = CATALOG_KIND[dimension];
117
- if (recordList(document.catalogs?.[kind]).some((entry) => entry.id === record.id) || bundledCatalogs[kind]?.some((entry) => entry.id === record.id)) return [];
118
- const value = { $schema: schemas[catalogSchemaNames[kind]].$id, ...record };
119
- if (!document.catalogs || typeof document.catalogs !== "object") return [{ op: "add", path: "/catalogs", value: { [kind]: { records: [value] } } }];
120
- if (!document.catalogs[kind] || typeof document.catalogs[kind] !== "object")
121
- return [{ op: "add", path: `/catalogs/${kind}`, value: { records: [value] } }];
122
- if (!Array.isArray(document.catalogs[kind].records)) return [{ op: "add", path: `/catalogs/${kind}/records`, value: [value] }];
123
- 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);
124
158
  }
125
159
 
126
- function slideAt(document, slideIndex, what = "this switch") {
127
- if (!Number.isInteger(slideIndex) || !document.slides?.[slideIndex])
160
+ function slideAt(presentation, slideIndex, what = "this switch") {
161
+ if (!Number.isInteger(slideIndex) || !presentation.slides?.[slideIndex])
128
162
  throw fail("slide-index-out-of-range", `Choose an existing slide for ${what}.`, { slideIndex });
129
- return document.slides[slideIndex];
163
+ return presentation.slides[slideIndex];
130
164
  }
131
165
 
132
- function rootPatch(document, field, value) {
133
- return same(document[field], value) ? [] : createValuePatch(document, [field], value);
166
+ function rootPatch(presentation, field, value) {
167
+ return same(presentation[field], value) ? [] : createValuePatch(presentation, [field], value);
134
168
  }
135
169
 
136
170
  // A deck-level switch does nothing for a slide that carries its own value for that key.
137
- function shadowedSlides(document, keys) {
138
- return (document.slides ?? []).flatMap((slide, index) => (keys.some((key) => slide?.design?.[key] !== undefined) ? [index] : []));
171
+ function shadowedSlides(presentation, keys) {
172
+ return (presentation.slides ?? []).flatMap((slide, index) => (keys.some((key) => slide?.design?.[key] !== undefined) ? [index] : []));
139
173
  }
140
174
 
141
- function findChartOwner(document, slideIndex) {
175
+ function findChartOwner(presentation, slideIndex) {
142
176
  const visit = (node, path, depth) => {
143
177
  if (!node || typeof node !== "object" || Array.isArray(node) || depth > 32) return undefined;
144
178
  if (node.chart && typeof node.chart === "object") return path;
@@ -154,28 +188,34 @@ function findChartOwner(document, slideIndex) {
154
188
  }
155
189
  return undefined;
156
190
  };
157
- return visit(document.slides[slideIndex], ["slides", String(slideIndex)], 0);
191
+ return visit(presentation.slides[slideIndex], ["slides", String(slideIndex)], 0);
158
192
  }
159
193
 
160
- function socialsPatches(document, value, options) {
194
+ function socialsPatches(presentation, value, options) {
161
195
  const owner = options.owner ?? "speaker";
162
196
  if (!["speaker", "organization"].includes(owner)) throw fail("invalid-switch-value", "Socials owner must be 'speaker' or 'organization'.", { owner });
163
197
  if (!value || typeof value !== "object" || typeof value.platform !== "string" || typeof value.handle !== "string" || !value.handle)
164
198
  throw fail("invalid-switch-value", "Switch socials to { platform, handle } with a non-empty handle.", { value });
165
- requireCatalogId(document, "socials", value.platform, options);
166
- const host = document[owner];
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 });
201
+ const host = presentation[owner];
167
202
  const index = Array.isArray(host) ? (options.index ?? 0) : undefined;
168
203
  const target = Array.isArray(host) ? host[index] : host;
169
204
  if (!target || typeof target !== "object")
170
205
  throw fail("missing-owner", `Add the ${owner} to the document before setting their socials.`, { owner });
171
206
  const base = Array.isArray(host) ? [owner, String(index)] : [owner];
172
207
  const socials = target.socials;
173
- const patches = catalogRecordPatches(document, "socials", options);
208
+ const patches = [];
174
209
  if (!socials || typeof socials !== "object") return [...patches, { op: "add", path: opfPathToJsonPointer([...base, "socials"]), value: { [value.platform]: value.handle } }];
175
210
  if (socials[value.platform] === value.handle) return patches;
176
211
  return [...patches, { op: Object.hasOwn(socials, value.platform) ? "replace" : "add", path: opfPathToJsonPointer([...base, "socials", value.platform]), value: value.handle }];
177
212
  }
178
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
+
179
219
  function blockValue(value, options) {
180
220
  if (typeof value === "string") {
181
221
  if (!BLOCK_KINDS.includes(value)) throw fail("invalid-switch-value", `Unknown content block kind: ${value}.`, { value });
@@ -189,16 +229,16 @@ function blockValue(value, options) {
189
229
  * Compute the patch that switches one dimension, without touching any session. The candidate
190
230
  * document is validated; an invalid result throws unless the input was already invalid.
191
231
  */
192
- export function prepareDimensionSwitch(document, dimension, value, options = {}) {
232
+ export function prepareDimensionSwitch(presentation, dimension, value, options = {}) {
193
233
  if (!SWITCH_DIMENSIONS.includes(dimension))
194
234
  throw fail("unknown-dimension", `Unknown dimension: ${dimension}. Use one of ${SWITCH_DIMENSIONS.join(", ")}.`, { dimension });
195
- if (!document || typeof document !== "object") throw fail("invalid-input", "Switch requires an OPF document object.");
235
+ if (!presentation || typeof presentation !== "object") throw fail("invalid-input", "Switch requires an OPF document object.");
196
236
  if (options.record) {
197
- const ids = dimension === "audiences" ? [value].flat() : dimension === "socials" ? [value?.platform] : [value];
198
- if (!CATALOG_KIND[dimension] || !ids.includes(options.record.id))
199
- 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 });
200
240
  }
201
- const before = validateOpfDocument(document);
241
+ const before = checkFormat(presentation);
202
242
  const scopeIndex = options.slideIndex;
203
243
  let patches = [];
204
244
  let scope = "deck";
@@ -210,30 +250,28 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
210
250
  // A preset string; a custom size (design.dimensions as an object with inches) is replaced by it.
211
251
  if (!SLIDE_SIZE_PRESETS.includes(value)) throw fail("invalid-switch-value", `Switch slide-sizes to one of ${SLIDE_SIZE_PRESETS.join(", ")}.`, { value });
212
252
  if (scopeIndex !== undefined) throw fail("deck-scope-only", "A presentation has one slide size: switch slide-sizes for the deck, without slideIndex.", { slideIndex: scopeIndex });
213
- const own = document.design?.dimensions;
253
+ const own = presentation.design?.dimensions;
214
254
  // {preset} alone is the same size as the bare preset string.
215
255
  const sameSize = own && typeof own === "object" && !Array.isArray(own) && own.preset === value && Object.keys(own).length === 1;
216
- patches = sameSize ? [] : designPatches(document, [], { dimensions: value });
217
- shadowed = shadowedSlides(document, ["dimensions"]);
218
- if (options.clearSlideOverrides) {
219
- for (const index of shadowed) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", "dimensions"]) });
220
- shadowed = [];
221
- }
256
+ // A slide's design cannot set dimensions (FA-07), so no slide can shadow the deck's size.
257
+ patches = sameSize ? [] : designPatches(presentation, [], { dimensions: value });
222
258
  } else if (dimension === "purposes") {
223
- // 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.
224
260
  if (typeof value !== "string" && !(value && typeof value === "object" && !Array.isArray(value)))
225
- 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 });
226
262
  if (typeof value === "string" && value.length === 0) throw fail("invalid-switch-value", "Switch purposes to a non-empty goal.", { value });
227
- patches = [...catalogRecordPatches(document, dimension, options), ...rootPatch(document, "purpose", value)];
263
+ const supplied = typeof value === "string" ? catalogChoiceIfSupplied(presentation, dimension, value, options) : undefined;
264
+ patches = [...(supplied?.patches ?? []), ...rootPatch(presentation, "purpose", supplied?.reference ?? value)];
228
265
  } else if (dimension === "layouts") {
229
266
  slideIndex = scopeIndex;
230
- slideAt(document, slideIndex, "a layout switch");
267
+ slideAt(presentation, slideIndex, "a layout switch");
231
268
  scope = "slide";
232
- const record = requireCatalogId(document, dimension, value, options);
233
- patches = catalogRecordPatches(document, dimension, options);
234
- const withRecord = patches.length ? applyJsonPatch(document, patches) : document;
235
- if (withRecord.slides[slideIndex].layout !== value) {
236
- const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], value);
269
+ const choice = catalogChoice(presentation, dimension, value, options);
270
+ const record = choice.record;
271
+ patches = choice.patches;
272
+ const withRecord = patches.length ? applyJsonPatch(presentation, patches) : presentation;
273
+ if (withRecord.slides[slideIndex].layout !== choice.reference) {
274
+ const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], choice.reference);
237
275
  const swapped = applyJsonPatch(withRecord, layoutOps);
238
276
  const types = Array.isArray(record.placeholders) ? record.placeholders.map((placeholder) => placeholder.type) : undefined;
239
277
  // Add the blank payloads the layout declares, as the JSON editor's layout choice does.
@@ -244,64 +282,93 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
244
282
  }
245
283
  } else if (dimension === "charts") {
246
284
  slideIndex = scopeIndex;
247
- slideAt(document, slideIndex, "a chart switch");
285
+ slideAt(presentation, slideIndex, "a chart switch");
248
286
  scope = "slide";
249
- requireCatalogId(document, dimension, value, options);
250
- const owner = options.path ? splitOpfPath(options.path) : findChartOwner(document, slideIndex);
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 });
288
+ const owner = options.path ? splitOpfPath(options.path) : findChartOwner(presentation, slideIndex);
251
289
  if (options.path && (owner[0] !== "slides" || owner[1] !== String(slideIndex)))
252
290
  throw fail("path-slide-mismatch", "options.path is not on the slide named by options.slideIndex.", { slideIndex, path: options.path });
253
- const chart = owner && getValueAtPath(document, [...owner, "chart"]);
291
+ const chart = owner && getValueAtPath(presentation, [...owner, "chart"]);
254
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 });
255
- patches = [...catalogRecordPatches(document, dimension, options), ...(chart.type === value ? [] : [...createValuePatch(document, [...owner, "chart", "type"], value), ...staleMappingPatches(document, owner, chart, value)])];
293
+ patches = chart.type === value ? [] : [...createValuePatch(presentation, [...owner, "chart", "type"], value), ...staleMappingPatches(presentation, owner, chart, value), ...staleComboPatches(presentation, owner, chart, value)];
256
294
  } else if (dimension === "blocks") {
257
295
  if (options.path === undefined) throw fail("missing-path", "Choose the block to replace with options.path.");
258
296
  // convert: true moves the block's own text into the new kind (block-convert.js) instead of replacing it.
259
297
  if (options.convert) {
260
298
  if (typeof value !== "string") throw fail("invalid-switch-value", "Convert a block to a content kind name.", { value });
261
- const conversion = prepareBlockConversion(document, options.path, value, options.conversion);
299
+ const conversion = prepareBlockConversion(presentation, options.path, value, options.conversion);
262
300
  patches = conversion.changed ? conversion.patches : [];
263
301
  conversionLoss = conversion.loss;
264
302
  } else {
265
303
  const block = blockValue(value, options);
266
- const change = prepareBlockReplace(document, options.path, block);
304
+ const change = prepareBlockReplace(presentation, options.path, block);
267
305
  patches = change.changed ? change.patches : [];
268
306
  }
269
307
  const parts = splitOpfPath(options.path);
270
308
  if (parts[0] === "slides") slideIndex = Number(parts[1]);
271
309
  scope = "block";
272
310
  } else if (dimension === "socials") {
273
- patches = socialsPatches(document, value, options);
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);
274
324
  } else if (ROOT_FIELD[dimension]) {
275
325
  const field = ROOT_FIELD[dimension];
276
- let next = value;
326
+ let next;
277
327
  if (dimension === "audiences") {
278
- next = Array.isArray(value) ? value : [value];
279
- if (!next.length) throw fail("invalid-catalog-id", "Switch audiences to at least one catalog id.", { value });
280
- for (const id of next) requireCatalogId(document, dimension, id, options);
281
- } else requireCatalogId(document, dimension, value, options);
282
- patches = [...catalogRecordPatches(document, dimension, options), ...rootPatch(document, 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)];
283
342
  } else {
284
343
  // Design dimensions: deck scope by default, or one slide with options.slideIndex.
285
344
  let entries;
286
345
  if (dimension === "themes") {
287
- const record = requireCatalogId(document, dimension, value, options);
288
- entries = { theme: value };
346
+ const choice = catalogChoice(presentation, dimension, value, options);
347
+ const record = choice.record ?? {};
348
+ entries = { theme: choice.reference };
289
349
  if (options.bundle !== false) {
290
- // The gallery's theme snippet writes the whole bundle, so an explicit deck choice
291
- // does not keep the previous theme's fonts, colors or background.
292
- for (const key of ["colorScheme", "fontScheme", "background", "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
+ }
293
359
  }
294
- patches = catalogRecordPatches(document, dimension, options);
360
+ patches = choice.patches;
295
361
  } else if (dimension === "color-schemes" || dimension === "font-schemes") {
296
- requireCatalogId(document, dimension, value, options);
362
+ const choice = catalogChoice(presentation, dimension, value, options);
363
+ value = choice.reference;
297
364
  entries = { [DESIGN_KEYS[dimension][0]]: value };
298
365
  if (dimension === "font-schemes") {
299
366
  // An accent font is its own choice, not part of the scheme being left: it stays (in the object form).
300
- const scopeBase = scopeIndex !== undefined && document.slides?.[scopeIndex] ? ["slides", String(scopeIndex)] : [];
301
- const own = getValueAtPath(document, [...scopeBase, "design", "fontScheme"]);
367
+ const scopeBase = scopeIndex !== undefined && presentation.slides?.[scopeIndex] ? ["slides", String(scopeIndex)] : [];
368
+ const own = getValueAtPath(presentation, [...scopeBase, "design", "fontScheme"]);
302
369
  if (own && typeof own === "object" && own.accent !== undefined) entries.fontScheme = { id: value, accent: structuredClone(own.accent) };
303
370
  }
304
- patches = catalogRecordPatches(document, dimension, options);
371
+ patches = choice.patches;
305
372
  } else {
306
373
  // A background is an object or a shorthand string (theme slot or hex color); the schema
307
374
  // validates the candidate document, so a string that is neither is rejected below.
@@ -318,29 +385,29 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
318
385
  }
319
386
  let base = [];
320
387
  if (scopeIndex !== undefined) {
321
- slideAt(document, scopeIndex, `a ${dimension} switch`);
388
+ slideAt(presentation, scopeIndex, `a ${dimension} switch`);
322
389
  base = ["slides", String(scopeIndex)];
323
390
  scope = "slide";
324
391
  slideIndex = scopeIndex;
325
392
  }
326
- patches = [...patches, ...designPatches(document, base, entries)];
393
+ patches = [...patches, ...designPatches(presentation, base, entries)];
327
394
  if (scope === "deck") {
328
395
  const keys = Object.entries(entries).filter(([, entry]) => entry !== undefined).map(([key]) => key);
329
- shadowed = shadowedSlides(document, keys);
396
+ shadowed = shadowedSlides(presentation, keys);
330
397
  if (options.clearSlideOverrides)
331
398
  for (const index of shadowed)
332
399
  for (const key of keys)
333
- if (document.slides[index].design?.[key] !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", key]) });
400
+ if (presentation.slides[index].design?.[key] !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", key]) });
334
401
  if (options.clearSlideOverrides) shadowed = [];
335
402
  }
336
403
  }
337
404
 
338
- const next = checkedDocument(document, patches, before);
405
+ const next = checkedDocument(presentation, patches, before);
339
406
  return {
340
407
  dimension,
341
408
  scope,
342
409
  ...(slideIndex !== undefined ? { slideIndex } : {}),
343
- document: structuredClone(next),
410
+ presentation: structuredClone(next),
344
411
  patches,
345
412
  changed: patches.length > 0,
346
413
  shadowed,
@@ -349,7 +416,8 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
349
416
  }
350
417
 
351
418
  /**
352
- * 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
353
421
  * editor change plus `dimension`, `scope`, `changed` and `shadowed` (slides whose own design
354
422
  * hides a deck-level switch). Switching to the current value commits nothing.
355
423
  */
@@ -357,90 +425,130 @@ export function switchDimension(editor, dimension, value, options = {}) {
357
425
  if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
358
426
  throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
359
427
  const { meta, ...switchOptions } = options;
360
- const prepared = prepareDimensionSwitch(editor.document, dimension, value, switchOptions);
361
- const { document, patches, ...summary } = prepared;
362
- void document;
363
- if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
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) });
430
+ const { presentation, patches, ...summary } = prepared;
431
+ void presentation;
432
+ if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
364
433
  const change = editor.applyPatch(patches, { ...meta, source: meta?.source ?? "dimension-switch", dimension, scope: prepared.scope });
365
434
  return { ...change, ...summary };
366
435
  }
367
436
 
368
437
  // --- options for pickers and current values ---------------------------------------------------
369
438
 
370
- const labelOf = (record) => record.label ?? 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);
371
452
 
372
453
  /**
373
- * The values a picker can offer for a catalog-backed dimension, in document order: the document's
374
- * inline records first (they override), then caller-loaded records, then the bundled catalog,
375
- * without duplicates. `blocks` lists the content kinds. `charts` lists every chart type; use
376
- * `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.
377
459
  */
378
- export function listSwitchOptions(document, dimension, options = {}) {
460
+ export function listSwitchOptions(presentation, dimension, options = {}) {
379
461
  if (dimension === "blocks") return BLOCK_KINDS.map((kind) => ({ id: kind, label: kind[0].toUpperCase() + kind.slice(1) }));
380
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
+ }
381
476
  const kind = CATALOG_KIND[dimension];
382
477
  if (!kind) return [];
383
- const seen = new Set();
384
- const out = [];
385
- for (const source of [document?.catalogs?.[kind], options.catalogs?.[kind] ?? options.catalogSources?.[kind], bundledCatalogs[kind]])
386
- for (const record of recordList(source)) {
387
- if (seen.has(record.id)) continue;
388
- seen.add(record.id);
389
- out.push({ id: record.id, label: labelOf(record), record });
390
- }
391
- return out;
478
+ return listCatalogRecords(presentation ?? {}, kind, { catalogs: catalogsOf(options) });
392
479
  }
393
480
 
394
481
  const SINGLE_SERIES_ONLY = new Set(["pieChart", "doughnutChart", "funnelChart", "treemapChart", "waterfallChart"]);
395
482
  const MULTI_SERIES_CAPABLE = new Set(["barChart", "lineChart", "areaChart", "radarChart"]);
483
+ const STACKED_GROUPINGS = new Set(["stacked", "percentStacked"]);
396
484
  const DISTRIBUTION_ELEMENTS = new Set(["histogramChart", "boxWhiskerChart", "mapChart"]);
397
485
 
398
486
  // RR-54: `mapping.x` names the X column of an XY chart; a type without an X axis ignores it (core warns chart-mapping-adapted). Switching to such a
399
487
  // type removes it, and the whole mapping when nothing else is left, so the document does not keep a warning it cannot act on.
400
- function staleMappingPatches(document, owner, chart, type) {
488
+ function staleMappingPatches(presentation, owner, chart, type) {
401
489
  const mapping = chart.mapping;
402
490
  if (!mapping || typeof mapping !== "object" || Array.isArray(mapping) || mapping.x === undefined) return [];
403
- if (typeof core.isXYChartType !== "function" || core.isXYChartType(type)) return [];
491
+ if (isXYChartType(type)) return [];
404
492
  const { x: _x, ...rest } = mapping;
405
493
  const parts = [...owner, "chart", "mapping"];
406
- return Object.keys(rest).length ? createValuePatch(document, parts, rest) : [{ op: "remove", path: opfPathToJsonPointer(parts) }];
494
+ return Object.keys(rest).length ? createValuePatch(presentation, parts, rest) : [{ op: "remove", path: opfPathToJsonPointer(parts) }];
495
+ }
496
+
497
+ // FA-15: `line`, `secondaryAxis` and the secondary axis title belong to combo charts; any other type ignores them (core warns
498
+ // chart-option-adapted). Switching away from combo removes them, and an `axisTitles` left empty.
499
+ function staleComboPatches(presentation, owner, chart, type) {
500
+ if (chartOptionTarget(type)?.kind === "combo") return [];
501
+ const parts = [...owner, "chart"];
502
+ const patches = ["line", "secondaryAxis"].filter((key) => chart[key] !== undefined).map((key) => ({ op: "remove", path: opfPathToJsonPointer([...parts, key]) }));
503
+ const titles = chart.axisTitles;
504
+ if (titles && typeof titles === "object" && !Array.isArray(titles) && titles.secondary !== undefined) {
505
+ const { secondary: _secondary, ...rest } = titles;
506
+ patches.push(Object.keys(rest).length ? { op: "remove", path: opfPathToJsonPointer([...parts, "axisTitles", "secondary"]) } : { op: "remove", path: opfPathToJsonPointer([...parts, "axisTitles"]) });
507
+ }
508
+ return patches;
407
509
  }
408
510
 
409
- function chartDataShape(chart, document) {
511
+ function chartDataShape(chart, presentation) {
410
512
  // RR-54: inline data, a dataset reference and a series mapping all resolve to the columns the renderers read: the first column labels
411
513
  // the categories and every further column is a series.
412
- if (typeof core.resolveChartData !== "function") {
413
- const data = chart?.data;
414
- return data && Array.isArray(data.columns) && Array.isArray(data.rows) ? { series: Math.max(0, data.columns.length - 1), categories: data.rows.length } : undefined;
415
- }
416
- const resolved = core.resolveChartData(chart, document);
514
+ const resolved = resolveChartData(chart, presentation);
417
515
  if (!resolved.ok) return undefined;
418
516
  return { series: Math.max(0, resolved.columns.length - 1), categories: resolved.rows.length };
419
517
  }
420
518
 
421
519
  /**
422
- * 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,
423
521
  * non-geographic, non-distribution types whose series count fits the data (the first column labels
424
522
  * the categories and each further column is a series; a type with N series needs exactly N value
425
- * columns; column, bar, line, area and radar take any number). Data that is read from
523
+ * columns, except that a stacked or percent-stacked type and a combination such as combo take N or more;
524
+ * column, bar, line, area and radar take any number). Data that is read from
426
525
  * an external source returns every simple type. This is data-shape compatibility, not a claim that
427
526
  * an engine draws the type. `path` or `slideIndex` picks the chart (default: the slide's first).
428
527
  * Each entry has `current: true` for the chart's present type, which is always listed.
429
528
  */
430
- export function compatibleChartTypes(document, options = {}) {
529
+ export function compatibleChartTypes(presentation, options = {}) {
431
530
  const slideIndex = options.slideIndex ?? 0;
432
- const owner = options.path ? splitOpfPath(options.path) : document.slides?.[slideIndex] ? findChartOwner(document, slideIndex) : undefined;
433
- const chart = owner && getValueAtPath(document, [...owner, "chart"]);
531
+ const owner = options.path ? splitOpfPath(options.path) : presentation.slides?.[slideIndex] ? findChartOwner(presentation, slideIndex) : undefined;
532
+ const chart = owner && getValueAtPath(presentation, [...owner, "chart"]);
434
533
  if (!chart || typeof chart !== "object") return [];
435
- const shape = chartDataShape(chart, document);
534
+ const shape = chartDataShape(chart, presentation);
436
535
  const result = [];
437
- for (const option of listSwitchOptions(document, "charts", options)) {
536
+ for (const option of listSwitchOptions(presentation, "charts", options)) {
438
537
  const record = option.record;
439
538
  const element = record.mappings?.openxml?.element;
440
539
  const current = option.id === chart.type;
441
- const simple = record.complexity === "simple" && record.mappings?.openxml?.composition !== "mixed" && !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);
542
+ // A combination (composition "mixed", the FA-15 combo chart) needs at least its series count, like a stacked type.
543
+ const atLeast = STACKED_GROUPINGS.has(record.mappings?.openxml?.grouping) || record.mappings?.openxml?.composition === "mixed";
442
544
  const seriesOk =
443
- !shape || !record.series || (record.series > 1 ? record.series === shape.series : shape.series === 1 || (MULTI_SERIES_CAPABLE.has(element) && !SINGLE_SERIES_ONLY.has(element)));
545
+ !shape ||
546
+ !record.series ||
547
+ (record.series > 1
548
+ ? atLeast
549
+ ? shape.series >= record.series
550
+ : record.series === shape.series
551
+ : shape.series === 1 || (MULTI_SERIES_CAPABLE.has(element) && !SINGLE_SERIES_ONLY.has(element)));
444
552
  if (current || (simple && seriesOk)) result.push({ id: option.id, label: option.label, current, record });
445
553
  }
446
554
  return result;
@@ -449,46 +557,49 @@ export function compatibleChartTypes(document, options = {}) {
449
557
  /**
450
558
  * The value a dimension currently has, for pickers: `{ value, scope }` where scope is "slide" when
451
559
  * `slideIndex` names a slide whose own design sets it, else "deck". `value` is undefined when the
452
- * dimension is unset. Catalog dimensions return the catalog id even when the document holds an
453
- * 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.
454
562
  */
455
- export function currentSwitchValue(document, dimension, options = {}) {
563
+ export function currentSwitchValue(presentation, dimension, options = {}) {
456
564
  const idOf = (reference) => (reference && typeof reference === "object" && !Array.isArray(reference) ? reference.id : reference);
457
565
  const design = (key) => {
458
- const slide = options.slideIndex !== undefined ? document.slides?.[options.slideIndex]?.design?.[key] : undefined;
566
+ const slide = options.slideIndex !== undefined ? presentation.slides?.[options.slideIndex]?.design?.[key] : undefined;
459
567
  if (slide !== undefined) return { value: slide, scope: "slide" };
460
- return document.design?.[key] === undefined ? { scope: "deck" } : { value: document.design[key], scope: "deck" };
568
+ return presentation.design?.[key] === undefined ? { scope: "deck" } : { value: presentation.design[key], scope: "deck" };
461
569
  };
462
- if (dimension === "layouts") return { value: document.slides?.[options.slideIndex]?.layout, scope: "slide" };
570
+ if (dimension === "layouts") return { value: presentation.slides?.[options.slideIndex]?.layout, scope: "slide" };
463
571
  if (dimension === "charts") {
464
- const owner = options.path ? splitOpfPath(options.path) : findChartOwner(document, options.slideIndex ?? 0);
465
- return { value: owner ? getValueAtPath(document, [...owner, "chart", "type"]) : undefined, scope: "slide" };
572
+ const owner = options.path ? splitOpfPath(options.path) : findChartOwner(presentation, options.slideIndex ?? 0);
573
+ return { value: owner ? getValueAtPath(presentation, [...owner, "chart", "type"]) : undefined, scope: "slide" };
466
574
  }
467
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
+ }
468
582
  if (dimension === "slide-sizes") {
469
583
  // The size the deck composes at: its own design.dimensions, else the theme's; a {preset} object reads as its preset.
470
584
  // A custom size (inches without a preset) reads as the object itself. Unset reads as undefined (composed as widescreen).
471
- const own = design("dimensions");
472
- let size = own.value;
473
- let scope = own.scope;
585
+ let size = presentation.design?.dimensions;
474
586
  if (size === undefined) {
475
- const themeId = idOf(design("theme").value);
476
- size = themeId ? findCatalogRecord(document, "themes", themeId, {})?.dimensions : undefined;
477
- scope = "deck";
587
+ const theme = presentation.design?.theme;
588
+ size = typeof theme === "string" ? resolveRecord(presentation, "themes", theme, options)?.record?.dimensions : undefined;
478
589
  }
479
- return { value: size && typeof size === "object" && !Array.isArray(size) && Object.keys(size).length === 1 && size.preset ? size.preset : size, scope };
590
+ return { value: size && typeof size === "object" && !Array.isArray(size) && Object.keys(size).length === 1 && size.preset ? size.preset : size, scope: "deck" };
480
591
  }
481
592
  if (dimension === "purposes") {
482
- // A catalog id or goal text reads as itself, a Purpose object as its id (else the object).
483
- const purpose = document.purpose;
593
+ // A catalog reference or goal text reads as itself, a Purpose object as its id (else the object).
594
+ const purpose = presentation.purpose;
484
595
  return { value: purpose && typeof purpose === "object" && !Array.isArray(purpose) ? (purpose.id ?? purpose) : purpose, scope: "deck" };
485
596
  }
486
597
  if (dimension === "socials") {
487
- const host = document[options.owner ?? "speaker"];
598
+ const host = presentation[options.owner ?? "speaker"];
488
599
  const target = Array.isArray(host) ? host[options.index ?? 0] : host;
489
600
  return { value: target?.socials, scope: "deck" };
490
601
  }
491
- if (ROOT_FIELD[dimension]) return { value: document[ROOT_FIELD[dimension]], scope: "deck" };
602
+ if (ROOT_FIELD[dimension]) return { value: presentation[ROOT_FIELD[dimension]], scope: "deck" };
492
603
  if (dimension === "color-schemes") return { ...design("colorScheme"), value: idOf(design("colorScheme").value) };
493
604
  if (dimension === "font-schemes") return { ...design("fontScheme"), value: idOf(design("fontScheme").value) };
494
605
  if (dimension === "themes") return { ...design("theme"), value: idOf(design("theme").value) };