@openpresentation/opf-editor 0.12.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +122 -80
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +16 -16
  6. package/dist/background-options.d.ts +12 -5
  7. package/dist/background-options.js +44 -19
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +22 -21
  11. package/dist/blocks.d.ts +8 -8
  12. package/dist/blocks.js +41 -40
  13. package/dist/canvas-fields.js +35 -12
  14. package/dist/canvas.d.ts +30 -31
  15. package/dist/canvas.js +64 -68
  16. package/dist/catalogs.d.ts +48 -0
  17. package/dist/catalogs.js +217 -0
  18. package/dist/chart-data.d.ts +10 -10
  19. package/dist/chart-data.js +21 -21
  20. package/dist/chart-options-panel.d.ts +2 -2
  21. package/dist/chart-options-panel.js +82 -8
  22. package/dist/chart-options.d.ts +35 -9
  23. package/dist/chart-options.js +133 -40
  24. package/dist/checks.js +23 -0
  25. package/dist/color-authoring.js +1 -1
  26. package/dist/content-actions.d.ts +17 -17
  27. package/dist/content-actions.js +74 -73
  28. package/dist/content-controls.js +21 -21
  29. package/dist/data-grid.js +7 -8
  30. package/dist/data.d.ts +4 -4
  31. package/dist/data.js +5 -5
  32. package/dist/dataset-refs.d.ts +2 -2
  33. package/dist/dataset-refs.js +5 -5
  34. package/dist/design-controls.d.ts +12 -5
  35. package/dist/design-controls.js +312 -111
  36. package/dist/design-options.d.ts +20 -18
  37. package/dist/design-options.js +91 -99
  38. package/dist/edit-helpers.js +8 -7
  39. package/dist/exact-source.js +5 -5
  40. package/dist/export.d.ts +7 -7
  41. package/dist/export.js +48 -17
  42. package/dist/find-panel.js +5 -5
  43. package/dist/find-replace.d.ts +3 -3
  44. package/dist/find-replace.js +23 -20
  45. package/dist/font-gate.js +49 -58
  46. package/dist/galleries.js +50 -101
  47. package/dist/grid-model.d.ts +25 -27
  48. package/dist/grid-model.js +92 -102
  49. package/dist/image-crop.d.ts +4 -4
  50. package/dist/image-crop.js +28 -29
  51. package/dist/image-cropper.js +3 -3
  52. package/dist/image-options.d.ts +45 -0
  53. package/dist/image-options.js +195 -0
  54. package/dist/index.d.ts +48 -31
  55. package/dist/index.js +154 -126
  56. package/dist/json-editor.js +2 -2
  57. package/dist/json-menu.js +2 -2
  58. package/dist/json-options.d.ts +3 -3
  59. package/dist/json-options.js +44 -54
  60. package/dist/layout-handles.js +5 -5
  61. package/dist/layout-placeholders.js +4 -4
  62. package/dist/layout.d.ts +2 -2
  63. package/dist/layout.js +8 -7
  64. package/dist/numbering-panel.js +3 -5
  65. package/dist/numbering.d.ts +3 -5
  66. package/dist/numbering.js +14 -26
  67. package/dist/outline-view.js +24 -24
  68. package/dist/outline.d.ts +12 -12
  69. package/dist/outline.js +52 -50
  70. package/dist/persistence.d.ts +1 -1
  71. package/dist/persistence.js +19 -18
  72. package/dist/presentation-ids.js +2 -2
  73. package/dist/react.js +3 -3
  74. package/dist/review-panel.d.ts +30 -13
  75. package/dist/review-panel.js +160 -53
  76. package/dist/review.d.ts +36 -43
  77. package/dist/review.js +92 -53
  78. package/dist/rich-text-input.js +4 -3
  79. package/dist/rich-text-toolbar.js +5 -3
  80. package/dist/rich-text.js +5 -2
  81. package/dist/schema-inspector.d.ts +2 -2
  82. package/dist/schema-inspector.js +16 -11
  83. package/dist/schema.d.ts +1 -1
  84. package/dist/schema.js +26 -6
  85. package/dist/slide-manager.d.ts +2 -2
  86. package/dist/slide-manager.js +19 -19
  87. package/dist/slides.d.ts +21 -21
  88. package/dist/slides.js +104 -101
  89. package/dist/svelte.js +2 -3
  90. package/dist/switches.d.ts +37 -22
  91. package/dist/switches.js +282 -171
  92. package/dist/table-options.d.ts +8 -8
  93. package/dist/table-options.js +28 -27
  94. package/dist/table-structure.d.ts +10 -10
  95. package/dist/table-structure.js +21 -21
  96. package/dist/template-panel.d.ts +3 -3
  97. package/dist/template-panel.js +37 -13
  98. package/dist/templates.d.ts +22 -11
  99. package/dist/templates.js +48 -55
  100. package/dist/transfer.d.ts +15 -4
  101. package/dist/transfer.js +107 -161
  102. package/package.json +12 -6
  103. package/dist/font-defaults.js +0 -25
@@ -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
@@ -156,11 +155,11 @@ export const isFullRect = (rect, bounds) => rect.x < 0.5 && rect.y < 0.5 && Math
156
155
  * (the field is `{ src, ... }`); `assetSrc` is what `src` resolves to (the asset's own `src`), and `origin` is the asset a
157
156
  * crop of this picture was made from, when there is one.
158
157
  */
159
- export function describeImage(document, path) {
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." };
163
- const value = getValueAtPath(document, path);
161
+ if (last !== "image") return { error: "This is not a picture." };
162
+ const value = getValueAtPath(presentation, path);
164
163
  const pointer = opfPathToJsonPointer(segments);
165
164
  let form;
166
165
  let src;
@@ -177,7 +176,7 @@ export function describeImage(document, path) {
177
176
  let assetSrc = src;
178
177
  let entry;
179
178
  if (assetId !== undefined) {
180
- entry = getValueAtPath(document, ["assets", assetId]);
179
+ entry = getValueAtPath(presentation, ["assets", assetId]);
181
180
  if (entry === undefined) return { error: `The picture asset "${assetId}" is missing from this presentation.` };
182
181
  assetSrc = typeof entry === "string" ? entry : entry?.src;
183
182
  if (typeof assetSrc !== "string") return { error: `The picture asset "${assetId}" has no source.` };
@@ -193,22 +192,22 @@ export function describeImage(document, path) {
193
192
  }
194
193
 
195
194
  /** How many places in the document use `asset:<id>` (the `assets` map itself is not counted). */
196
- export function countAssetReferences(document, id) {
195
+ export function countAssetReferences(presentation, id) {
197
196
  const reference = `asset:${id}`;
198
197
  let count = 0;
199
198
  const walk = (value) => {
200
199
  if (typeof value === "string") count += value === reference ? 1 : 0;
201
200
  else if (Array.isArray(value)) value.forEach(walk);
202
- else if (isObject(value)) for (const [key, child] of Object.entries(value)) if (key !== "assets" || value !== document) walk(child);
201
+ else if (isObject(value)) for (const [key, child] of Object.entries(value)) if (key !== "assets" || value !== presentation) walk(child);
203
202
  };
204
- walk(document);
203
+ walk(presentation);
205
204
  return count;
206
205
  }
207
206
 
208
207
  // ---- the change --------------------------------------------------------------------------------------------------------
209
208
 
210
- function assetPatches(document, id, entry) {
211
- const assets = document?.assets;
209
+ function assetPatches(presentation, id, entry) {
210
+ const assets = presentation?.assets;
212
211
  return isObject(assets)
213
212
  ? [{ op: "add", path: opfPathToJsonPointer(["assets", id]), value: entry }]
214
213
  : [{ op: "add", path: "/assets", value: { [id]: entry } }];
@@ -219,33 +218,33 @@ function assetPatches(document, id, entry) {
219
218
  * pointing at it. `pixels` is `{ dataUri, mediaType, width, height }` (from `cropImagePixels`). A previous crop's asset that
220
219
  * nothing else uses is removed in the same change. Pure.
221
220
  */
222
- export function prepareCrop(document, path, pixels) {
223
- const image = describeImage(document, path);
221
+ export function prepareCrop(presentation, path, pixels) {
222
+ const image = describeImage(presentation, path);
224
223
  if (image.error) throw fail("not-croppable", image.error, { path });
225
224
  const origin = image.origin ?? image.assetId;
226
- const id = uniqueAssetId(document, `${origin ?? "image"}-crop`);
225
+ const id = uniqueAssetId(presentation, `${origin ?? "image"}-crop`);
227
226
  const title = (isObject(image.entry) && typeof image.entry.title === "string" ? image.entry.title : origin ?? "Picture").replace(/ \(cropped\)$/, "");
228
227
  const entry = { src: pixels.dataUri, mediaType: pixels.mediaType, title: `${title} (cropped)` };
229
228
  if (image.alt) entry.alt = image.alt;
230
229
  if (origin) entry.description = `Cropped from asset:${origin}`;
231
- const patches = assetPatches(document, id, entry);
230
+ const patches = assetPatches(presentation, id, entry);
232
231
  patches.push({ op: "replace", path: image.srcPointer, value: `asset:${id}` });
233
- if (image.origin && image.assetId && countAssetReferences(document, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
232
+ if (image.origin && image.assetId && countAssetReferences(presentation, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
234
233
  return { patches, assetId: id, reference: `asset:${id}`, origin, image };
235
234
  }
236
235
 
237
236
  /** The patches that put a cropped picture back to the asset it was cropped from, or null when there is none. */
238
- export function prepareRestore(document, path) {
239
- const image = describeImage(document, path);
240
- if (image.error || !image.origin || getValueAtPath(document, ["assets", image.origin]) === undefined) return null;
237
+ export function prepareRestore(presentation, path) {
238
+ const image = describeImage(presentation, path);
239
+ if (image.error || !image.origin || getValueAtPath(presentation, ["assets", image.origin]) === undefined) return null;
241
240
  const patches = [{ op: "replace", path: image.srcPointer, value: `asset:${image.origin}` }];
242
- if (countAssetReferences(document, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
241
+ if (countAssetReferences(presentation, image.assetId) === 1) patches.push({ op: "remove", path: opfPathToJsonPointer(["assets", image.assetId]) });
243
242
  return { patches, origin: image.origin, image };
244
243
  }
245
244
 
246
245
  /** Put a cropped picture back to its original as one undoable change. Returns null when there is no original to restore. */
247
246
  export function restoreOriginal(editor, path, meta = {}) {
248
- const prepared = prepareRestore(editor.document, path);
247
+ const prepared = prepareRestore(editor.presentation, path);
249
248
  if (!prepared) return null;
250
249
  return { ...editor.applyPatch(prepared.patches, { ...meta, source: meta.source ?? "image-restore", path }), origin: prepared.origin };
251
250
  }
@@ -325,12 +324,12 @@ export async function cropImagePixels(loaded, rect, { mediaType, maxBytes = DEFA
325
324
  * `{ assetId, reference, width, height }`.
326
325
  */
327
326
  export async function applyCrop(editor, path, rect, options = {}) {
328
- const image = describeImage(editor.document, path);
327
+ const image = describeImage(editor.presentation, path);
329
328
  if (image.error) throw fail("not-croppable", image.error, { path });
330
329
  const loaded = options.loaded ?? await loadImagePixels(image.assetSrc);
331
330
  const pixels = await cropImagePixels(loaded, rect, { mediaType: image.mediaType, maxBytes: options.maxBytes });
332
331
  // The document may have changed while the picture was encoding.
333
- const prepared = prepareCrop(editor.document, path, pixels);
332
+ const prepared = prepareCrop(editor.presentation, path, pixels);
334
333
  const change = editor.applyPatch(prepared.patches, { ...options.meta, source: options.meta?.source ?? "image-crop", path, assetId: prepared.assetId });
335
334
  return { ...change, assetId: prepared.assetId, reference: prepared.reference, width: pixels.width, height: pixels.height };
336
335
  }
@@ -121,7 +121,7 @@ export function createImageCropper(root, overlay, options) {
121
121
 
122
122
  function placePill() {
123
123
  const target = selected && !session ? frameImage(selected) : null;
124
- if (!target || !editor || describeImage(editor.document, selected).error) { pill.hidden = true; return; }
124
+ if (!target || !editor || describeImage(editor.presentation, selected).error) { pill.hidden = true; return; }
125
125
  const box = target.getBoundingClientRect();
126
126
  const base = root.getBoundingClientRect();
127
127
  if (!box.width || !box.height) { pill.hidden = true; return; }
@@ -135,7 +135,7 @@ export function createImageCropper(root, overlay, options) {
135
135
  async function open(path, openOptions = {}) {
136
136
  if (destroyed || session) return false;
137
137
  if (options.beforeOpen && !options.beforeOpen()) return false;
138
- const image = describeImage(editor.document, path);
138
+ const image = describeImage(editor.presentation, path);
139
139
  if (image.error) {
140
140
  options.report?.(new Error(image.error));
141
141
  return false;
@@ -483,7 +483,7 @@ export function createImageCropper(root, overlay, options) {
483
483
  aspectSelect.querySelector('option[value="frame"]').disabled = !frame;
484
484
  s.rect = fullRect(s.loaded.width, s.loaded.height);
485
485
  layout();
486
- restoreButton.hidden = !prepareRestore(editor.document, path);
486
+ restoreButton.hidden = !prepareRestore(editor.presentation, path);
487
487
  if (s.tool === "focus") {
488
488
  s.tool = "crop";
489
489
  setTool("focus");
@@ -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,8 +1,18 @@
1
1
  import type { PaginationOptions, PaginationResult } from "@openpresentation/opf/pagination";
2
2
  import type { Composition, ComposeSlideOptions, SlideComposition } from "@openpresentation/opf/composition";
3
- /** Reported when a font-scheme id matches no inline or bundled record; the default font scheme (`aptos`) is the base. */
4
- export interface FontSchemeDiagnostic { code: "unresolved-font-scheme"; path: string; message: string; id: string; fallback: string }
5
- export interface EditorDiagnosticOptions { onDiagnostic?: (diagnostic: FontSchemeDiagnostic) => void }
3
+ import type { Catalog, Fonts, PresentationStats, SlideContextDiagnostic, StatsOptions, ValidationReport } from "@openpresentation/opf";
4
+ export * from "./catalogs.js";
5
+ /**
6
+ * What the editor adds to the options of `composeSlide` and `paginateSlide`: `fonts` is the renderer's fonts handle (`loadFonts()`), whose
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.
10
+ */
11
+ export interface EditorDiagnosticOptions {
12
+ fonts?: Fonts;
13
+ catalogs?: readonly Catalog[];
14
+ onDiagnostic?: (diagnostic: SlideContextDiagnostic) => void;
15
+ }
6
16
  export declare const packageName = "@openpresentation/opf-editor";
7
17
 
8
18
  export declare const releaseLane: Readonly<{
@@ -29,16 +39,9 @@ export type JsonPatchOperation =
29
39
  | { op: "copy"; from: string; path: string }
30
40
  | { op: "test"; path: string; value: unknown };
31
41
 
32
- export interface OPFValidationSummary {
33
- valid: boolean;
34
- errors: unknown[];
35
- warnings: unknown[];
36
- result: unknown;
37
- }
38
-
39
42
  export interface EditorSnapshot {
40
- document: unknown;
41
- validation: OPFValidationSummary;
43
+ presentation: unknown;
44
+ validation: ValidationReport;
42
45
  canUndo: boolean;
43
46
  canRedo: boolean;
44
47
  undoDepth: number;
@@ -46,19 +49,20 @@ export interface EditorSnapshot {
46
49
  }
47
50
 
48
51
  export interface EditorChange {
49
- document: unknown;
52
+ presentation: unknown;
50
53
  patches: JsonPatchOperation[];
51
54
  inversePatches?: JsonPatchOperation[];
52
55
  redoPatches?: JsonPatchOperation[];
53
- validation: OPFValidationSummary;
56
+ validation: ValidationReport;
54
57
  }
55
58
 
56
59
  export interface EditorEvent {
57
- type: "patch" | "undo" | "redo" | "restore";
60
+ /** `catalogs`: the registered catalogs changed (`setCatalogs`); the document did not. */
61
+ type: "patch" | "undo" | "redo" | "restore" | "catalogs";
58
62
  patches: JsonPatchOperation[];
59
63
  inversePatches?: JsonPatchOperation[];
60
64
  redoPatches?: JsonPatchOperation[];
61
- validation: OPFValidationSummary;
65
+ validation: ValidationReport;
62
66
  meta?: Record<string, unknown>;
63
67
  snapshot: EditorSnapshot;
64
68
  }
@@ -70,8 +74,13 @@ export interface EditorSession {
70
74
  paginateSlide(slideIndex: number, options?: PaginationOptions & EditorDiagnosticOptions, meta?: Record<string, unknown>): { change: EditorChange | null; pagination: PaginationResult };
71
75
  composeSlide(slideIndex: number, options?: ComposeSlideOptions & EditorDiagnosticOptions): SlideComposition;
72
76
  setComposition(slideIndex: number, composition: Composition, meta?: Record<string, unknown>): EditorChange;
73
- readonly document: unknown;
74
- readonly validation: OPFValidationSummary;
77
+ readonly presentation: unknown;
78
+ /** The `format` report of the document plus the catalog reference rules, checked against the registered catalogs. */
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[];
75
84
  readonly canUndo: boolean;
76
85
  readonly canRedo: boolean;
77
86
  snapshot(): EditorSnapshot;
@@ -84,26 +93,36 @@ export interface EditorSession {
84
93
  /** The undo and redo stacks as plain data (oldest entry first), for hosts that persist work. */
85
94
  exportHistory(): EditorHistory;
86
95
  /** Replace the document and, optionally, the history in one step (one `restore` event). A history that does not replay against the document throws `invalid-history` before anything changes. */
87
- restoreState(state: { document: unknown; undo?: EditorHistoryEntry[]; redo?: EditorHistoryEntry[] }, meta?: Record<string, unknown>): EditorChange;
96
+ restoreState(state: { presentation: unknown; undo?: EditorHistoryEntry[]; redo?: EditorHistoryEntry[] }, meta?: Record<string, unknown>): EditorChange;
88
97
  undo(meta?: Record<string, unknown>): EditorChange | null;
89
98
  redo(meta?: Record<string, unknown>): EditorChange | null;
90
99
  }
91
100
 
92
101
  export interface CreateEditorSessionOptions {
93
- validate?: (document: unknown) => unknown;
102
+ /** Refuse every edit whose result has an error finding (the default per edit is `meta.rejectInvalid`). */
94
103
  rejectInvalid?: boolean;
104
+ /** The host's catalogs (for example `[defaultCatalog]` from `@openpresentation/opf/catalog`): resolution, validation, pickers, embedding. */
105
+ catalogs?: readonly Catalog[];
95
106
  }
96
107
 
97
108
  export interface CatalogOption {
109
+ /** The reference to write: `id` or `name:id`. */
98
110
  id: string;
111
+ reference: string;
99
112
  label: string;
113
+ description?: string;
100
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";
101
119
  }
102
120
 
103
121
  export interface CatalogOptionsInput {
104
122
  presentation?: { catalogs?: Record<string, unknown> } | Record<string, unknown>;
105
- catalogs?: Record<string, unknown>;
106
- catalogSources?: Record<string, unknown>;
123
+ /** Host catalogs, merged after `editor`'s registered ones. */
124
+ catalogs?: readonly Catalog[];
125
+ editor?: EditorSession;
107
126
  [key: string]: unknown;
108
127
  }
109
128
 
@@ -160,20 +179,18 @@ export declare function opfPathToJsonPointer(path: string | string[]): string;
160
179
 
161
180
  export declare function jsonPointerToOpfPath(pointer: string): string;
162
181
 
163
- export declare function getValueAtPath(document: unknown, path: string | string[], fallback?: unknown): unknown;
164
-
165
- export declare function hasValueAtPath(document: unknown, path: string | string[]): boolean;
182
+ export declare function getValueAtPath(presentation: unknown, path: string | string[], fallback?: unknown): unknown;
166
183
 
167
- export declare function createValuePatch(document: unknown, path: string | string[], value: unknown): JsonPatchOperation[];
184
+ export declare function hasValueAtPath(presentation: unknown, path: string | string[]): boolean;
168
185
 
169
- export declare function applyJsonPatch(document: unknown, operations: JsonPatchOperation[]): unknown;
186
+ export declare function createValuePatch(presentation: unknown, path: string | string[], value: unknown): JsonPatchOperation[];
170
187
 
171
- export declare function invertJsonPatch(document: unknown, operations: JsonPatchOperation[]): JsonPatchOperation[];
188
+ export declare function applyJsonPatch(presentation: unknown, operations: JsonPatchOperation[]): unknown;
172
189
 
173
- export declare function validateOpfDocument(document: unknown, validator?: (document: unknown) => unknown): OPFValidationSummary;
190
+ export declare function invertJsonPatch(presentation: unknown, operations: JsonPatchOperation[]): JsonPatchOperation[];
174
191
 
175
- /** Font families the slide resolves to (slide, deck, theme, then the shared default scheme). */
176
- export declare function resolveSlideFonts(document: unknown, slideIndex?: number, options?: EditorDiagnosticOptions): { heading?: string; body?: string; code?: string; [role: string]: string | undefined };
192
+ /** Core's `stats` of the session's document: neutral facts (counts, words, notes coverage, images and alt text, speaking time), never severities. Reads the JSON only. */
193
+ export declare function deckStats(editor: EditorSession, options?: StatsOptions): PresentationStats;
177
194
 
178
195
  export declare function createEditorSession(input: unknown, options?: CreateEditorSessionOptions): EditorSession;
179
196