@openpresentation/opf-editor 0.11.3 → 0.14.2

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