@openpresentation/opf-editor 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/transfer.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { applyJsonPatch, createValuePatch, getValueAtPath } from "./index.js";
2
- import { catalogs, resolveSlideContext } from "@openpresentation/opf";
2
+ import { ENGINE_DEFAULT_COLOR_SCHEME, ENGINE_DEFAULT_FONT_SCHEME, ENGINE_DEFAULT_THEME, copySlides, parseReference, resolveReference } from "@openpresentation/opf";
3
3
  import { collectReservedPresentationIds, remapSlideTreeIds } from "./presentation-ids.js";
4
- import { DEFAULT_FONT_SCHEME } from "@openpresentation/opf/composition";
5
4
  import { checkFormat, firstErrorMessage } from "./checks.js";
5
+ import { mergeCatalogs, prepareSave } from "./catalogs.js";
6
6
  export const MAX_OPF_BYTES = 20 * 1024 * 1024;
7
7
  const clone = (value) => structuredClone(value);
8
8
  export function assertOpf(presentation) {
@@ -66,15 +66,21 @@ export function parseOpfTransfer(text) {
66
66
  return { kind: "slides", value, presentation };
67
67
  return { kind: "selection", value };
68
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
+ */
69
73
  export function serializeOpfTransfer(
70
74
  presentation,
71
- { scope = "presentation", slideIndex = 0, path, format = "pretty" } = {},
75
+ { scope = "presentation", slideIndex = 0, path, format = "pretty", catalogs } = {},
72
76
  ) {
73
77
  let value = presentation;
74
78
  if (scope === "slide") {
75
79
  if (!presentation.slides?.[slideIndex])
76
80
  throw new Error("Select a slide first.");
77
- value = { ...presentation, slides: [presentation.slides[slideIndex]] };
81
+ value = prepareSave({ ...presentation, slides: [presentation.slides[slideIndex]] }, { catalogs }).document;
82
+ } else if (scope === "presentation") {
83
+ value = prepareSave(presentation, { catalogs }).document;
78
84
  } else if (scope === "selection") {
79
85
  value = getValueAtPath(presentation, path);
80
86
  if (value === undefined) throw new Error("Select some content first.");
@@ -82,22 +88,59 @@ export function serializeOpfTransfer(
82
88
  const json = JSON.stringify(value, null, format === "compact" ? 0 : 2);
83
89
  return format === "markdown" ? `\`\`\`json\n${json}\n\`\`\`` : json;
84
90
  }
85
- const catalogKeys = {
86
- layouts: "layout",
87
- themes: "theme",
88
- fontSchemes: "fontScheme",
89
- colorSchemes: "colorScheme",
90
- narratives: "narrative",
91
- languages: "language",
92
- tones: "tone",
93
- audiences: "audience",
94
- purposes: "purpose",
95
- socialPlatforms: "platform",
96
- };
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
+ });
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
+ */
97
140
  export function prepareOpfImport(
98
141
  current,
99
142
  transfer,
100
- { mode = "insert", slideIndex = 0, path } = {},
143
+ { mode = "insert", slideIndex = 0, path, catalogs } = {},
101
144
  ) {
102
145
  if (mode === "selection") {
103
146
  const presentation = applyJsonPatch(
@@ -105,71 +148,21 @@ export function prepareOpfImport(
105
148
  createValuePatch(current, path, transfer.value),
106
149
  );
107
150
  assertOpf(presentation);
108
- return { presentation, slideIndex };
151
+ return { presentation, slideIndex, renamed: [], addedGroups: [] };
109
152
  }
110
153
  if (!transfer.presentation)
111
154
  throw new Error(
112
155
  "This is a content fragment. Choose Replace selected content.",
113
156
  );
114
157
  if (mode === "replace")
115
- return { presentation: assertOpf(clone(transfer.presentation)), slideIndex: 0 };
158
+ return { presentation: assertOpf(clone(transfer.presentation)), slideIndex: 0, renamed: [], addedGroups: [] };
116
159
  if (mode !== "insert") throw new Error("Unknown import action.");
160
+ const list = mergeCatalogs(catalogs);
117
161
  const incoming = clone(transfer.presentation),
118
- presentation = clone(current);
119
- // Freeze the source deck defaults on inserted slides before changing their catalog ids.
120
- incoming.slides = incoming.slides.map((slide, index) => {
121
- // A slide's design cannot set dimensions (a PPTX has one slide size): the inserted slide takes the host deck's.
122
- const { dimensions: _deckSize, ...design } = { ...incoming.design, ...slide.design };
123
- // The theme record the slide resolves to, as core resolves it for every engine (an unknown id falls back to `minimal`).
124
- const theme = resolveSlideContext(incoming, index).resolved.theme;
125
- return {
126
- ...slide,
127
- design: {
128
- ...design,
129
- theme: design.theme ?? "minimal",
130
- colorScheme: design.colorScheme ?? theme.colorScheme ?? "cool-horizon",
131
- fontScheme: design.fontScheme ?? theme.fontScheme ?? DEFAULT_FONT_SCHEME,
132
- background: design.background ?? theme.background ?? "#FFFFFF",
133
- header: design.header ?? false,
134
- footer: design.footer ?? false,
135
- },
136
- };
137
- });
138
- const mappings = {},
139
- assetMap = new Map();
140
- let chartTypes = new Map();
141
- for (const [kind, catalog] of Object.entries(incoming.catalogs ?? {})) {
142
- const existing = presentation.catalogs?.[kind];
143
- if (
144
- catalog.source &&
145
- existing?.source &&
146
- JSON.stringify(catalog.source) !== JSON.stringify(existing.source)
147
- )
148
- throw new Error(
149
- `The ${kind} gallery sources conflict. Open this as a separate presentation instead.`,
150
- );
151
- if (catalog.source && !existing?.source)
152
- throw new Error(
153
- `Resolve ${kind} records into this OPF before inserting, or open it as a presentation.`,
154
- );
155
- const used = new Set(
156
- [...(existing?.records ?? []), ...(catalogs[kind] ?? [])].map(
157
- (record) => record.id,
158
- ),
159
- );
160
- const names = new Map();
161
- for (const record of catalog.records ?? []) {
162
- let id = `import-${record.id}`,
163
- n = 2;
164
- while (used.has(id)) id = `import-${record.id}-${n++}`;
165
- used.add(id);
166
- names.set(record.id, id);
167
- record.id = id;
168
- }
169
- if (catalogKeys[kind]) mappings[catalogKeys[kind]] = names;
170
- if (kind === "chartTypes") chartTypes = names;
171
- }
172
- const assetIds = new Set(Object.keys(presentation.assets ?? {}));
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 ?? {}));
173
166
  for (const id of Object.keys(incoming.assets ?? {})) {
174
167
  let next = id,
175
168
  n = 2;
@@ -187,11 +180,11 @@ export function prepareOpfImport(
187
180
  else if (key === "dataset" && typeof value === "string") usedDatasets.add(value);
188
181
  };
189
182
  collectDatasets(incoming.slides);
190
- const datasetIds = new Set(Object.keys(presentation.datasets ?? {}));
183
+ const datasetIds = new Set(Object.keys(target.datasets ?? {}));
191
184
  for (const id of usedDatasets) {
192
185
  const mine = incoming.datasets && Object.hasOwn(incoming.datasets, id) ? incoming.datasets[id] : undefined;
193
186
  if (mine === undefined) continue;
194
- if (datasetIds.has(id) && JSON.stringify(presentation.datasets[id]) === JSON.stringify(mine)) {
187
+ if (datasetIds.has(id) && JSON.stringify(target.datasets[id]) === JSON.stringify(mine)) {
195
188
  datasetMap.set(id, id);
196
189
  continue;
197
190
  }
@@ -203,70 +196,33 @@ export function prepareOpfImport(
203
196
  }
204
197
  const rewrite = (value, key = "") => {
205
198
  if (Array.isArray(value)) return value.map((child) => rewrite(child, key));
206
- if (value && typeof value === "object") {
207
- const result = Object.fromEntries(
208
- Object.entries(value).map(([childKey, child]) => [
209
- childKey,
210
- rewrite(child, childKey),
211
- ]),
212
- );
213
- if (key === "socials" && mappings.platform) {
214
- for (const [id, child] of Object.entries(result)) {
215
- if (mappings.platform.has(id)) {
216
- delete result[id];
217
- result[mappings.platform.get(id)] = child;
218
- }
219
- }
220
- }
221
- if (mappings[key]?.has(value.id)) result.id = mappings[key].get(value.id);
222
- if (value.data && chartTypes.has(value.type))
223
- result.type = chartTypes.get(value.type);
224
- return result;
225
- }
199
+ if (value && typeof value === "object")
200
+ return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, rewrite(child, childKey)]));
226
201
  if (typeof value === "string") {
227
202
  if (key === "dataset" && datasetMap.has(value)) return datasetMap.get(value);
228
- if (mappings[key]?.has(value)) return mappings[key].get(value);
229
- if (
230
- ["src", "image", "video", "poster"].includes(key) &&
231
- value.startsWith("asset:") &&
232
- assetMap.has(value.slice(6))
233
- )
203
+ if (["src", "image", "video", "poster", "background"].includes(key) && value.startsWith("asset:") && assetMap.has(value.slice(6)))
234
204
  return `asset:${assetMap.get(value.slice(6))}`;
235
205
  }
236
206
  return value;
237
207
  };
238
- const rewritten = rewrite(incoming);
239
- if (incoming.catalogs) {
240
- presentation.catalogs ??= {};
241
- for (const [kind, catalog] of Object.entries(rewritten.catalogs))
242
- presentation.catalogs[kind] = {
243
- ...presentation.catalogs[kind],
244
- ...catalog,
245
- records: [
246
- ...(presentation.catalogs[kind]?.records ?? []),
247
- ...(catalog.records ?? []),
248
- ],
249
- };
250
- }
208
+ incoming.slides = rewrite(incoming.slides);
251
209
  if (incoming.assets) {
252
- presentation.assets ??= {};
253
- for (const [id, asset] of Object.entries(rewritten.assets))
254
- presentation.assets[assetMap.get(id)] =
255
- typeof asset === "string" &&
256
- asset.startsWith("asset:") &&
257
- assetMap.has(asset.slice(6))
258
- ? `asset:${assetMap.get(asset.slice(6))}`
259
- : 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;
260
214
  }
261
215
  for (const [id, next] of datasetMap) {
262
- if (presentation.datasets && Object.hasOwn(presentation.datasets, next)) continue;
263
- presentation.datasets ??= {};
264
- presentation.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]);
265
219
  }
266
- const ids = new Set(collectReservedPresentationIds(presentation));
267
- for (const slide of rewritten.slides) remapSlideTreeIds(slide, ids);
268
- const index = Math.max(0, Math.min(presentation.slides.length, slideIndex + 1));
269
- presentation.slides.splice(index, 0, ...rewritten.slides);
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);
270
226
  assertOpf(presentation);
271
- return { presentation, slideIndex: index };
227
+ return { presentation, slideIndex: copied.slides[0] ?? index, renamed: copied.renamed ?? [], addedGroups: copied.addedGroups ?? [] };
272
228
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.14.2",
3
+ "version": "0.15.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -118,6 +118,10 @@
118
118
  "types": "./dist/background-options.d.ts",
119
119
  "default": "./dist/background-options.js"
120
120
  },
121
+ "./image-options": {
122
+ "types": "./dist/image-options.d.ts",
123
+ "default": "./dist/image-options.js"
124
+ },
121
125
  "./review": {
122
126
  "types": "./dist/review.d.ts",
123
127
  "default": "./dist/review.js"
@@ -253,11 +257,11 @@
253
257
  "@codemirror/state": "6.7.6",
254
258
  "@codemirror/view": "6.43.13",
255
259
  "@lezer/highlight": "1.2.4",
256
- "@openpresentation/opf": "^0.14.0",
260
+ "@openpresentation/opf": "^0.15.1",
257
261
  "jsonc-parser": "3.3.1"
258
262
  },
259
263
  "peerDependencies": {
260
- "@openpresentation/opf-render": "^0.14.0"
264
+ "@openpresentation/opf-render": "^0.15.0"
261
265
  },
262
266
  "peerDependenciesMeta": {
263
267
  "@openpresentation/opf-render": {
@@ -299,8 +303,8 @@
299
303
  "@expo-google-fonts/noto-sans-thai": "0.4.2",
300
304
  "@expo-google-fonts/noto-serif-hebrew": "0.4.2",
301
305
  "@expo-google-fonts/noto-serif-tibetan": "0.4.2",
302
- "@openpresentation/opf-pptx": "^0.14.0",
303
- "@openpresentation/opf-render": "^0.14.0",
306
+ "@openpresentation/opf-pptx": "^0.15.0",
307
+ "@openpresentation/opf-render": "^0.15.0",
304
308
  "axe-core": "4.13.0",
305
309
  "esbuild": "0.28.2",
306
310
  "jszip": "3.10.2",