@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/templates.js CHANGED
@@ -2,12 +2,13 @@
2
2
  // with variables; filling it resolves the variables to a concrete deck (core resolveVariables).
3
3
  // Everything here reads and writes the document through the session, so a fill, a declaration
4
4
  // and a token insertion are validated, undoable JSON Patch edits like any other. Importing this
5
- // module needs no DOM. Core is read from the namespace so an older core still loads this module;
6
- // the functions then throw "templates-unavailable".
7
- import * as core from "@openpresentation/opf";
5
+ // module needs no DOM.
6
+ import { coerceVariableValue, hasContentVariables, listBuiltinVariables, listVariables, resolveVariables, variableDeclarations } from "@openpresentation/opf";
8
7
  import { OPFEditorError, getValueAtPath, opfPathToJsonPointer } from "./index.js";
9
8
 
10
9
  const VARIABLE_ID = /^[a-z][a-z0-9-]*$/;
10
+ // A built-in variable name (core FA-04): `speakers`, or deck/speaker/organization plus one or two dotted segments.
11
+ const BUILTIN_NAME = /^(?:speakers|(?:deck|speaker|organization)(?:.[A-Za-z0-9_-]+){1,2})$/;
11
12
  const KINDS = ["color", "text", "number", "date", "image", "url", "list"];
12
13
 
13
14
  /** The form control each variable kind uses. */
@@ -25,12 +26,6 @@ function fail(code, message, details) {
25
26
  return new OPFEditorError(code, message, details);
26
27
  }
27
28
 
28
- function requireCore() {
29
- if (typeof core.resolveVariables !== "function" || typeof core.listVariables !== "function") {
30
- throw fail("templates-unavailable", "Templates need a core release that ships resolveVariables (@openpresentation/opf after 0.11.4).");
31
- }
32
- }
33
-
34
29
  function checkEditor(editor) {
35
30
  if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function") {
36
31
  throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
@@ -39,19 +34,22 @@ function checkEditor(editor) {
39
34
 
40
35
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
41
36
 
42
- /** True when the core this editor runs on can resolve template variables. */
43
- export function templatesAvailable() {
44
- return typeof core.resolveVariables === "function" && typeof core.listVariables === "function";
45
- }
46
-
47
37
  /** True when the document is marked as a template (`template: true`). */
48
- export function isTemplateDocument(document) {
49
- return isObject(document) && document.template === true;
38
+ export function isTemplateDocument(presentation) {
39
+ return isObject(presentation) && presentation.template === true;
50
40
  }
51
41
 
52
42
  /** True when the document is a template or declares a variable that is not a color. */
53
- export function hasTemplateVariables(document) {
54
- return templatesAvailable() && core.hasContentVariables(document);
43
+ export function hasTemplateVariables(presentation) {
44
+ return hasContentVariables(presentation);
45
+ }
46
+
47
+ /**
48
+ * The presentation's built-in variables (read-only values from its own speaker, organization and deck metadata) with
49
+ * kind, label, current value, whether the presentation has a source value and where each is used.
50
+ */
51
+ export function listBuiltins(presentation) {
52
+ return listBuiltinVariables(presentation);
55
53
  }
56
54
 
57
55
  function humanize(id) {
@@ -80,10 +78,9 @@ export function valueToFieldText(kind, value) {
80
78
  * applies again). Returns `{ ok: true, value }` (value undefined when cleared) or `{ ok: false, message }`.
81
79
  */
82
80
  export function fieldTextToValue(kind, text) {
83
- requireCore();
84
81
  if (typeof text !== "string") return { ok: false, message: "Expected text." };
85
82
  if (text.trim() === "") return { ok: true, value: undefined };
86
- const coerced = core.coerceVariableValue(kind, kind === "text" ? text : text.trim());
83
+ const coerced = coerceVariableValue(kind, kind === "text" ? text : text.trim());
87
84
  return coerced.ok ? { ok: true, value: coerced.value } : { ok: false, message: `${coerced.message}.` };
88
85
  }
89
86
 
@@ -92,10 +89,9 @@ export function fieldTextToValue(kind, text) {
92
89
  * `status` is "filled" (a value was supplied), "default" (the declaration has a value), "unfilled"
93
90
  * (required, no value) or "optional" (not required, no value).
94
91
  */
95
- export function listTemplateFields(document, values = {}) {
96
- requireCore();
97
- const declared = new Map(core.variableDeclarations(document).map((declaration) => [declaration.id, declaration]));
98
- return core.listVariables(document, values).map((info) => {
92
+ export function listTemplateFields(presentation, values = {}) {
93
+ const declared = new Map(variableDeclarations(presentation).map((declaration) => [declaration.id, declaration]));
94
+ return listVariables(presentation, values).map((info) => {
99
95
  const declaration = declared.get(info.id);
100
96
  const supplied = Object.hasOwn(values, info.id) && values[info.id] !== undefined && values[info.id] !== null;
101
97
  const defaultValue = declaration?.value;
@@ -122,12 +118,12 @@ export function listTemplateFields(document, values = {}) {
122
118
  }
123
119
 
124
120
  /** The summary a panel shows: how many required variables still need a value. */
125
- export function templateStatus(document, values = {}) {
126
- const fields = listTemplateFields(document, values);
121
+ export function templateStatus(presentation, values = {}) {
122
+ const fields = listTemplateFields(presentation, values);
127
123
  const required = fields.filter((field) => field.required);
128
124
  const unfilled = fields.filter((field) => field.status === "unfilled").map((field) => field.id);
129
125
  return {
130
- template: isTemplateDocument(document),
126
+ template: isTemplateDocument(presentation),
131
127
  fieldCount: fields.length,
132
128
  requiredCount: required.length,
133
129
  filledRequiredCount: required.length - unfilled.length,
@@ -140,9 +136,8 @@ export function templateStatus(document, values = {}) {
140
136
  * Preview data for the supplied values: the concrete deck with each unfilled variable's example (or its
141
137
  * token when it has none). Never throws for missing values; the diagnostics say what was unfilled.
142
138
  */
143
- export function previewTemplate(document, values = {}) {
144
- requireCore();
145
- return core.resolveVariables(document, values, { examples: true, partial: true });
139
+ export function previewTemplate(presentation, values = {}) {
140
+ return resolveVariables(presentation, values, { examples: true, partial: true });
146
141
  }
147
142
 
148
143
  /**
@@ -151,34 +146,33 @@ export function previewTemplate(document, values = {}) {
151
146
  * document, `subscribe` notifies listeners (a panel redraws on it).
152
147
  */
153
148
  export function createTemplateFill(editor, options = {}) {
154
- requireCore();
155
149
  checkEditor(editor);
156
150
  let values = {};
157
151
  const listeners = new Set();
158
152
  const emit = () => {
159
153
  for (const listener of listeners) listener(api);
160
154
  };
161
- const declared = () => new Set(core.variableDeclarations(editor.document).map((declaration) => declaration.id));
162
- const kindOf = (id) => core.variableDeclarations(editor.document).find((declaration) => declaration.id === id)?.kind;
155
+ const declared = () => new Set(variableDeclarations(editor.presentation).map((declaration) => declaration.id));
156
+ const kindOf = (id) => variableDeclarations(editor.presentation).find((declaration) => declaration.id === id)?.kind;
163
157
  const api = {
164
158
  get values() {
165
159
  return structuredClone(values);
166
160
  },
167
161
  fields() {
168
- return listTemplateFields(editor.document, values);
162
+ return listTemplateFields(editor.presentation, values);
169
163
  },
170
164
  status() {
171
- return templateStatus(editor.document, values);
165
+ return templateStatus(editor.presentation, values);
172
166
  },
173
167
  preview() {
174
- return previewTemplate(editor.document, values);
168
+ return previewTemplate(editor.presentation, values);
175
169
  },
176
170
  set(id, value) {
177
171
  if (!declared().has(id)) throw fail("unknown-variable", `No variable '${id}' is declared.`, { id });
178
172
  if (value === undefined || value === null) {
179
173
  delete values[id];
180
174
  } else {
181
- const coerced = core.coerceVariableValue(kindOf(id), value);
175
+ const coerced = coerceVariableValue(kindOf(id), value);
182
176
  if (!coerced.ok) throw fail("invalid-variable-value", `Value for '${id}': ${coerced.message}.`, { id });
183
177
  values[id] = coerced.value;
184
178
  }
@@ -218,8 +212,8 @@ export function createTemplateFill(editor, options = {}) {
218
212
  * `partial` is set (the unfilled ones stay declared for a later pass).
219
213
  */
220
214
  apply({ partial = false, meta = {} } = {}) {
221
- const document = editor.document;
222
- const result = core.resolveVariables(document, values, { partial, template: false });
215
+ const presentation = editor.presentation;
216
+ const result = resolveVariables(presentation, values, { partial, template: false });
223
217
  const errors = result.diagnostics.filter((entry) => entry.severity === "error");
224
218
  if (errors.length) {
225
219
  const unfilled = errors.some((entry) => entry.code === "variable-unfilled");
@@ -239,27 +233,27 @@ export function createTemplateFill(editor, options = {}) {
239
233
  return api;
240
234
  }
241
235
 
242
- /** The token text for a variable: `{{id}}`, or `{{id|format}}` with a one-off format. */
236
+ /** The token text for a variable or a built-in name: `{{id}}`, or `{{id|format}}` with a one-off format. */
243
237
  export function variableToken(id, format) {
244
- if (!VARIABLE_ID.test(id)) throw fail("invalid-variable-id", "A variable id is lowercase kebab-case: letters, digits and hyphens, starting with a letter.", { id });
238
+ if (!VARIABLE_ID.test(id) && !BUILTIN_NAME.test(id)) throw fail("invalid-variable-id", "A variable id is lowercase kebab-case: letters, digits and hyphens, starting with a letter.", { id });
245
239
  return format ? `{{${id}|${format}}}` : `{{${id}}}`;
246
240
  }
247
241
 
248
242
  /** An unused variable id derived from a label, for a "new variable" form. */
249
- export function suggestVariableId(document, label = "value") {
243
+ export function suggestVariableId(presentation, label = "value") {
250
244
  const base = String(label).normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/^[^a-z]+/, "") || "value";
251
- const taken = new Set(isObject(document?.variables) ? Object.keys(document.variables) : []);
245
+ const taken = new Set(isObject(presentation?.variables) ? Object.keys(presentation.variables) : []);
252
246
  if (!taken.has(base)) return base;
253
247
  for (let n = 2; ; n++) if (!taken.has(`${base}-${n}`)) return `${base}-${n}`;
254
248
  }
255
249
 
256
- function declarationPatches(document, id, declaration) {
250
+ function declarationPatches(presentation, id, declaration) {
257
251
  if (!VARIABLE_ID.test(id)) throw fail("invalid-variable-id", "A variable id is lowercase kebab-case: letters, digits and hyphens, starting with a letter.", { id });
258
252
  const entry = typeof declaration === "string" ? declaration : structuredClone(declaration);
259
253
  if (typeof entry !== "string" && !(isObject(entry) && KINDS.includes(entry.type))) {
260
254
  throw fail("invalid-variable", `A variable declaration is a hex color or an object whose type is one of ${KINDS.join(", ")}.`, { id });
261
255
  }
262
- const variables = document.variables;
256
+ const variables = presentation.variables;
263
257
  if (variables !== undefined && !isObject(variables)) throw fail("invalid-variable", "The document's variables field is not an object.", { id });
264
258
  if (variables && Object.hasOwn(variables, id)) throw fail("variable-exists", `A variable '${id}' is already declared.`, { id });
265
259
  return variables === undefined
@@ -269,22 +263,21 @@ function declarationPatches(document, id, declaration) {
269
263
 
270
264
  /** Declare a variable as one undoable edit. The resulting document must validate (a required variable with no value needs `template: true`). */
271
265
  export function declareVariable(editor, id, declaration, meta = {}) {
272
- requireCore();
273
266
  checkEditor(editor);
274
- return editor.applyPatch(declarationPatches(editor.document, id, declaration), { source: "template-variable", rejectInvalid: true, ...meta });
267
+ return editor.applyPatch(declarationPatches(editor.presentation, id, declaration), { source: "template-variable", rejectInvalid: true, ...meta });
275
268
  }
276
269
 
277
270
  /** Mark the document as a template (or, with `false`, as a normal deck) as one undoable edit. */
278
271
  export function setTemplate(editor, enabled, meta = {}) {
279
272
  checkEditor(editor);
280
- const document = editor.document;
281
- const present = Object.hasOwn(document, "template");
273
+ const presentation = editor.presentation;
274
+ const present = Object.hasOwn(presentation, "template");
282
275
  const patches = enabled
283
276
  ? [{ op: present ? "replace" : "add", path: "/template", value: true }]
284
277
  : present
285
278
  ? [{ op: "remove", path: "/template" }]
286
279
  : [];
287
- if (!patches.length) return { document, patches: [], inversePatches: [], validation: editor.validation };
280
+ if (!patches.length) return { presentation, patches: [], inversePatches: [], validation: editor.validation };
288
281
  return editor.applyPatch(patches, { source: "template-mode", rejectInvalid: true, ...meta });
289
282
  }
290
283
 
@@ -295,18 +288,18 @@ export function setTemplate(editor, enabled, meta = {}) {
295
288
  * the declaration, and both changes commit together.
296
289
  */
297
290
  export function insertVariableToken(editor, path, id, { start, end, runIndex, format, declare, meta = {} } = {}) {
298
- requireCore();
299
291
  checkEditor(editor);
300
- const document = editor.document;
292
+ const presentation = editor.presentation;
301
293
  const token = variableToken(id, format);
302
294
  const patches = [];
303
- const declaredNow = isObject(document.variables) && Object.hasOwn(document.variables, id);
295
+ // A built-in needs no declaration; an unknown built-in path fails the validated edit.
296
+ const declaredNow = BUILTIN_NAME.test(id) || (isObject(presentation.variables) && Object.hasOwn(presentation.variables, id));
304
297
  if (!declaredNow) {
305
298
  if (declare === undefined) throw fail("unknown-variable", `No variable '${id}' is declared. Declare it first, or pass its declaration.`, { id });
306
- patches.push(...declarationPatches(document, id, declare));
299
+ patches.push(...declarationPatches(presentation, id, declare));
307
300
  }
308
301
  const pointer = opfPathToJsonPointer(path);
309
- const current = getValueAtPath(document, path);
302
+ const current = getValueAtPath(presentation, path);
310
303
  const splice = (text) => {
311
304
  const from = start === undefined ? text.length : start;
312
305
  const to = end === undefined ? from : end;
@@ -1,19 +1,22 @@
1
+ import type { Catalog, CopiedRecordRename } from "@openpresentation/opf";
1
2
  export declare const MAX_OPF_BYTES: number;
2
3
  export interface OpfTransfer {
3
4
  kind: "presentation" | "slides" | "selection";
4
5
  value: unknown;
5
- document?: any;
6
+ presentation?: any;
6
7
  }
7
- export declare function assertOpf(document: any): any;
8
+ export declare function assertOpf(presentation: any): any;
8
9
  export declare function unwrapOpf(value: any): any;
9
10
  export declare function parseOpfTransfer(text: string): OpfTransfer;
10
11
  export declare function serializeOpfTransfer(
11
- document: any,
12
+ presentation: any,
12
13
  options?: {
13
14
  scope?: "presentation" | "slide" | "selection";
14
15
  slideIndex?: number;
15
16
  path?: string;
16
17
  format?: "pretty" | "compact" | "markdown";
18
+ /** Host catalogs: a presentation or slide is embedded (core `embed`) so it renders the same where none is registered. */
19
+ catalogs?: readonly Catalog[];
17
20
  },
18
21
  ): string;
19
22
  export declare function prepareOpfImport(
@@ -23,5 +26,13 @@ export declare function prepareOpfImport(
23
26
  mode?: "insert" | "replace" | "selection";
24
27
  slideIndex?: number;
25
28
  path?: string;
29
+ /** Host catalogs, for resolving and copying the inserted slides' records (core `copySlides`). */
30
+ catalogs?: readonly Catalog[];
26
31
  },
27
- ): { document: any; slideIndex: number };
32
+ ): {
33
+ presentation: any;
34
+ slideIndex: number;
35
+ /** Records that did not keep their reference while inserting: tell the user. */
36
+ renamed: CopiedRecordRename[];
37
+ addedGroups: { name: string; source?: string }[];
38
+ };
package/dist/transfer.js CHANGED
@@ -1,20 +1,16 @@
1
- import {
2
- applyJsonPatch,
3
- createValuePatch,
4
- getValueAtPath,
5
- validateOpfDocument,
6
- } from "./index.js";
7
- import { catalogs } from "@openpresentation/opf";
1
+ import { applyJsonPatch, createValuePatch, getValueAtPath } from "./index.js";
2
+ import { ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_THEME, copySlides, parseReference, resolveReference } from "@openpresentation/opf";
8
3
  import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
9
- import { DEFAULT_FONT_SCHEME } from "./font-defaults.js";
4
+ import { checkFormat, firstErrorMessage } from "./checks.js";
5
+ import { mergeCatalogs, prepareSave } from "./catalogs.js";
10
6
  export const MAX_OPF_BYTES = 20 * 1024 * 1024;
11
7
  const clone = (value) => structuredClone(value);
12
- export function assertOpf(document) {
13
- bounded(document);
14
- const result = validateOpfDocument(document);
8
+ export function assertOpf(presentation) {
9
+ bounded(presentation);
10
+ const result = checkFormat(presentation);
15
11
  if (!result.valid)
16
- throw new Error(result.errors[0]?.message ?? "Invalid OPF document.");
17
- return document;
12
+ throw new Error(firstErrorMessage(result, "Invalid OPF document."));
13
+ return presentation;
18
14
  }
19
15
  function bounded(value, depth = 0, budget = { remaining: 250000 }) {
20
16
  if (depth > 64 || --budget.remaining < 0)
@@ -36,7 +32,7 @@ export function unwrapOpf(value, depth = 0) {
36
32
  if (snippet) return unwrapOpf(snippet.value ?? JSON.parse(snippet.source), depth + 1);
37
33
  }
38
34
  if (value?.opf && typeof value.opf === "object") return unwrapOpf(value.opf, depth + 1);
39
- if (value?.document) return unwrapOpf(value.document, depth + 1);
35
+ if (value?.presentation) return unwrapOpf(value.presentation, depth + 1);
40
36
  return value;
41
37
  }
42
38
  export function parseOpfTransfer(text) {
@@ -63,123 +59,110 @@ export function parseOpfTransfer(text) {
63
59
  bounded(value);
64
60
  if (value?.slides) {
65
61
  assertOpf(value);
66
- return { kind: "presentation", value, document: clone(value) };
62
+ return { kind: "presentation", value, presentation: clone(value) };
67
63
  }
68
- const document = { slides: Array.isArray(value) ? value : [value] };
69
- if (validateOpfDocument(document).valid)
70
- return { kind: "slides", value, document };
64
+ const presentation = { slides: Array.isArray(value) ? value : [value] };
65
+ if (checkFormat(presentation).valid)
66
+ return { kind: "slides", value, presentation };
71
67
  return { kind: "selection", value };
72
68
  }
69
+ /**
70
+ * The OPF text to copy or save. A presentation or a slide is self-contained: core's `embed` adds every catalog record it
71
+ * references (from `catalogs`, the host's registered list), so it renders the same where no catalog is registered.
72
+ */
73
73
  export function serializeOpfTransfer(
74
- document,
75
- { scope = "presentation", slideIndex = 0, path, format = "pretty" } = {},
74
+ presentation,
75
+ { scope = "presentation", slideIndex = 0, path, format = "pretty", catalogs } = {},
76
76
  ) {
77
- let value = document;
77
+ let value = presentation;
78
78
  if (scope === "slide") {
79
- if (!document.slides?.[slideIndex])
79
+ if (!presentation.slides?.[slideIndex])
80
80
  throw new Error("Select a slide first.");
81
- value = { ...document, slides: [document.slides[slideIndex]] };
81
+ value = prepareSave({ ...presentation, slides: [presentation.slides[slideIndex]] }, { catalogs }).document;
82
+ } else if (scope === "presentation") {
83
+ value = prepareSave(presentation, { catalogs }).document;
82
84
  } else if (scope === "selection") {
83
- value = getValueAtPath(document, path);
85
+ value = getValueAtPath(presentation, path);
84
86
  if (value === undefined) throw new Error("Select some content first.");
85
87
  }
86
88
  const json = JSON.stringify(value, null, format === "compact" ? 0 : 2);
87
89
  return format === "markdown" ? `\`\`\`json\n${json}\n\`\`\`` : json;
88
90
  }
89
- const catalogKeys = {
90
- layouts: "layout",
91
- themes: "theme",
92
- fontSchemes: "fontScheme",
93
- colorSchemes: "colorScheme",
94
- narratives: "narrative",
95
- languages: "language",
96
- tones: "tone",
97
- audiences: "audience",
98
- purposes: "purpose",
99
- socialPlatforms: "platform",
100
- };
101
- function themeFor(document, reference) {
102
- const id = typeof reference === "string" ? reference : reference?.id;
103
- const record =
104
- document.catalogs?.themes?.records?.find((item) => item.id === id) ??
105
- catalogs.themes.find((item) => item.id === id);
106
- return { ...record, ...(typeof reference === "object" ? reference : {}) };
91
+ const RESERVED_GROUPS = new Set(["default", "custom"]);
92
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
93
+ // A reference read out of a record of `group` keeps naming that group's record when it is written on a slide.
94
+ function qualified(value, group) {
95
+ const qualify = (reference) => {
96
+ const parsed = typeof reference === "string" ? parseReference(reference) : undefined;
97
+ return !parsed || parsed.group || RESERVED_GROUPS.has(group) ? reference : `${group}:${parsed.id}`;
98
+ };
99
+ if (typeof value === "string") return qualify(value);
100
+ if (isObject(value) && typeof value.id === "string") return { ...clone(value), id: qualify(value.id) };
101
+ return value === undefined ? undefined : clone(value);
102
+ }
103
+
104
+ /**
105
+ * Freeze the source deck's look on its slides before they move: each slide carries its colour scheme, font scheme, background
106
+ * and header/footer, so the target deck's design does not restyle it. A value the source takes from its theme is written with
107
+ * the theme's catalog group; one it takes from the engine default is written only when the target would not use the engine
108
+ * default too (core's `ENGINE_DEFAULT_*`, never a catalog id).
109
+ */
110
+ function frozenSlides(incoming, current, catalogs) {
111
+ const target = isObject(current.design) ? current.design : {};
112
+ const targetDefault = (key) => target[key] === undefined && target.theme === undefined;
113
+ return incoming.slides.map((slide) => {
114
+ // A slide's design cannot set dimensions (a PPTX has one slide size): the inserted slide takes the host deck's.
115
+ const { dimensions: _deckSize, ...design } = { ...incoming.design, ...slide.design };
116
+ const theme = typeof design.theme === "string" ? resolveReference(incoming, "themes", design.theme, { catalogs }) : undefined;
117
+ const record = isObject(theme?.record) ? theme.record : {};
118
+ const next = { ...design };
119
+ const freeze = (key, engineDefault) => {
120
+ if (design[key] !== undefined) return;
121
+ if (record[key] !== undefined) next[key] = qualified(record[key], theme.group);
122
+ else if (!targetDefault(key)) next[key] = clone(engineDefault);
123
+ };
124
+ freeze("colorScheme", ENGINE_DEFAULT_COLOR_SCHEME);
125
+ freeze("fontScheme", ENGINE_DEFAULT_FONT_SCHEME);
126
+ freeze("background", ENGINE_DEFAULT_THEME.background);
127
+ next.header = design.header ?? false;
128
+ next.footer = design.footer ?? false;
129
+ return { ...slide, design: next };
130
+ });
107
131
  }
132
+
133
+ /**
134
+ * Apply a parsed transfer to the current document. `mode` "selection" replaces the selected value, "replace" opens the transfer
135
+ * as the presentation, and "insert" copies its slides in after `slideIndex` with core's `copySlides`: catalog groups match by
136
+ * `source`, identical records are reused, a differing record is renamed, and `renamed` / `addedGroups` say what changed so the
137
+ * host can tell the user. Assets and shared datasets come along under free ids. `catalogs` are the host's registered catalogs.
138
+ * Returns `{ presentation, slideIndex, renamed, addedGroups }`.
139
+ */
108
140
  export function prepareOpfImport(
109
141
  current,
110
142
  transfer,
111
- { mode = "insert", slideIndex = 0, path } = {},
143
+ { mode = "insert", slideIndex = 0, path, catalogs } = {},
112
144
  ) {
113
145
  if (mode === "selection") {
114
- const document = applyJsonPatch(
146
+ const presentation = applyJsonPatch(
115
147
  current,
116
148
  createValuePatch(current, path, transfer.value),
117
149
  );
118
- assertOpf(document);
119
- return { document, slideIndex };
150
+ assertOpf(presentation);
151
+ return { presentation, slideIndex, renamed: [], addedGroups: [] };
120
152
  }
121
- if (!transfer.document)
153
+ if (!transfer.presentation)
122
154
  throw new Error(
123
155
  "This is a content fragment. Choose Replace selected content.",
124
156
  );
125
157
  if (mode === "replace")
126
- return { document: assertOpf(clone(transfer.document)), slideIndex: 0 };
158
+ return { presentation: assertOpf(clone(transfer.presentation)), slideIndex: 0, renamed: [], addedGroups: [] };
127
159
  if (mode !== "insert") throw new Error("Unknown import action.");
128
- const incoming = clone(transfer.document),
129
- document = clone(current);
130
- // Freeze the source deck defaults on inserted slides before changing their catalog ids.
131
- incoming.slides = incoming.slides.map((slide) => {
132
- const design = { ...incoming.design, ...slide.design };
133
- const theme = themeFor(incoming, design.theme ?? "minimal");
134
- return {
135
- ...slide,
136
- design: {
137
- ...design,
138
- theme: design.theme ?? "minimal",
139
- colorScheme: design.colorScheme ?? theme.colorScheme ?? "cool-horizon",
140
- fontScheme: design.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
141
- dimensions: design.dimensions ?? theme.dimensions ?? "widescreen",
142
- background: design.background ?? theme.background ?? "#FFFFFF",
143
- header: design.header ?? false,
144
- footer: design.footer ?? false,
145
- },
146
- };
147
- });
148
- const mappings = {},
149
- assetMap = new Map();
150
- let chartTypes = new Map();
151
- for (const [kind, catalog] of Object.entries(incoming.catalogs ?? {})) {
152
- const existing = document.catalogs?.[kind];
153
- if (
154
- catalog.source &&
155
- existing?.source &&
156
- JSON.stringify(catalog.source) !== JSON.stringify(existing.source)
157
- )
158
- throw new Error(
159
- `The ${kind} gallery sources conflict. Open this as a separate presentation instead.`,
160
- );
161
- if (catalog.source && !existing?.source)
162
- throw new Error(
163
- `Resolve ${kind} records into this OPF before inserting, or open it as a presentation.`,
164
- );
165
- const used = new Set(
166
- [...(existing?.records ?? []), ...(catalogs[kind] ?? [])].map(
167
- (record) => record.id,
168
- ),
169
- );
170
- const names = new Map();
171
- for (const record of catalog.records ?? []) {
172
- let id = `import-${record.id}`,
173
- n = 2;
174
- while (used.has(id)) id = `import-${record.id}-${n++}`;
175
- used.add(id);
176
- names.set(record.id, id);
177
- record.id = id;
178
- }
179
- if (catalogKeys[kind]) mappings[catalogKeys[kind]] = names;
180
- if (kind === "chartTypes") chartTypes = names;
181
- }
182
- const assetIds = new Set(Object.keys(document.assets ?? {}));
160
+ const list = mergeCatalogs(catalogs);
161
+ const incoming = clone(transfer.presentation),
162
+ target = clone(current);
163
+ incoming.slides = frozenSlides(incoming, current, list);
164
+ const assetMap = new Map();
165
+ const assetIds = new Set(Object.keys(target.assets ?? {}));
183
166
  for (const id of Object.keys(incoming.assets ?? {})) {
184
167
  let next = id,
185
168
  n = 2;
@@ -197,11 +180,11 @@ export function prepareOpfImport(
197
180
  else if (key === "dataset" && typeof value === "string") usedDatasets.add(value);
198
181
  };
199
182
  collectDatasets(incoming.slides);
200
- const datasetIds = new Set(Object.keys(document.datasets ?? {}));
183
+ const datasetIds = new Set(Object.keys(target.datasets ?? {}));
201
184
  for (const id of usedDatasets) {
202
185
  const mine = incoming.datasets && Object.hasOwn(incoming.datasets, id) ? incoming.datasets[id] : undefined;
203
186
  if (mine === undefined) continue;
204
- if (datasetIds.has(id) && JSON.stringify(document.datasets[id]) === JSON.stringify(mine)) {
187
+ if (datasetIds.has(id) && JSON.stringify(target.datasets[id]) === JSON.stringify(mine)) {
205
188
  datasetMap.set(id, id);
206
189
  continue;
207
190
  }
@@ -213,70 +196,33 @@ export function prepareOpfImport(
213
196
  }
214
197
  const rewrite = (value, key = "") => {
215
198
  if (Array.isArray(value)) return value.map((child) => rewrite(child, key));
216
- if (value && typeof value === "object") {
217
- const result = Object.fromEntries(
218
- Object.entries(value).map(([childKey, child]) => [
219
- childKey,
220
- rewrite(child, childKey),
221
- ]),
222
- );
223
- if (key === "socials" && mappings.platform) {
224
- for (const [id, child] of Object.entries(result)) {
225
- if (mappings.platform.has(id)) {
226
- delete result[id];
227
- result[mappings.platform.get(id)] = child;
228
- }
229
- }
230
- }
231
- if (mappings[key]?.has(value.id)) result.id = mappings[key].get(value.id);
232
- if (value.data && chartTypes.has(value.type))
233
- result.type = chartTypes.get(value.type);
234
- return result;
235
- }
199
+ if (value && typeof value === "object")
200
+ return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, rewrite(child, childKey)]));
236
201
  if (typeof value === "string") {
237
202
  if (key === "dataset" && datasetMap.has(value)) return datasetMap.get(value);
238
- if (mappings[key]?.has(value)) return mappings[key].get(value);
239
- if (
240
- ["src", "image", "video", "poster"].includes(key) &&
241
- value.startsWith("asset:") &&
242
- assetMap.has(value.slice(6))
243
- )
203
+ if (["src", "image", "video", "poster", "background"].includes(key) && value.startsWith("asset:") && assetMap.has(value.slice(6)))
244
204
  return `asset:${assetMap.get(value.slice(6))}`;
245
205
  }
246
206
  return value;
247
207
  };
248
- const rewritten = rewrite(incoming);
249
- if (incoming.catalogs) {
250
- document.catalogs ??= {};
251
- for (const [kind, catalog] of Object.entries(rewritten.catalogs))
252
- document.catalogs[kind] = {
253
- ...document.catalogs[kind],
254
- ...catalog,
255
- records: [
256
- ...(document.catalogs[kind]?.records ?? []),
257
- ...(catalog.records ?? []),
258
- ],
259
- };
260
- }
208
+ incoming.slides = rewrite(incoming.slides);
261
209
  if (incoming.assets) {
262
- document.assets ??= {};
263
- for (const [id, asset] of Object.entries(rewritten.assets))
264
- document.assets[assetMap.get(id)] =
265
- typeof asset === "string" &&
266
- asset.startsWith("asset:") &&
267
- assetMap.has(asset.slice(6))
268
- ? `asset:${assetMap.get(asset.slice(6))}`
269
- : asset;
210
+ target.assets ??= {};
211
+ for (const [id, asset] of Object.entries(rewrite(incoming.assets)))
212
+ target.assets[assetMap.get(id)] =
213
+ typeof asset === "string" && asset.startsWith("asset:") && assetMap.has(asset.slice(6)) ? `asset:${assetMap.get(asset.slice(6))}` : asset;
270
214
  }
271
215
  for (const [id, next] of datasetMap) {
272
- if (document.datasets && Object.hasOwn(document.datasets, next)) continue;
273
- document.datasets ??= {};
274
- document.datasets[next] = clone(incoming.datasets[id]);
216
+ if (target.datasets && Object.hasOwn(target.datasets, next)) continue;
217
+ target.datasets ??= {};
218
+ target.datasets[next] = clone(incoming.datasets[id]);
275
219
  }
276
- const ids = new Set(collectReservedPresentationIds(document));
277
- for (const slide of rewritten.slides) remapSlideTreeIds(slide, ids);
278
- const index = Math.max(0, Math.min(document.slides.length, slideIndex + 1));
279
- document.slides.splice(index, 0, ...rewritten.slides);
280
- assertOpf(document);
281
- return { document, slideIndex: index };
220
+ const index = Math.max(0, Math.min(target.slides.length, slideIndex + 1));
221
+ // Core copies the catalog records the slides use and rewrites their references (FA-20).
222
+ const copied = copySlides(incoming, target, incoming.slides.map((_, at) => at), { catalogs: list, at: index });
223
+ const presentation = clone(copied.document);
224
+ const ids = new Set(collectReservedPresentationIds({ ...presentation, slides: presentation.slides.filter((_, at) => !copied.slides.includes(at)) }));
225
+ for (const at of copied.slides) remapSlideTreeIds(presentation.slides[at], ids);
226
+ assertOpf(presentation);
227
+ return { presentation, slideIndex: copied.slides[0] ?? index, renamed: copied.renamed ?? [], addedGroups: copied.addedGroups ?? [] };
282
228
  }