@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/export.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // // result.download is { name, type, bytes }: one PDF, one PNG or SVG, or a ZIP of the slides.
9
9
 
10
10
  import { renderSvg } from "@openpresentation/opf-render/svg";
11
+ import { mergeCatalogs, prepareSave } from "./catalogs.js";
11
12
  import { fontGate } from "./font-gate.js";
12
13
  import { createZip } from "./zip.js";
13
14
 
@@ -125,6 +126,8 @@ async function loadConverters(options) {
125
126
  * - `pdfMode`: "vector" (default: selectable text, vector shapes) or "raster" (each slide an image).
126
127
  * - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
127
128
  * - `renderOptions`: the render options the host draws with (`catalogs`, `date`, ...), the same as its preview.
129
+ * - `catalogs`: the host's registered catalogs (`Catalog[]`, merged with `renderOptions.catalogs`). The deck is embedded first (core's
130
+ * `embed`), so what is drawn is the self-contained document a save writes.
128
131
  * - `fonts`: the renderer's fonts handle (`loadFonts()` from `@openpresentation/opf-render/fonts-browser`), the one the preview uses. Its
129
132
  * `textMeasurement` lays the slides out, the faces the deck needs load through its `ensure` before anything is drawn, and its registry's
130
133
  * faces are what gets embedded: the SVG carries the faces its slides draw, and the PDF embeds the registry's faces (script faces included).
@@ -136,7 +139,7 @@ async function loadConverters(options) {
136
139
  * Resolves `{ download, files, diagnostics, slides }`. Rejects with an error whose `code` is `export-aborted`,
137
140
  * `export-fonts-unlicensed`, `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code.
138
141
  */
139
- export async function exportDeck(deck, options = {}) {
142
+ export async function exportDeck(input, options = {}) {
140
143
  const format = EXPORT_FORMATS[options.format];
141
144
  if (!format) throw exportError("export-format", `Choose a format: ${Object.keys(EXPORT_FORMATS).join(", ")}.`);
142
145
  const { signal } = options;
@@ -144,12 +147,14 @@ export async function exportDeck(deck, options = {}) {
144
147
  const note = (diagnostic) => { diagnostics.push(diagnostic); options.onDiagnostic?.(diagnostic); };
145
148
  const progress = (stage, done, total, message) => options.onProgress?.({ stage, done, total, message });
146
149
  const check = () => { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); };
147
- const indexes = slidesToExport(deck, options);
150
+ const indexes = slidesToExport(input, options);
148
151
  if (!indexes.length) throw exportError("export-no-slides", "There are no slides to export.");
149
152
  const selected = new Set(indexes);
150
153
  const keep = (diagnostic) => diagnostic.slide === undefined || selected.has(diagnostic.slide);
151
154
  const scale = Math.min(MAX_PNG_SCALE, Math.max(1, Number(options.scale) || 2));
152
- const renderOptions = options.renderOptions ?? {};
155
+ const catalogs = mergeCatalogs(options.catalogs, options.renderOptions?.catalogs);
156
+ const renderOptions = { ...(options.renderOptions ?? {}), catalogs };
157
+ const deck = prepareSave(input, { catalogs }).document;
153
158
 
154
159
  check();
155
160
  const gate = fontGate(options.fonts);
package/dist/galleries.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { assertOpf, unwrapOpf, MAX_OPF_BYTES } from "./transfer.js";
2
- import { catalogs } from "@openpresentation/opf";
3
2
  export function galleryUrl(input, base) {
4
3
  const url = new URL(input, base);
5
4
  if (
@@ -133,8 +132,6 @@ function fontRole(value) {
133
132
  // pair). `accent` is not part of the record schema and is dropped.
134
133
  function fontSchemeRecord(source, id) {
135
134
  const record = {
136
- $schema: "https://openpresentation.org/schema/opf-font-scheme/v1",
137
- id,
138
135
  name: source.name ?? id,
139
136
  major:
140
137
  typeof source.major === "string"
@@ -151,10 +148,25 @@ function fontSchemeRecord(source, id) {
151
148
  if (code) record.code = code;
152
149
  return record;
153
150
  }
154
- function attachDefinition(presentation, descriptor) {
151
+ const isPlain = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
152
+ // Embedded records carry no `$schema`, `id` or `x-*` display metadata (OPF 0.15): the key is the id.
153
+ function embeddedRecord(source) {
154
+ return Object.fromEntries(
155
+ Object.entries(structuredClone(source)).filter(
156
+ ([key]) => key !== "$schema" && key !== "id" && key !== "slug" && !key.startsWith("x-"),
157
+ ),
158
+ );
159
+ }
160
+ /**
161
+ * A gallery item that describes one catalog record (a layout, theme, colour or font scheme) is embedded in the
162
+ * document it previews: under `default` with the gallery's origin as its `source` when the document has no default
163
+ * group (or its default is that gallery), else under `custom` (an item with no gallery URL, or another default
164
+ * catalog). Records the document already embeds are kept.
165
+ */
166
+ function attachDefinition(presentation, descriptor, gallerySource) {
155
167
  const source = descriptor.metadata?.source,
156
168
  category = descriptor.category ?? descriptor.metadata?.category;
157
- if (!source) return;
169
+ if (!isPlain(source)) return;
158
170
  const kind = {
159
171
  layouts: "layouts",
160
172
  "font-schemes": "fontSchemes",
@@ -163,12 +175,13 @@ function attachDefinition(presentation, descriptor) {
163
175
  }[category];
164
176
  if (!kind) return;
165
177
  const id = source.id ?? source.slug;
166
- if (!id) return;
167
- if (
168
- catalogs[kind]?.some((record) => record.id === id) ||
169
- presentation.catalogs?.[kind]?.records?.some((record) => record.id === id)
170
- )
171
- return;
178
+ if (typeof id !== "string" || !/^[a-z][a-z0-9-]*$/.test(id)) return;
179
+ const groups = (presentation.catalogs ??= {});
180
+ if (groups.custom?.[kind]?.[id] !== undefined || groups.default?.[kind]?.[id] !== undefined) return;
181
+ const group = gallerySource && (groups.default === undefined || groups.default?.source === gallerySource) ? "default" : "custom";
182
+ if (group === "default") groups.default ??= { source: gallerySource };
183
+ else groups.custom ??= {};
184
+ groups[group][kind] ??= {};
172
185
  let record;
173
186
  if (kind === "layouts") {
174
187
  // A gallery layout item is a layout record: its placeholders, design hints and composition are the
@@ -176,33 +189,17 @@ function attachDefinition(presentation, descriptor) {
176
189
  const placeholders = Array.isArray(source.placeholders)
177
190
  ? source.placeholders
178
191
  .filter((placeholder) => typeof placeholder?.type === "string")
179
- .map((placeholder) => ({ type: placeholder.type }))
192
+ .map((placeholder) => structuredClone(placeholder))
180
193
  : [{ type: "title" }, { type: "text" }];
181
- const plain = (value) =>
182
- value && typeof value === "object" && !Array.isArray(value)
183
- ? structuredClone(value)
184
- : undefined;
185
194
  record = {
186
- $schema: "https://openpresentation.org/schema/opf-layout/v1",
187
- id,
188
195
  name: source.label ?? source.name ?? id,
189
- ...(plain(source.design) ? { design: plain(source.design) } : {}),
196
+ ...(isPlain(source.design) ? { design: structuredClone(source.design) } : {}),
190
197
  placeholders,
191
- ...(plain(source.composition)
192
- ? { composition: plain(source.composition) }
193
- : {}),
198
+ ...(isPlain(source.composition) ? { composition: structuredClone(source.composition) } : {}),
194
199
  };
195
200
  } else if (kind === "fontSchemes") record = fontSchemeRecord(source, id);
196
- else {
197
- record = {
198
- ...source,
199
- $schema: `https://openpresentation.org/schema/opf-${kind === "themes" ? "theme" : "color-scheme"}/v1`,
200
- };
201
- }
202
- presentation.catalogs ??= {};
203
- presentation.catalogs[kind] ??= { records: [] };
204
- presentation.catalogs[kind].records ??= [];
205
- presentation.catalogs[kind].records.push(record);
201
+ else record = embeddedRecord(source);
202
+ groups[group][kind][id] = record;
206
203
  }
207
204
  export async function loadOpfGalleryItem(item, { gallery, ...options } = {}) {
208
205
  const base = gallery ?? options.base;
@@ -221,41 +218,9 @@ export async function loadOpfGalleryItem(item, { gallery, ...options } = {}) {
221
218
  const presentation = structuredClone(unwrapOpf(descriptor));
222
219
  if (!presentation?.slides)
223
220
  throw new Error("This gallery item does not include a presentation.");
224
- attachDefinition(presentation, descriptor);
225
- // PPTX.gallery's older descriptors omit inline layout records. Resolve only named
226
- // layout references from its known, same-origin registry; no arbitrary dependency crawl.
227
- if (
228
- url &&
229
- ["www.pptx.gallery", "pptx.gallery"].includes(new URL(url).hostname)
230
- ) {
231
- const missing = [
232
- ...new Set(
233
- presentation.slides
234
- .map((slide) => slide.layout)
235
- .filter(
236
- (id) =>
237
- typeof id === "string" &&
238
- !catalogs.layouts.some((record) => record.id === id) &&
239
- !presentation.catalogs?.layouts?.records?.some(
240
- (record) => record.id === id,
241
- ),
242
- ),
243
- ),
244
- ];
245
- if (missing.length > 12)
246
- throw new Error(
247
- "This item needs too many layout definitions. Supply self-contained OPF.",
248
- );
249
- for (const id of missing) {
250
- if (!/^[a-z][a-z0-9-]*$/.test(id))
251
- throw new Error("Unsupported layout reference.");
252
- const dependency = await fetchGalleryJson(
253
- `/registry/layouts/${id}.json`,
254
- { ...options, base: url, origin: new URL(url).origin },
255
- );
256
- attachDefinition(presentation, dependency);
257
- }
258
- }
221
+ // A gallery publishes self-contained OPF (every record a document uses is embedded, FA-23); the item's own record is
222
+ // embedded too when the snippet omits it. Nothing else is fetched.
223
+ attachDefinition(presentation, descriptor, url ? new URL(url).origin : undefined);
259
224
  assertOpf(presentation);
260
225
  return presentation;
261
226
  }
@@ -1,13 +1,12 @@
1
1
  // Image crop and focal point (RR-25): the model.
2
2
  //
3
- // The OPF schema has no crop rectangle and no focal point on an image (`Asset` is `src`, `alt`, `title`, `description`,
4
- // `mediaType`, `format`; the only fit controls are `design.imageFill` and a slide image's `fill`, both "crop" or "fit",
5
- // and a crop is always centred). `crop` and `focalPoint` are listed as deferred fields in core's
6
- // docs/content-item-design-overrides.md. So the editor writes a crop into the picture itself: the cropped pixels become a
7
- // new entry of `assets` and the image points at it, in ONE undoable change. Because the preview and the PPTX export both
8
- // read those pixels, they cannot disagree about what is shown; the fit the document already has (centred "crop" cover, or
9
- // "fit") then places the cropped picture in its frame exactly as before (opf-pptx writes the usual `a:srcRect` for that
10
- // placement, and none for a picture whose shape matches its frame).
3
+ // The OPF schema has no crop rectangle on an image (`Asset` is `src`, `alt`, `title`, `description`, `mediaType`, `format`).
4
+ // An image block has a `fit` (cover, contain, stretch) and, since OPF 0.15, a `focus` point that a cover fit keeps in view
5
+ // (image-options.js sets both). A crop that removes part of the picture is still not a field, so the editor writes it into
6
+ // the picture itself: the cropped pixels become a new entry of `assets` and the image points at it, in ONE undoable change.
7
+ // Because the preview and the PPTX export both read those pixels, they cannot disagree about what is shown; the block's fit
8
+ // then places the cropped picture in its frame exactly as before (opf-pptx writes the usual `a:srcRect` for that placement,
9
+ // and none for a picture whose shape matches its frame).
11
10
  //
12
11
  // A focal point works the same way: the picture is cut to the frame's shape around the chosen point, so a centred cover
13
12
  // fit shows what was chosen. The original asset stays in `assets`, and the new asset's `description` says "Cropped from
@@ -159,7 +158,7 @@ export const isFullRect = (rect, bounds) => rect.x < 0.5 && rect.y < 0.5 && Math
159
158
  export function describeImage(presentation, path) {
160
159
  const segments = String(path).split(".");
161
160
  const last = segments.at(-1);
162
- if (!["image", "slideImage"].includes(last)) return { error: "This is not a picture." };
161
+ if (last !== "image") return { error: "This is not a picture." };
163
162
  const value = getValueAtPath(presentation, path);
164
163
  const pointer = opfPathToJsonPointer(segments);
165
164
  let form;
@@ -0,0 +1,45 @@
1
+ import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
+
3
+ export declare const IMAGE_FITS: readonly ["cover", "contain", "stretch"];
4
+ export declare const IMAGE_SHAPES: readonly ["rectangle", "rounded", "circle", "hexagon"];
5
+ export declare const IMAGE_EDGES: readonly ["left", "right", "top", "bottom"];
6
+ export declare const IMAGE_TREATMENT_FIELDS: readonly ["fit", "focus", "aspectRatio", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "placement"];
7
+
8
+ export interface ImageFocus { x: number; y: number }
9
+ /** One overlay, shared by image backgrounds and image blocks: the whole picture, or a band along `edge` (`size` 0.05 to 1). */
10
+ export interface ImageOverlay { color: string; opacity: number; edge?: (typeof IMAGE_EDGES)[number]; size?: number }
11
+ /** The block bleeds to `edge` and takes `size` (0.1 to 0.9, default 0.5) of the slide; `inset` keeps it inside the padding. */
12
+ export interface ImagePlacement { edge: (typeof IMAGE_EDGES)[number]; size?: number; inset?: boolean }
13
+ export interface ImageTreatments {
14
+ fit?: (typeof IMAGE_FITS)[number] | null;
15
+ focus?: ImageFocus | null;
16
+ aspectRatio?: number | null;
17
+ shape?: (typeof IMAGE_SHAPES)[number] | null;
18
+ cornerRadius?: number | null;
19
+ border?: { color: string; width: number } | null;
20
+ opacity?: number | null;
21
+ recolor?: "grayscale" | { dark: string; light: string } | null;
22
+ overlay?: ImageOverlay | null;
23
+ placement?: ImagePlacement | null;
24
+ }
25
+ export interface ImageTreatmentState extends Omit<ImageTreatments, "fit" | "focus"> {
26
+ path: string;
27
+ fit?: (typeof IMAGE_FITS)[number];
28
+ focus?: ImageFocus;
29
+ /** Whether the block may be placed (a top-level block, `slides.N.blocks.I`). */
30
+ placeable: boolean;
31
+ /** Edges other placed blocks of the slide already take. */
32
+ usedEdges: (typeof IMAGE_EDGES)[number][];
33
+ }
34
+
35
+ export declare function normalizeFocus(value: unknown): ImageFocus;
36
+ export declare function normalizeOverlay(value: unknown): ImageOverlay;
37
+ export declare function normalizeRecolor(value: unknown): "grayscale" | { dark: string; light: string };
38
+ export declare function normalizePlacement(value: unknown): ImagePlacement;
39
+ /** The image block's framing for a form. Throws `not-an-image-block`. */
40
+ export declare function readImageTreatments(presentation: unknown, blockPath: string | string[]): ImageTreatmentState;
41
+ /** The patches that set (a value) or remove (`null`) the fields. Throws `invalid-image-treatment`, `placement-not-top-level` or `placement-edge-taken`. */
42
+ export declare function imageTreatmentPatches(presentation: unknown, blockPath: string | string[], fields: ImageTreatments): JsonPatchOperation[];
43
+ export declare function prepareImageTreatment(presentation: unknown, blockPath: string | string[], fields: ImageTreatments): { presentation: unknown; patches: JsonPatchOperation[]; changed: boolean; path: string; slideIndex: number };
44
+ /** Set or remove treatment fields of one image block as one undoable transaction. */
45
+ export declare function setImageTreatment(editor: EditorSession, blockPath: string | string[], fields: ImageTreatments, meta?: Record<string, unknown>): EditorChange & { changed: boolean; path: string; slideIndex: number };
@@ -0,0 +1,195 @@
1
+ // Image blocks (FA-22/FA-23, OPF 0.15): every content picture is an image block, and its framing lives on the block: fit and
2
+ // focus, the treatments (shape, corner radius, border, opacity, recolor, overlay, aspect ratio) and placement (bleed to one slide
3
+ // edge, the only thing that moves content aside). Each change is one validated JSON Patch applied as one undoable transaction;
4
+ // the `prepare…` form returns the patch without touching a session. `design.imageFit` (a design option) is the deck or slide
5
+ // default fit.
6
+ import { getValueAtPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
7
+ import { checkedDocument, fail, same } from "./edit-helpers.js";
8
+ import { checkFormat } from "./checks.js";
9
+ import { isAuthoringColorRef } from "./color-authoring.js";
10
+
11
+ export const IMAGE_FITS = Object.freeze(["cover", "contain", "stretch"]);
12
+ export const IMAGE_SHAPES = Object.freeze(["rectangle", "rounded", "circle", "hexagon"]);
13
+ export const IMAGE_EDGES = Object.freeze(["left", "right", "top", "bottom"]);
14
+ /** The fields an image block carries besides its `image`, in schema order. */
15
+ export const IMAGE_TREATMENT_FIELDS = Object.freeze(["fit", "focus", "aspectRatio", "shape", "cornerRadius", "border", "opacity", "recolor", "overlay", "placement"]);
16
+
17
+ const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
18
+ const bad = (message, details) => fail("invalid-image-treatment", message, details);
19
+ const fraction = (value, min = 0, max = 1) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
20
+ const colorRef = (value, what) => {
21
+ if (typeof value !== "string" || !value.trim() || !isAuthoringColorRef(value)) throw bad(`${what} must be a hex color such as #1F2937, a scheme color such as accent1 or surface, or var:<id>.`, { value });
22
+ return value.trim();
23
+ };
24
+
25
+ /** A focus point `{ x, y }`, each 0 (left/top) to 1 (right/bottom). */
26
+ export function normalizeFocus(value) {
27
+ if (!isObject(value) || !fraction(Number(value.x)) || !fraction(Number(value.y))) throw bad("A focus point is { x, y }, each from 0 (left or top) to 1 (right or bottom).", { value });
28
+ return { x: Number(value.x), y: Number(value.y) };
29
+ }
30
+
31
+ /**
32
+ * An overlay `{ color, opacity, edge?, size? }`, shared by image backgrounds and image blocks: a scrim over the whole picture, or a
33
+ * band along one edge (`size` 0.05 to 1, default 0.3). `color` is any ColorRef, theme roles included.
34
+ */
35
+ export function normalizeOverlay(value) {
36
+ if (!isObject(value)) throw bad("An overlay is { color, opacity }, with an optional edge and size for a band.", { value });
37
+ const out = { color: colorRef(value.color, "The overlay color"), opacity: Number(value.opacity) };
38
+ if (value.opacity === undefined || value.opacity === "" || !fraction(out.opacity)) throw bad("The overlay opacity is a number from 0 to 1.", { value });
39
+ if (value.edge !== undefined && value.edge !== null && value.edge !== "") {
40
+ if (!IMAGE_EDGES.includes(value.edge)) throw bad(`An overlay band runs along one edge: ${IMAGE_EDGES.join(", ")}.`, { value });
41
+ out.edge = value.edge;
42
+ if (value.size !== undefined && value.size !== null && value.size !== "") {
43
+ const size = Number(value.size);
44
+ if (!fraction(size, 0.05, 1)) throw bad("The overlay band size is a share of the picture from 0.05 to 1.", { value });
45
+ out.size = size;
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+
51
+ /** A recolor: `"grayscale"` or a duotone `{ dark, light }` (ColorRefs), shared by image backgrounds and image blocks. */
52
+ export function normalizeRecolor(value) {
53
+ if (value === "grayscale") return value;
54
+ if (isObject(value)) return { dark: colorRef(value.dark, "The duotone dark color"), light: colorRef(value.light, "The duotone light color") };
55
+ throw bad('Recolor is "grayscale" or a duotone { dark, light }.', { field: "recolor", value });
56
+ }
57
+
58
+ /** A placement `{ edge, size?, inset? }`: the block bleeds to that slide edge and takes `size` (0.1 to 0.9, default 0.5) of the slide. */
59
+ export function normalizePlacement(value) {
60
+ if (!isObject(value) || !IMAGE_EDGES.includes(value.edge)) throw bad(`A placement names the slide edge the image bleeds to: ${IMAGE_EDGES.join(", ")}.`, { value });
61
+ const out = { edge: value.edge };
62
+ if (value.size !== undefined && value.size !== null && value.size !== "") {
63
+ const size = Number(value.size);
64
+ if (!fraction(size, 0.1, 0.9)) throw bad("The placed image's share of the slide is from 0.1 to 0.9.", { value });
65
+ out.size = size;
66
+ }
67
+ if (value.inset === true) out.inset = true;
68
+ return out;
69
+ }
70
+
71
+ function normalizeField(key, value) {
72
+ switch (key) {
73
+ case "fit":
74
+ if (!IMAGE_FITS.includes(value)) throw bad(`Image fit is one of ${IMAGE_FITS.join(", ")}.`, { field: key, value });
75
+ return value;
76
+ case "focus":
77
+ return normalizeFocus(value);
78
+ case "aspectRatio": {
79
+ const ratio = Number(value);
80
+ if (!(Number.isFinite(ratio) && ratio > 0 && ratio <= 10)) throw bad("The frame's aspect ratio is a width-to-height number above 0 and up to 10, for example 1.78.", { field: key, value });
81
+ return ratio;
82
+ }
83
+ case "shape":
84
+ if (!IMAGE_SHAPES.includes(value)) throw bad(`Image shape is one of ${IMAGE_SHAPES.join(", ")}.`, { field: key, value });
85
+ return value;
86
+ case "cornerRadius": {
87
+ const radius = Number(value);
88
+ if (!fraction(radius, 0, 0.5)) throw bad("The corner radius is a share of the frame's shorter side from 0 to 0.5.", { field: key, value });
89
+ return radius;
90
+ }
91
+ case "border": {
92
+ if (!isObject(value)) throw bad("A border is { color, width }.", { field: key, value });
93
+ const width = Number(value.width);
94
+ if (!fraction(width, 0, 64)) throw bad("The border width is from 0 to 64 reference pixels.", { field: key, value });
95
+ return { color: colorRef(value.color, "The border color"), width };
96
+ }
97
+ case "opacity": {
98
+ const opacity = Number(value);
99
+ if (!fraction(opacity)) throw bad("Image opacity is a number from 0 to 1.", { field: key, value });
100
+ return opacity;
101
+ }
102
+ case "recolor":
103
+ return normalizeRecolor(value);
104
+ case "overlay":
105
+ return normalizeOverlay(value);
106
+ case "placement":
107
+ return normalizePlacement(value);
108
+ default:
109
+ throw bad(`Unknown image treatment: ${key}. Use ${IMAGE_TREATMENT_FIELDS.join(", ")}.`, { field: key });
110
+ }
111
+ }
112
+
113
+ /** The payload that holds the picture at `blockPath` (a block, a region payload or the slide itself for `Slide.image`). */
114
+ function imagePayload(presentation, blockPath) {
115
+ const parts = splitOpfPath(blockPath);
116
+ if (parts[0] === "slides" && parts.at(-1) === "image") parts.pop();
117
+ if (parts[0] !== "slides" || !presentation.slides?.[Number(parts[1])]) throw fail("not-an-image-block", "Choose an image block on a slide.", { path: blockPath });
118
+ // The slide's own `image` (Slide.image) takes no framing fields: the slide object is not an image block.
119
+ if (parts.length === 2) throw fail("not-an-image-block", "The slide's own image takes no fit, treatments or placement. Make it an image block (in blocks) to frame it.", { path: blockPath });
120
+ const payload = getValueAtPath(presentation, parts);
121
+ if (!isObject(payload) || payload.image === undefined) throw fail("not-an-image-block", "Choose an image block: these settings frame a picture.", { path: blockPath });
122
+ return { parts, payload };
123
+ }
124
+
125
+ // Only a top-level block (slides.N.blocks.I) may be placed (core's image-placement-invalid rule; the slide's own image cannot).
126
+ const placeable = (parts) => parts.length === 4 && parts[2] === "blocks";
127
+
128
+ /** Edges already taken by another placed top-level block of the slide. */
129
+ function usedEdges(presentation, parts) {
130
+ const slide = presentation.slides[Number(parts[1])];
131
+ const self = parts.length === 4 ? Number(parts[3]) : -1;
132
+ const edges = new Set();
133
+ for (const [index, block] of (Array.isArray(slide?.blocks) ? slide.blocks : []).entries()) if (index !== self && isObject(block?.placement)) edges.add(block.placement.edge);
134
+ return edges;
135
+ }
136
+
137
+ /**
138
+ * The image block's framing as a form shows it: `{ path, fit, focus, aspectRatio, shape, cornerRadius, border, opacity, recolor,
139
+ * overlay, placement, placeable, usedEdges }`. Fields the block does not set are undefined (the effective fit is then
140
+ * `design.imageFit`, else cover).
141
+ */
142
+ export function readImageTreatments(presentation, blockPath) {
143
+ const { parts, payload } = imagePayload(presentation, blockPath);
144
+ const out = { path: parts.join(".") };
145
+ for (const key of IMAGE_TREATMENT_FIELDS) out[key] = payload[key] === undefined ? undefined : structuredClone(payload[key]);
146
+ out.placeable = placeable(parts);
147
+ out.usedEdges = [...usedEdges(presentation, parts)];
148
+ return out;
149
+ }
150
+
151
+ /** The patches that set (a value) or remove (`null`) treatment fields of the image block at `blockPath`. */
152
+ export function imageTreatmentPatches(presentation, blockPath, fields) {
153
+ if (!isObject(fields)) throw bad("Pass the image settings to change, for example { fit: \"contain\" }.", { fields });
154
+ const { parts, payload } = imagePayload(presentation, blockPath);
155
+ const patches = [];
156
+ for (const [key, raw] of Object.entries(fields)) {
157
+ if (raw === undefined) continue;
158
+ const at = opfPathToJsonPointer([...parts, key]);
159
+ if (raw === null) {
160
+ if (payload[key] !== undefined) patches.push({ op: "remove", path: at });
161
+ continue;
162
+ }
163
+ const value = normalizeField(key, raw);
164
+ if (key === "placement") {
165
+ if (!placeable(parts)) throw fail("placement-not-top-level", "Only an image block directly in the slide's blocks can bleed to an edge, not one inside a group or region or the slide's own image.", { path: blockPath });
166
+ if (usedEdges(presentation, parts).has(value.edge)) throw fail("placement-edge-taken", `Another image already bleeds to the ${value.edge} edge of this slide. Choose another edge or move that image first.`, { path: blockPath, edge: value.edge });
167
+ }
168
+ if (payload[key] === undefined) patches.push({ op: "add", path: at, value });
169
+ else if (!same(payload[key], value)) patches.push({ op: "replace", path: at, value });
170
+ }
171
+ return patches;
172
+ }
173
+
174
+ /** Compute the patch for `setImageTreatment` without touching a session: `{ presentation, patches, changed, path, slideIndex }`. */
175
+ export function prepareImageTreatment(presentation, blockPath, fields) {
176
+ const before = checkFormat(presentation);
177
+ const patches = imageTreatmentPatches(presentation, blockPath, fields);
178
+ const next = checkedDocument(presentation, patches, before);
179
+ const parts = splitOpfPath(blockPath);
180
+ return { presentation: structuredClone(next), patches, changed: patches.length > 0, path: parts.join("."), slideIndex: Number(parts[1]) };
181
+ }
182
+
183
+ /**
184
+ * Set or remove (`null`) treatment fields of one image block as a single undoable transaction:
185
+ * `setImageTreatment(editor, "slides.1.blocks.0", { fit: "contain", placement: { edge: "right", size: 0.45 } })`.
186
+ */
187
+ export function setImageTreatment(editor, blockPath, fields, meta = {}) {
188
+ if (!editor || typeof editor.applyPatch !== "function") throw fail("invalid-editor", "Expected an editor session created by createEditorSession.");
189
+ const prepared = prepareImageTreatment(editor.presentation, blockPath, fields);
190
+ const { presentation, patches, ...summary } = prepared;
191
+ void presentation;
192
+ if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
193
+ const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "image-treatment", path: prepared.path });
194
+ return { ...change, ...summary };
195
+ }
package/dist/index.d.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  import type { PaginationOptions, PaginationResult } from "@openpresentation/opf/pagination";
2
2
  import type { Composition, ComposeSlideOptions, SlideComposition } from "@openpresentation/opf/composition";
3
- import type { Fonts, PresentationStats, SlideContextDiagnostic, StatsOptions, ValidationReport } from "@openpresentation/opf";
3
+ import type { Catalog, Fonts, PresentationStats, SlideContextDiagnostic, StatsOptions, ValidationReport } from "@openpresentation/opf";
4
+ export * from "./catalogs.js";
4
5
  /**
5
6
  * What the editor adds to the options of `composeSlide` and `paginateSlide`: `fonts` is the renderer's fonts handle (`loadFonts()`), whose
6
- * `textMeasurement` measures the text; `catalogs` are host catalog records; `onDiagnostic` hears every `unresolved-font-scheme`,
7
- * `unresolved-layout`, `unresolved-theme` and `unresolved-color-scheme` diagnostic of core's `resolveSlideContext`. Any other option overrides
8
- * the resolved one.
7
+ * `textMeasurement` measures the text; `catalogs` are host catalogs for this call (merged after the session's registered ones);
8
+ * `onDiagnostic` hears every diagnostic of core's `resolveSlideContext` (`unresolved-reference`). Any other
9
+ * option overrides the resolved one.
9
10
  */
10
11
  export interface EditorDiagnosticOptions {
11
12
  fonts?: Fonts;
12
- catalogs?: Record<string, readonly unknown[]>;
13
+ catalogs?: readonly Catalog[];
13
14
  onDiagnostic?: (diagnostic: SlideContextDiagnostic) => void;
14
15
  }
15
16
  export declare const packageName = "@openpresentation/opf-editor";
@@ -56,7 +57,8 @@ export interface EditorChange {
56
57
  }
57
58
 
58
59
  export interface EditorEvent {
59
- type: "patch" | "undo" | "redo" | "restore";
60
+ /** `catalogs`: the registered catalogs changed (`setCatalogs`); the document did not. */
61
+ type: "patch" | "undo" | "redo" | "restore" | "catalogs";
60
62
  patches: JsonPatchOperation[];
61
63
  inversePatches?: JsonPatchOperation[];
62
64
  redoPatches?: JsonPatchOperation[];
@@ -73,7 +75,12 @@ export interface EditorSession {
73
75
  composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
74
76
  setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
75
77
  readonly presentation: unknown;
78
+ /** The `format` report of the document plus the catalog reference rules, checked against the registered catalogs. */
76
79
  readonly validation: ValidationReport;
80
+ /** The host's registered catalogs, frozen; the first is the host default. */
81
+ readonly catalogs: readonly Catalog[];
82
+ /** Replace the registered catalogs: re-validates the document and emits one `catalogs` event. */
83
+ setCatalogs(catalogs: readonly Catalog[] | undefined, meta?: Record<string, unknown>): readonly Catalog[];
77
84
  readonly canUndo: boolean;
78
85
  readonly canRedo: boolean;
79
86
  snapshot(): EditorSnapshot;
@@ -94,18 +101,28 @@ export interface EditorSession {
94
101
  export interface CreateEditorSessionOptions {
95
102
  /** Refuse every edit whose result has an error finding (the default per edit is `meta.rejectInvalid`). */
96
103
  rejectInvalid?: boolean;
104
+ /** The host's catalogs (for example `[defaultCatalog]` from `@openpresentation/opf/catalog`): resolution, validation, pickers, embedding. */
105
+ catalogs?: readonly Catalog[];
97
106
  }
98
107
 
99
108
  export interface CatalogOption {
109
+ /** The reference to write: `id` or `name:id`. */
100
110
  id: string;
111
+ reference: string;
101
112
  label: string;
113
+ description?: string;
102
114
  record: Record<string, unknown>;
115
+ /** The catalog group the record resolves in (`custom`, `default` or a named group). */
116
+ group: string;
117
+ source?: string;
118
+ origin: "document" | "host";
103
119
  }
104
120
 
105
121
  export interface CatalogOptionsInput {
106
122
  presentation?: { catalogs?: Record<string, unknown> } | Record<string, unknown>;
107
- catalogs?: Record<string, unknown>;
108
- catalogSources?: Record<string, unknown>;
123
+ /** Host catalogs, merged after `editor`'s registered ones. */
124
+ catalogs?: readonly Catalog[];
125
+ editor?: EditorSession;
109
126
  [key: string]: unknown;
110
127
  }
111
128