@openpresentation/opf-editor 0.14.2 → 0.16.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/README.md +93 -35
- package/dist/assets.js +1 -1
- package/dist/background-options.d.ts +10 -3
- package/dist/background-options.js +38 -13
- package/dist/canvas.d.ts +8 -1
- package/dist/canvas.js +25 -10
- package/dist/catalog-control.js +21 -0
- package/dist/catalogs.d.ts +48 -0
- package/dist/catalogs.js +217 -0
- package/dist/checks.js +9 -7
- package/dist/content-actions.d.ts +2 -2
- package/dist/content-actions.js +3 -3
- package/dist/content-controls.js +9 -9
- package/dist/design-controls.d.ts +12 -5
- package/dist/design-controls.js +286 -82
- package/dist/design-options.d.ts +4 -4
- package/dist/design-options.js +6 -26
- package/dist/export.d.ts +8 -1
- package/dist/export.js +14 -4
- package/dist/find-replace.js +1 -0
- package/dist/font-gate.js +0 -11
- package/dist/galleries.js +32 -67
- package/dist/image-crop.js +8 -9
- package/dist/image-options.d.ts +45 -0
- package/dist/image-options.js +195 -0
- package/dist/index.d.ts +26 -8
- package/dist/index.js +107 -51
- package/dist/json-editor.js +2 -2
- package/dist/json-menu.js +2 -2
- package/dist/json-options.d.ts +3 -3
- package/dist/json-options.js +37 -47
- package/dist/layout-handles.js +13 -0
- package/dist/layout-placeholders.js +15 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +6 -2
- package/dist/review-panel.js +8 -2
- package/dist/schema-inspector.js +1 -1
- package/dist/schema.js +1 -1
- package/dist/slide-manager.js +4 -2
- package/dist/slides.d.ts +1 -1
- package/dist/slides.js +6 -5
- package/dist/svelte.d.ts +1 -0
- package/dist/svelte.js +4 -4
- package/dist/switches.d.ts +31 -16
- package/dist/switches.js +194 -92
- package/dist/table-options.d.ts +6 -1
- package/dist/table-options.js +43 -0
- package/dist/transfer.d.ts +12 -1
- package/dist/transfer.js +87 -131
- package/package.json +22 -9
package/dist/switches.js
CHANGED
|
@@ -2,14 +2,22 @@
|
|
|
2
2
|
// dimension that turns "switch this dimension to X" into a validated JSON Patch, and applies
|
|
3
3
|
// it to an editor session as one undoable transaction. The preview recomposes and the PPTX
|
|
4
4
|
// export follows from the same document, so no dimension needs a special refresh path.
|
|
5
|
-
|
|
5
|
+
//
|
|
6
|
+
// OPF 0.15 (FA-23): the catalog-backed dimensions (layouts, themes, colour and font schemes, narratives, audiences, purposes,
|
|
7
|
+
// tones) resolve through core with the host's registered catalogs (`options.catalogs`, `Catalog[]`) and write the reference core
|
|
8
|
+
// gives (`id` or `name:id`). Languages, chart types and social platforms are engine vocabularies, checked against core's
|
|
9
|
+
// tables; their labels come from the host's display metadata (`options.vocabularies`, the shape of `catalogDisplay` from
|
|
10
|
+
// `@openpresentation/opf/catalog`), never from data the editor ships.
|
|
11
|
+
import { CHART_TYPES, LANGUAGES, SOCIAL_PLATFORMS, isXYChartType, parseReference, resolveChartData, resolveReference } from "@openpresentation/opf";
|
|
6
12
|
import { chartOptionTarget } from "@openpresentation/opf/composition";
|
|
7
|
-
import {
|
|
13
|
+
import { createValuePatch, applyJsonPatch, getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
|
|
8
14
|
import { createContentBlock, prepareBlockReplace } from "./blocks.js";
|
|
9
15
|
import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
|
|
10
|
-
import { populateLayoutPlaceholders } from "./layout-placeholders.js";
|
|
16
|
+
import { layoutSlotTypes, populateLayoutPlaceholders } from "./layout-placeholders.js";
|
|
11
17
|
import { blockConversionTargets, prepareBlockConversion } from "./block-convert.js";
|
|
12
18
|
import { checkFormat } from "./checks.js";
|
|
19
|
+
import { listCatalogRecords, mergeCatalogs } from "./catalogs.js";
|
|
20
|
+
import { IMAGE_TREATMENT_FIELDS, imageTreatmentPatches, readImageTreatments } from "./image-options.js";
|
|
13
21
|
|
|
14
22
|
/** The schema's DimensionPreset values (RR-41, FA-13: with the social-feed ratios), the values of the slide-sizes switch. */
|
|
15
23
|
export const SLIDE_SIZE_PRESETS = Object.freeze(["16:9", "4:3", "16:10", "1:1", "4:5", "9:16", "letter", "a4", "widescreen", "standard"]);
|
|
@@ -52,20 +60,19 @@ export const SWITCH_DIMENSIONS = Object.freeze([
|
|
|
52
60
|
"purposes",
|
|
53
61
|
]);
|
|
54
62
|
|
|
55
|
-
// Catalog kind behind each catalog-backed dimension.
|
|
63
|
+
// Catalog kind behind each catalog-backed dimension (core's content kinds).
|
|
56
64
|
const CATALOG_KIND = Object.freeze({
|
|
57
65
|
layouts: "layouts",
|
|
58
66
|
"color-schemes": "colorSchemes",
|
|
59
67
|
"font-schemes": "fontSchemes",
|
|
60
|
-
languages: "languages",
|
|
61
68
|
narratives: "narratives",
|
|
62
69
|
themes: "themes",
|
|
63
70
|
audiences: "audiences",
|
|
64
71
|
tones: "tones",
|
|
65
|
-
socials: "socialPlatforms",
|
|
66
|
-
charts: "chartTypes",
|
|
67
72
|
purposes: "purposes",
|
|
68
73
|
});
|
|
74
|
+
// Display metadata key (`options.vocabularies`) behind each engine-vocabulary dimension.
|
|
75
|
+
const VOCABULARY = Object.freeze({ languages: "languages", charts: "chartTypes", socials: "socialPlatforms" });
|
|
69
76
|
// Top-level document field for the simple metadata dimensions.
|
|
70
77
|
const ROOT_FIELD = Object.freeze({ languages: "language", narratives: "narrative", tones: "tone", audiences: "audience" });
|
|
71
78
|
// `design` keys for the design dimensions. A key set to `null` in the value is removed.
|
|
@@ -74,49 +81,80 @@ const DESIGN_KEYS = Object.freeze({
|
|
|
74
81
|
"font-schemes": ["fontScheme"],
|
|
75
82
|
backgrounds: ["background"],
|
|
76
83
|
"headers-footers": ["header", "footer"],
|
|
77
|
-
"image-treatments": ["slideImage", "imageFill"],
|
|
78
84
|
});
|
|
79
85
|
const REGION = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
|
|
80
86
|
const BLOCK_KINDS = ["text", "list", "chart", "table", "metric", "quote", "code", "timeline", "group", "image", "video"];
|
|
87
|
+
const RESERVED_GROUPS = new Set(["default", "custom"]);
|
|
88
|
+
const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
81
89
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
const catalogsOf = (options) => mergeCatalogs(options.catalogs);
|
|
91
|
+
|
|
92
|
+
// A reference resolved the one way every engine resolves it (core's `resolveReference`, with the registered catalogs).
|
|
93
|
+
function resolveRecord(presentation, kind, reference, options) {
|
|
94
|
+
if (typeof reference !== "string" || !reference) return undefined;
|
|
95
|
+
return resolveReference(presentation, kind, reference, { catalogs: catalogsOf(options) });
|
|
85
96
|
}
|
|
86
97
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
/**
|
|
99
|
+
* A record the caller supplies with the switch (a gallery item carries its own): it is embedded in the same transaction, in
|
|
100
|
+
* the group whose `source` is `options.recordSource` (`default` when the document has no default group yet), else under `custom`,
|
|
101
|
+
* with no `$schema`, `id` or `x-*` display fields. Returns the patches and the reference to write.
|
|
102
|
+
*/
|
|
103
|
+
function suppliedRecord(presentation, kind, options) {
|
|
104
|
+
const record = options.record;
|
|
105
|
+
if (!record) return undefined;
|
|
106
|
+
const id = record.id;
|
|
107
|
+
const groups = isObject(presentation.catalogs) ? presentation.catalogs : undefined;
|
|
108
|
+
let group = "custom";
|
|
109
|
+
if (typeof options.recordSource === "string" && options.recordSource) {
|
|
110
|
+
const named = groups && Object.entries(groups).find(([, value]) => isObject(value) && value.source === options.recordSource);
|
|
111
|
+
if (named) group = named[0];
|
|
112
|
+
else if (!groups || groups.default === undefined) group = "default";
|
|
113
|
+
}
|
|
114
|
+
const reference = RESERVED_GROUPS.has(group) ? id : `${group}:${id}`;
|
|
115
|
+
const { $schema: _schema, id: _id, ...fields } = record;
|
|
116
|
+
const value = Object.fromEntries(Object.entries(structuredClone(fields)).filter(([key]) => !key.startsWith("x-")));
|
|
117
|
+
if (isObject(groups?.[group]?.[kind]) && Object.hasOwn(groups[group][kind], id)) return { patches: [], reference, record: groups[group][kind][id], group };
|
|
118
|
+
const path = ["catalogs", group, kind, id];
|
|
119
|
+
let patches;
|
|
120
|
+
if (!groups) patches = [{ op: "add", path: "/catalogs", value: { [group]: { ...(group === "default" ? { source: options.recordSource } : {}), [kind]: { [id]: value } } } }];
|
|
121
|
+
else if (!isObject(groups[group])) patches = [{ op: "add", path: opfPathToJsonPointer(path.slice(0, 2)), value: { ...(group === "default" ? { source: options.recordSource } : {}), [kind]: { [id]: value } } }];
|
|
122
|
+
else if (!isObject(groups[group][kind])) patches = [{ op: "add", path: opfPathToJsonPointer(path.slice(0, 3)), value: { [id]: value } }];
|
|
123
|
+
else patches = [{ op: "add", path: opfPathToJsonPointer(path), value }];
|
|
124
|
+
return { patches, reference, record: value, group };
|
|
96
125
|
}
|
|
97
126
|
|
|
98
|
-
|
|
127
|
+
// The record a switched reference names: a supplied record, else core's resolution. Unknown references are refused.
|
|
128
|
+
function requireRecord(presentation, dimension, reference, options) {
|
|
99
129
|
const kind = CATALOG_KIND[dimension];
|
|
100
|
-
if (typeof
|
|
101
|
-
throw fail("invalid-catalog-id", `Switch ${dimension} to a non-empty catalog
|
|
102
|
-
const
|
|
103
|
-
if (!
|
|
104
|
-
return
|
|
130
|
+
if (typeof reference !== "string" || reference.length === 0)
|
|
131
|
+
throw fail("invalid-catalog-id", `Switch ${dimension} to a non-empty catalog reference.`, { dimension, id: reference });
|
|
132
|
+
const resolved = resolveRecord(presentation, kind, reference, options);
|
|
133
|
+
if (!resolved) throw fail("unknown-catalog-id", `Unknown ${kind} catalog reference: ${reference}. Register the catalog that holds it, or add the record to the document.`, { dimension, catalogKind: kind, id: reference });
|
|
134
|
+
return resolved;
|
|
105
135
|
}
|
|
106
136
|
|
|
107
|
-
// A
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
137
|
+
// A switch to a supplied record writes the reference it was embedded under; otherwise the reference must resolve.
|
|
138
|
+
function catalogChoice(presentation, dimension, reference, options) {
|
|
139
|
+
const supplied = options.record && parseReference(reference)?.id === options.record.id ? suppliedRecord(presentation, CATALOG_KIND[dimension], options) : undefined;
|
|
140
|
+
if (supplied) return { patches: supplied.patches, reference: supplied.reference, record: supplied.record, group: supplied.group };
|
|
141
|
+
const resolved = requireRecord(presentation, dimension, reference, options);
|
|
142
|
+
return { patches: [], reference, record: resolved.record, group: resolved.group };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A reference copied out of a record of group `group` into the document's design keeps naming that group's record.
|
|
146
|
+
function qualify(reference, group) {
|
|
147
|
+
if (typeof reference !== "string") return reference;
|
|
148
|
+
const parsed = parseReference(reference);
|
|
149
|
+
if (!parsed || parsed.group || RESERVED_GROUPS.has(group)) return reference;
|
|
150
|
+
return `${group}:${parsed.id}`;
|
|
151
|
+
}
|
|
152
|
+
function qualifiedValue(key, value, group) {
|
|
153
|
+
if (key === "colorScheme" || key === "fontScheme") {
|
|
154
|
+
if (typeof value === "string") return qualify(value, group);
|
|
155
|
+
if (isObject(value) && typeof value.id === "string") return { ...structuredClone(value), id: qualify(value.id, group) };
|
|
156
|
+
}
|
|
157
|
+
return structuredClone(value);
|
|
120
158
|
}
|
|
121
159
|
|
|
122
160
|
function slideAt(presentation, slideIndex, what = "this switch") {
|
|
@@ -158,7 +196,8 @@ function socialsPatches(presentation, value, options) {
|
|
|
158
196
|
if (!["speaker", "organization"].includes(owner)) throw fail("invalid-switch-value", "Socials owner must be 'speaker' or 'organization'.", { owner });
|
|
159
197
|
if (!value || typeof value !== "object" || typeof value.platform !== "string" || typeof value.handle !== "string" || !value.handle)
|
|
160
198
|
throw fail("invalid-switch-value", "Switch socials to { platform, handle } with a non-empty handle.", { value });
|
|
161
|
-
|
|
199
|
+
if (!Object.hasOwn(SOCIAL_PLATFORMS, value.platform))
|
|
200
|
+
throw fail("unknown-catalog-id", `Unknown social platform: ${value.platform}. Use one of ${Object.keys(SOCIAL_PLATFORMS).join(", ")}.`, { dimension: "socials", id: value.platform });
|
|
162
201
|
const host = presentation[owner];
|
|
163
202
|
const index = Array.isArray(host) ? (options.index ?? 0) : undefined;
|
|
164
203
|
const target = Array.isArray(host) ? host[index] : host;
|
|
@@ -166,12 +205,17 @@ function socialsPatches(presentation, value, options) {
|
|
|
166
205
|
throw fail("missing-owner", `Add the ${owner} to the document before setting their socials.`, { owner });
|
|
167
206
|
const base = Array.isArray(host) ? [owner, String(index)] : [owner];
|
|
168
207
|
const socials = target.socials;
|
|
169
|
-
const patches =
|
|
208
|
+
const patches = [];
|
|
170
209
|
if (!socials || typeof socials !== "object") return [...patches, { op: "add", path: opfPathToJsonPointer([...base, "socials"]), value: { [value.platform]: value.handle } }];
|
|
171
210
|
if (socials[value.platform] === value.handle) return patches;
|
|
172
211
|
return [...patches, { op: Object.hasOwn(socials, value.platform) ? "replace" : "add", path: opfPathToJsonPointer([...base, "socials", value.platform]), value: value.handle }];
|
|
173
212
|
}
|
|
174
213
|
|
|
214
|
+
// Purposes take free goal text, so only a supplied record is embedded; any other string is written as it is.
|
|
215
|
+
function catalogChoiceIfSupplied(presentation, dimension, value, options) {
|
|
216
|
+
return options.record ? catalogChoice(presentation, dimension, value, options) : undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
175
219
|
function blockValue(value, options) {
|
|
176
220
|
if (typeof value === "string") {
|
|
177
221
|
if (!BLOCK_KINDS.includes(value)) throw fail("invalid-switch-value", `Unknown content block kind: ${value}.`, { value });
|
|
@@ -190,9 +234,9 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
|
|
|
190
234
|
throw fail("unknown-dimension", `Unknown dimension: ${dimension}. Use one of ${SWITCH_DIMENSIONS.join(", ")}.`, { dimension });
|
|
191
235
|
if (!presentation || typeof presentation !== "object") throw fail("invalid-input", "Switch requires an OPF document object.");
|
|
192
236
|
if (options.record) {
|
|
193
|
-
const ids = dimension === "audiences" ? [value].flat() :
|
|
194
|
-
if (!CATALOG_KIND[dimension] || !ids.includes(options.record.id))
|
|
195
|
-
throw fail("record-id-mismatch", `The supplied record id must equal a switched ${dimension} id.`, { dimension, id: options.record
|
|
237
|
+
const ids = (dimension === "audiences" ? [value].flat() : [value]).map((entry) => (typeof entry === "string" ? parseReference(entry)?.id : undefined));
|
|
238
|
+
if (!CATALOG_KIND[dimension] || typeof options.record?.id !== "string" || !ids.includes(options.record.id))
|
|
239
|
+
throw fail("record-id-mismatch", `The supplied record id must equal a switched ${dimension} id.`, { dimension, id: options.record?.id });
|
|
196
240
|
}
|
|
197
241
|
const before = checkFormat(presentation);
|
|
198
242
|
const scopeIndex = options.slideIndex;
|
|
@@ -212,22 +256,25 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
|
|
|
212
256
|
// A slide's design cannot set dimensions (FA-07), so no slide can shadow the deck's size.
|
|
213
257
|
patches = sameSize ? [] : designPatches(presentation, [], { dimensions: value });
|
|
214
258
|
} else if (dimension === "purposes") {
|
|
215
|
-
// A catalog
|
|
259
|
+
// A catalog reference, free-form goal text (no reference check: any goal is valid) or an inline Purpose object; the schema validates it.
|
|
216
260
|
if (typeof value !== "string" && !(value && typeof value === "object" && !Array.isArray(value)))
|
|
217
|
-
throw fail("invalid-switch-value", "Switch purposes to a catalog
|
|
261
|
+
throw fail("invalid-switch-value", "Switch purposes to a catalog reference, a goal string or a purpose object.", { value });
|
|
218
262
|
if (typeof value === "string" && value.length === 0) throw fail("invalid-switch-value", "Switch purposes to a non-empty goal.", { value });
|
|
219
|
-
|
|
263
|
+
const supplied = typeof value === "string" ? catalogChoiceIfSupplied(presentation, dimension, value, options) : undefined;
|
|
264
|
+
patches = [...(supplied?.patches ?? []), ...rootPatch(presentation, "purpose", supplied?.reference ?? value)];
|
|
220
265
|
} else if (dimension === "layouts") {
|
|
221
266
|
slideIndex = scopeIndex;
|
|
222
267
|
slideAt(presentation, slideIndex, "a layout switch");
|
|
223
268
|
scope = "slide";
|
|
224
|
-
const
|
|
225
|
-
|
|
269
|
+
const choice = catalogChoice(presentation, dimension, value, options);
|
|
270
|
+
const record = choice.record;
|
|
271
|
+
patches = choice.patches;
|
|
226
272
|
const withRecord = patches.length ? applyJsonPatch(presentation, patches) : presentation;
|
|
227
|
-
if (withRecord.slides[slideIndex].layout !==
|
|
228
|
-
const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"],
|
|
273
|
+
if (withRecord.slides[slideIndex].layout !== choice.reference) {
|
|
274
|
+
const layoutOps = createValuePatch(withRecord, ["slides", String(slideIndex), "layout"], choice.reference);
|
|
229
275
|
const swapped = applyJsonPatch(withRecord, layoutOps);
|
|
230
|
-
|
|
276
|
+
// FA-26: a record's placeholder groups contribute their leaf regions, in reading order.
|
|
277
|
+
const types = layoutSlotTypes(record);
|
|
231
278
|
// Add the blank payloads the layout declares, as the JSON editor's layout choice does.
|
|
232
279
|
const populated = types ? populateLayoutPlaceholders(swapped, slideIndex, types) : swapped;
|
|
233
280
|
patches = same(populated.slides[slideIndex], swapped.slides[slideIndex])
|
|
@@ -238,13 +285,13 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
|
|
|
238
285
|
slideIndex = scopeIndex;
|
|
239
286
|
slideAt(presentation, slideIndex, "a chart switch");
|
|
240
287
|
scope = "slide";
|
|
241
|
-
|
|
288
|
+
if (typeof value !== "string" || !CHART_TYPES.includes(value)) throw fail("unknown-catalog-id", `Unknown chart type: ${value}. Use one of ${CHART_TYPES.join(", ")}.`, { dimension, id: value });
|
|
242
289
|
const owner = options.path ? splitOpfPath(options.path) : findChartOwner(presentation, slideIndex);
|
|
243
290
|
if (options.path && (owner[0] !== "slides" || owner[1] !== String(slideIndex)))
|
|
244
291
|
throw fail("path-slide-mismatch", "options.path is not on the slide named by options.slideIndex.", { slideIndex, path: options.path });
|
|
245
292
|
const chart = owner && getValueAtPath(presentation, [...owner, "chart"]);
|
|
246
293
|
if (!chart || typeof chart !== "object") throw fail("chart-not-found", "This slide has no chart to switch. Insert a chart block first.", { slideIndex, path: options.path });
|
|
247
|
-
patches =
|
|
294
|
+
patches = chart.type === value ? [] : [...createValuePatch(presentation, [...owner, "chart", "type"], value), ...staleMappingPatches(presentation, owner, chart, value), ...staleComboPatches(presentation, owner, chart, value)];
|
|
248
295
|
} else if (dimension === "blocks") {
|
|
249
296
|
if (options.path === undefined) throw fail("missing-path", "Choose the block to replace with options.path.");
|
|
250
297
|
// convert: true moves the block's own text into the new kind (block-convert.js) instead of replacing it.
|
|
@@ -263,30 +310,58 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
|
|
|
263
310
|
scope = "block";
|
|
264
311
|
} else if (dimension === "socials") {
|
|
265
312
|
patches = socialsPatches(presentation, value, options);
|
|
313
|
+
} else if (dimension === "image-treatments") {
|
|
314
|
+
// The treatments of one image block (FA-22): fit, focus, shape, corner radius, border, opacity, recolor, overlay, aspect ratio, placement.
|
|
315
|
+
if (options.path === undefined) throw fail("missing-path", "Choose the image block to treat with options.path.");
|
|
316
|
+
patches = imageTreatmentPatches(presentation, options.path, value);
|
|
317
|
+
const parts = splitOpfPath(options.path);
|
|
318
|
+
if (parts[0] === "slides") slideIndex = Number(parts[1]);
|
|
319
|
+
scope = "block";
|
|
320
|
+
} else if (dimension === "languages") {
|
|
321
|
+
// A BCP-47 tag (en-US) or a language object ({ bcp47, ... }); the schema checks the tag. Language catalog ids are gone.
|
|
322
|
+
if (!(typeof value === "string" && value) && !(isObject(value) && typeof value.bcp47 === "string"))
|
|
323
|
+
throw fail("invalid-switch-value", "Switch languages to a BCP-47 tag such as en-US, or a language object with bcp47.", { value });
|
|
324
|
+
patches = rootPatch(presentation, "language", value);
|
|
266
325
|
} else if (ROOT_FIELD[dimension]) {
|
|
267
326
|
const field = ROOT_FIELD[dimension];
|
|
268
|
-
let next
|
|
327
|
+
let next;
|
|
269
328
|
if (dimension === "audiences") {
|
|
270
|
-
|
|
271
|
-
if (!
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
329
|
+
const list = Array.isArray(value) ? value : [value];
|
|
330
|
+
if (!list.length) throw fail("invalid-catalog-id", "Switch audiences to at least one catalog reference.", { value });
|
|
331
|
+
next = [];
|
|
332
|
+
for (const reference of list) {
|
|
333
|
+
const choice = catalogChoice(applyJsonPatch(presentation, patches), dimension, reference, options);
|
|
334
|
+
patches = [...patches, ...choice.patches];
|
|
335
|
+
next.push(choice.reference);
|
|
336
|
+
}
|
|
337
|
+
} else {
|
|
338
|
+
const choice = catalogChoice(presentation, dimension, value, options);
|
|
339
|
+
patches = choice.patches;
|
|
340
|
+
next = choice.reference;
|
|
341
|
+
}
|
|
342
|
+
patches = [...patches, ...rootPatch(presentation, field, next)];
|
|
275
343
|
} else {
|
|
276
344
|
// Design dimensions: deck scope by default, or one slide with options.slideIndex.
|
|
277
345
|
let entries;
|
|
278
346
|
if (dimension === "themes") {
|
|
279
|
-
const
|
|
280
|
-
|
|
347
|
+
const choice = catalogChoice(presentation, dimension, value, options);
|
|
348
|
+
const record = choice.record ?? {};
|
|
349
|
+
entries = { theme: choice.reference };
|
|
281
350
|
if (options.bundle !== false) {
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
|
|
351
|
+
// An explicit theme choice brings the theme's own colour scheme, font scheme and background (and, for the deck, its
|
|
352
|
+
// slide size): an override of those at the same scope is removed so the theme's applies. Nothing is copied out of the
|
|
353
|
+
// record, except on a slide whose deck sets the key: there the theme's value is written on the slide (its references
|
|
354
|
+
// qualified with the theme's catalog group), because the deck's design would otherwise win over the slide's theme.
|
|
355
|
+
// A slide's design cannot set dimensions (a PPTX has one slide size), so only a deck-scope switch touches them.
|
|
356
|
+
for (const key of ["colorScheme", "fontScheme", "background", ...(scopeIndex === undefined ? ["dimensions"] : [])]) {
|
|
357
|
+
if (record[key] === undefined) continue;
|
|
358
|
+
entries[key] = scopeIndex !== undefined && presentation.design?.[key] !== undefined ? qualifiedValue(key, record[key], choice.group) : null;
|
|
359
|
+
}
|
|
286
360
|
}
|
|
287
|
-
patches =
|
|
361
|
+
patches = choice.patches;
|
|
288
362
|
} else if (dimension === "color-schemes" || dimension === "font-schemes") {
|
|
289
|
-
|
|
363
|
+
const choice = catalogChoice(presentation, dimension, value, options);
|
|
364
|
+
value = choice.reference;
|
|
290
365
|
entries = { [DESIGN_KEYS[dimension][0]]: value };
|
|
291
366
|
if (dimension === "font-schemes") {
|
|
292
367
|
// An accent font is its own choice, not part of the scheme being left: it stays (in the object form).
|
|
@@ -294,7 +369,7 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
|
|
|
294
369
|
const own = getValueAtPath(presentation, [...scopeBase, "design", "fontScheme"]);
|
|
295
370
|
if (own && typeof own === "object" && own.accent !== undefined) entries.fontScheme = { id: value, accent: structuredClone(own.accent) };
|
|
296
371
|
}
|
|
297
|
-
patches =
|
|
372
|
+
patches = choice.patches;
|
|
298
373
|
} else {
|
|
299
374
|
// A background is an object or a shorthand string (theme slot or hex color); the schema
|
|
300
375
|
// validates the candidate document, so a string that is neither is rejected below.
|
|
@@ -342,7 +417,8 @@ export function prepareDimensionSwitch(presentation, dimension, value, options =
|
|
|
342
417
|
}
|
|
343
418
|
|
|
344
419
|
/**
|
|
345
|
-
* Switch one dimension of the session's document as a single undoable transaction
|
|
420
|
+
* Switch one dimension of the session's document as a single undoable transaction, resolving references with the session's
|
|
421
|
+
* registered catalogs (and `options.catalogs`). Returns the
|
|
346
422
|
* editor change plus `dimension`, `scope`, `changed` and `shadowed` (slides whose own design
|
|
347
423
|
* hides a deck-level switch). Switching to the current value commits nothing.
|
|
348
424
|
*/
|
|
@@ -350,7 +426,8 @@ export function switchDimension(editor, dimension, value, options = {}) {
|
|
|
350
426
|
if (!editor || typeof editor.applyPatch !== "function" || typeof editor.subscribe !== "function")
|
|
351
427
|
throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
|
|
352
428
|
const { meta, ...switchOptions } = options;
|
|
353
|
-
|
|
429
|
+
// The session's registered catalogs first, then any the caller adds for this switch: the one list core resolves with.
|
|
430
|
+
const prepared = prepareDimensionSwitch(editor.presentation, dimension, value, { ...switchOptions, catalogs: mergeCatalogs(editor.catalogs, options.catalogs) });
|
|
354
431
|
const { presentation, patches, ...summary } = prepared;
|
|
355
432
|
void presentation;
|
|
356
433
|
if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
|
|
@@ -360,28 +437,46 @@ export function switchDimension(editor, dimension, value, options = {}) {
|
|
|
360
437
|
|
|
361
438
|
// --- options for pickers and current values ---------------------------------------------------
|
|
362
439
|
|
|
363
|
-
|
|
440
|
+
// Host display metadata for an engine vocabulary (`options.vocabularies[key]`, the shape of `catalogDisplay`): a list of records
|
|
441
|
+
// with `id` (or `bcp47` for a language), or an object keyed by id. Returns a Map id -> record.
|
|
442
|
+
function displayRecords(options, key) {
|
|
443
|
+
const source = options.vocabularies?.[key];
|
|
444
|
+
const out = new Map();
|
|
445
|
+
if (Array.isArray(source)) {
|
|
446
|
+
for (const record of source) if (isObject(record) && typeof (record.id ?? record.bcp47) === "string") out.set(record.id ?? record.bcp47, record);
|
|
447
|
+
} else if (isObject(source)) {
|
|
448
|
+
for (const [id, record] of Object.entries(source)) out.set(isObject(record) && typeof record.bcp47 === "string" ? record.bcp47 : id, isObject(record) ? record : {});
|
|
449
|
+
}
|
|
450
|
+
return out;
|
|
451
|
+
}
|
|
452
|
+
const displayLabel = (record, id) => (typeof record?.name === "string" && record.name ? record.name : typeof record?.label === "string" && record.label ? record.label : id);
|
|
364
453
|
|
|
365
454
|
/**
|
|
366
|
-
* The values a picker can offer for a catalog-backed dimension
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* `
|
|
455
|
+
* The values a picker can offer for a dimension. A catalog-backed dimension lists core's `catalogRecords` (the document's embedded
|
|
456
|
+
* records first, then `options.catalogs`'), each `{ id, label, record, reference, group, source, origin }` with `id` the reference
|
|
457
|
+
* to write. `charts` lists core's `CHART_TYPES` and `socials` core's `SOCIAL_PLATFORMS`, labelled from `options.vocabularies`;
|
|
458
|
+
* `languages` lists core's `LANGUAGES` tags. `blocks` lists the content
|
|
459
|
+
* kinds. Use `compatibleChartTypes` to narrow `charts` to the types the chart's data can use.
|
|
370
460
|
*/
|
|
371
461
|
export function listSwitchOptions(presentation, dimension, options = {}) {
|
|
372
462
|
if (dimension === "blocks") return BLOCK_KINDS.map((kind) => ({ id: kind, label: kind[0].toUpperCase() + kind.slice(1) }));
|
|
373
463
|
if (dimension === "slide-sizes") return SLIDE_SIZE_PRESETS.map((preset) => ({ id: preset, label: SLIDE_SIZE_LABELS[preset] }));
|
|
464
|
+
if (dimension === "charts") {
|
|
465
|
+
const display = displayRecords(options, VOCABULARY.charts);
|
|
466
|
+
return CHART_TYPES.map((id) => ({ id, label: displayLabel(display.get(id), id), record: display.get(id) ?? {} }));
|
|
467
|
+
}
|
|
468
|
+
if (dimension === "socials") {
|
|
469
|
+
const display = displayRecords(options, VOCABULARY.socials);
|
|
470
|
+
return Object.keys(SOCIAL_PLATFORMS).map((id) => ({ id, label: displayLabel(display.get(id), id), record: { ...SOCIAL_PLATFORMS[id], ...display.get(id) } }));
|
|
471
|
+
}
|
|
472
|
+
if (dimension === "languages") {
|
|
473
|
+
// Core's language table (the tags engines know the script, direction and default fonts of), labelled from the host's display metadata.
|
|
474
|
+
const display = displayRecords(options, VOCABULARY.languages);
|
|
475
|
+
return LANGUAGES.map((language) => ({ id: language.tag, label: displayLabel(display.get(language.tag), language.tag), record: { ...language, ...display.get(language.tag) } }));
|
|
476
|
+
}
|
|
374
477
|
const kind = CATALOG_KIND[dimension];
|
|
375
478
|
if (!kind) return [];
|
|
376
|
-
|
|
377
|
-
const out = [];
|
|
378
|
-
for (const source of [presentation?.catalogs?.[kind], options.catalogs?.[kind] ?? options.catalogSources?.[kind], bundledCatalogs[kind]])
|
|
379
|
-
for (const record of recordList(source)) {
|
|
380
|
-
if (seen.has(record.id)) continue;
|
|
381
|
-
seen.add(record.id);
|
|
382
|
-
out.push({ id: record.id, label: labelOf(record), record });
|
|
383
|
-
}
|
|
384
|
-
return out;
|
|
479
|
+
return listCatalogRecords(presentation ?? {}, kind, { catalogs: catalogsOf(options) });
|
|
385
480
|
}
|
|
386
481
|
|
|
387
482
|
const SINGLE_SERIES_ONLY = new Set(["pieChart", "doughnutChart", "funnelChart", "treemapChart", "waterfallChart"]);
|
|
@@ -423,7 +518,7 @@ function chartDataShape(chart, presentation) {
|
|
|
423
518
|
}
|
|
424
519
|
|
|
425
520
|
/**
|
|
426
|
-
* Chart types the chart's inline data can use as it is, from the
|
|
521
|
+
* Chart types the chart's inline data can use as it is, from core's chart types and the host's chart display metadata: simple,
|
|
427
522
|
* non-geographic, non-distribution types whose series count fits the data (the first column labels
|
|
428
523
|
* the categories and each further column is a series; a type with N series needs exactly N value
|
|
429
524
|
* columns, except that a stacked or percent-stacked type and a combination such as combo take N or more;
|
|
@@ -443,7 +538,8 @@ export function compatibleChartTypes(presentation, options = {}) {
|
|
|
443
538
|
const record = option.record;
|
|
444
539
|
const element = record.mappings?.openxml?.element;
|
|
445
540
|
const current = option.id === chart.type;
|
|
446
|
-
|
|
541
|
+
// Without the host's display metadata for a type (complexity, series, mappings) it is offered: compatibility is unknown, not refused.
|
|
542
|
+
const simple = record.complexity === undefined ? !DISTRIBUTION_ELEMENTS.has(element) : record.complexity === "simple" && !DISTRIBUTION_ELEMENTS.has(element);
|
|
447
543
|
// A combination (composition "mixed", the FA-15 combo chart) needs at least its series count, like a stacked type.
|
|
448
544
|
const atLeast = STACKED_GROUPINGS.has(record.mappings?.openxml?.grouping) || record.mappings?.openxml?.composition === "mixed";
|
|
449
545
|
const seriesOk =
|
|
@@ -462,8 +558,8 @@ export function compatibleChartTypes(presentation, options = {}) {
|
|
|
462
558
|
/**
|
|
463
559
|
* The value a dimension currently has, for pickers: `{ value, scope }` where scope is "slide" when
|
|
464
560
|
* `slideIndex` names a slide whose own design sets it, else "deck". `value` is undefined when the
|
|
465
|
-
* dimension is unset. Catalog dimensions return the
|
|
466
|
-
*
|
|
561
|
+
* dimension is unset. Catalog dimensions return the reference (`id` or `name:id`) even when the document holds an
|
|
562
|
+
* object form with overrides. `options.catalogs` resolves the theme's slide size.
|
|
467
563
|
*/
|
|
468
564
|
export function currentSwitchValue(presentation, dimension, options = {}) {
|
|
469
565
|
const idOf = (reference) => (reference && typeof reference === "object" && !Array.isArray(reference) ? reference.id : reference);
|
|
@@ -478,18 +574,24 @@ export function currentSwitchValue(presentation, dimension, options = {}) {
|
|
|
478
574
|
return { value: owner ? getValueAtPath(presentation, [...owner, "chart", "type"]) : undefined, scope: "slide" };
|
|
479
575
|
}
|
|
480
576
|
if (dimension === "blocks") return { value: undefined, scope: "block" };
|
|
577
|
+
if (dimension === "image-treatments") {
|
|
578
|
+
// The treatments of the image block at `options.path` (FA-22); without a path there is nothing to read.
|
|
579
|
+
if (options.path === undefined) return { value: undefined, scope: "block" };
|
|
580
|
+
const state = readImageTreatments(presentation, options.path);
|
|
581
|
+
return { value: Object.fromEntries(IMAGE_TREATMENT_FIELDS.map((key) => [key, state[key]])), scope: "block" };
|
|
582
|
+
}
|
|
481
583
|
if (dimension === "slide-sizes") {
|
|
482
584
|
// The size the deck composes at: its own design.dimensions, else the theme's; a {preset} object reads as its preset.
|
|
483
585
|
// A custom size (inches without a preset) reads as the object itself. Unset reads as undefined (composed as widescreen).
|
|
484
586
|
let size = presentation.design?.dimensions;
|
|
485
587
|
if (size === undefined) {
|
|
486
|
-
const
|
|
487
|
-
size =
|
|
588
|
+
const theme = presentation.design?.theme;
|
|
589
|
+
size = typeof theme === "string" ? resolveRecord(presentation, "themes", theme, options)?.record?.dimensions : undefined;
|
|
488
590
|
}
|
|
489
591
|
return { value: size && typeof size === "object" && !Array.isArray(size) && Object.keys(size).length === 1 && size.preset ? size.preset : size, scope: "deck" };
|
|
490
592
|
}
|
|
491
593
|
if (dimension === "purposes") {
|
|
492
|
-
// A catalog
|
|
594
|
+
// A catalog reference or goal text reads as itself, a Purpose object as its id (else the object).
|
|
493
595
|
const purpose = presentation.purpose;
|
|
494
596
|
return { value: purpose && typeof purpose === "object" && !Array.isArray(purpose) ? (purpose.id ?? purpose) : purpose, scope: "deck" };
|
|
495
597
|
}
|
package/dist/table-options.d.ts
CHANGED
|
@@ -37,7 +37,7 @@ export interface TableCellState extends TableCellAddress {
|
|
|
37
37
|
rows: number;
|
|
38
38
|
}
|
|
39
39
|
export interface PreparedTableChange {
|
|
40
|
-
action: "merge" | "split" | "style" | "table-style";
|
|
40
|
+
action: "merge" | "split" | "style" | "table-style" | "table-alt";
|
|
41
41
|
tablePath: string;
|
|
42
42
|
presentation: unknown;
|
|
43
43
|
patches: JsonPatchOperation[];
|
|
@@ -72,6 +72,11 @@ export declare function mergeTableCells(editor: EditorSession, tablePath: string
|
|
|
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;
|
|
74
74
|
export declare function setTableStyle(editor: EditorSession, tablePath: string, preset: keyof typeof TABLE_STYLE_PRESETS | Partial<TableStyle>, meta?: Record<string, unknown>): TableChange;
|
|
75
|
+
/** FA-27: the table's text alternative as form state: `alt` (empty when absent or decorative) and `decorative` (the empty alt). */
|
|
76
|
+
export declare function readTableAlt(table: unknown): { alt: string; decorative: boolean };
|
|
77
|
+
/** FA-27: change the table's text alternative. `alt` is trimmed and an empty string or null removes it; `decorative: true` writes the empty alt and wins over `alt`; `decorative: false` removes an empty alt. Works on inline and dataset-backed tables. */
|
|
78
|
+
export declare function prepareTableAlt(presentation: unknown, tablePath: string, change: { alt?: string | null; decorative?: boolean }): PreparedTableChange;
|
|
79
|
+
export declare function setTableAlt(editor: EditorSession, tablePath: string, change: { alt?: string | null; decorative?: boolean }, meta?: Record<string, unknown>): TableChange;
|
|
75
80
|
/** {@link readTableStyle} for a table object. */
|
|
76
81
|
export declare function readTableStyleOfTable(table: unknown): (TableStyle | { header: "custom"; banding: "custom"; borders: "custom" }) & { preset: keyof typeof TABLE_STYLE_PRESETS | "custom" };
|
|
77
82
|
/** Apply a table style to a table object in place (the structure operations use it to keep banding, header fill and borders correct). */
|
package/dist/table-options.js
CHANGED
|
@@ -394,6 +394,44 @@ export function readTableStyleOfTable(table) {
|
|
|
394
394
|
// Table structure (RR-24): insert, delete, move and sort rows and columns, the header row, cell values.
|
|
395
395
|
export * from "./table-structure.js";
|
|
396
396
|
|
|
397
|
+
// --- text alternative (FA-27) -----------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The table's text alternative as form state, like a chart's: `alt` is the text (empty when absent or decorative) and `decorative`
|
|
401
|
+
* is the empty alt, a reviewed choice and not a missing one.
|
|
402
|
+
*/
|
|
403
|
+
export function readTableAlt(table) {
|
|
404
|
+
return { alt: typeof table?.alt === "string" ? table.alt : "", decorative: table?.alt === "" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Compute the patch that changes the table's text alternative. `change` is `{ alt?, decorative? }`: `alt` is trimmed and an empty string
|
|
409
|
+
* or `null` removes it, `decorative: true` writes the empty alt (and wins over `alt`), `decorative: false` removes an empty alt. It works
|
|
410
|
+
* on an inline table and on a table that shows a shared dataset, because `alt` belongs to the table and not to its data. The document
|
|
411
|
+
* is not modified.
|
|
412
|
+
*/
|
|
413
|
+
export function prepareTableAlt(presentation, tablePath, change) {
|
|
414
|
+
const before = checkFormat(presentation);
|
|
415
|
+
const parts = splitOpfPath(tablePath);
|
|
416
|
+
const table = getValueAtPath(presentation, parts);
|
|
417
|
+
if (!isObject(table) || (!Array.isArray(table.rows) && typeof table.dataset !== "string")) throw fail("table-not-found", "Choose a table (a path ending in .table).", { tablePath });
|
|
418
|
+
if (!isObject(change)) throw fail("invalid-table-option", "A table alt change is { alt?, decorative? }.", { change });
|
|
419
|
+
let wanted;
|
|
420
|
+
if (change.decorative === true) wanted = "";
|
|
421
|
+
else if (change.alt !== undefined) wanted = typeof change.alt === "string" && change.alt.trim() ? change.alt.trim() : undefined;
|
|
422
|
+
else if (change.decorative === false && table.alt === "") wanted = undefined;
|
|
423
|
+
else wanted = table.alt;
|
|
424
|
+
const pointer = opfPathToJsonPointer([...parts, "alt"]);
|
|
425
|
+
const present = Object.hasOwn(table, "alt");
|
|
426
|
+
const patches = [];
|
|
427
|
+
if (wanted === undefined) {
|
|
428
|
+
if (present) patches.push({ op: "remove", path: pointer });
|
|
429
|
+
} else if (!present) patches.push({ op: "add", path: pointer, value: wanted });
|
|
430
|
+
else if (table.alt !== wanted) patches.push({ op: "replace", path: pointer, value: wanted });
|
|
431
|
+
const next = checkedDocument(presentation, patches, before);
|
|
432
|
+
return { action: "table-alt", tablePath: parts.join("."), changed: patches.length > 0, patches, presentation: clone(next) };
|
|
433
|
+
}
|
|
434
|
+
|
|
397
435
|
// --- session forms ----------------------------------------------------------------------------
|
|
398
436
|
|
|
399
437
|
function commit(editor, prepared, meta = {}) {
|
|
@@ -423,6 +461,11 @@ export function setTableCellStyle(editor, tablePath, cells, style, meta = {}) {
|
|
|
423
461
|
checkEditor(editor);
|
|
424
462
|
return commit(editor, prepareTableCellStyle(editor.presentation, tablePath, cells, style), meta);
|
|
425
463
|
}
|
|
464
|
+
/** Change the table's text alternative as one undoable transaction. See {@link prepareTableAlt}. */
|
|
465
|
+
export function setTableAlt(editor, tablePath, change, meta = {}) {
|
|
466
|
+
checkEditor(editor);
|
|
467
|
+
return commit(editor, prepareTableAlt(editor.presentation, tablePath, change), meta);
|
|
468
|
+
}
|
|
426
469
|
/** Apply a table style as one undoable transaction. See {@link prepareTableStyle}. */
|
|
427
470
|
export function setTableStyle(editor, tablePath, preset, meta = {}) {
|
|
428
471
|
checkEditor(editor);
|
package/dist/transfer.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Catalog, CopiedRecordRename } from "@openpresentation/opf";
|
|
1
2
|
export declare const MAX_OPF_BYTES: number;
|
|
2
3
|
export interface OpfTransfer {
|
|
3
4
|
kind: "presentation" | "slides" | "selection";
|
|
@@ -14,6 +15,8 @@ export declare function serializeOpfTransfer(
|
|
|
14
15
|
slideIndex?: number;
|
|
15
16
|
path?: string;
|
|
16
17
|
format?: "pretty" | "compact" | "markdown";
|
|
18
|
+
/** Host catalogs: a presentation or slide is embedded (core `embed`) so it renders the same where none is registered. */
|
|
19
|
+
catalogs?: readonly Catalog[];
|
|
17
20
|
},
|
|
18
21
|
): string;
|
|
19
22
|
export declare function prepareOpfImport(
|
|
@@ -23,5 +26,13 @@ export declare function prepareOpfImport(
|
|
|
23
26
|
mode?: "insert" | "replace" | "selection";
|
|
24
27
|
slideIndex?: number;
|
|
25
28
|
path?: string;
|
|
29
|
+
/** Host catalogs, for resolving and copying the inserted slides' records (core `copySlides`). */
|
|
30
|
+
catalogs?: readonly Catalog[];
|
|
26
31
|
},
|
|
27
|
-
): {
|
|
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
|
+
};
|