@openpresentation/opf-editor 0.10.5 → 0.11.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 (76) hide show
  1. package/README.md +333 -8
  2. package/dist/annotations.d.ts +71 -0
  3. package/dist/annotations.js +281 -0
  4. package/dist/assets.d.ts +67 -0
  5. package/dist/assets.js +176 -0
  6. package/dist/background-options.d.ts +48 -0
  7. package/dist/background-options.js +134 -0
  8. package/dist/block-convert.d.ts +64 -0
  9. package/dist/block-convert.js +142 -0
  10. package/dist/canvas.d.ts +16 -0
  11. package/dist/canvas.js +82 -21
  12. package/dist/chart-data.d.ts +32 -0
  13. package/dist/chart-data.js +101 -0
  14. package/dist/chart-options-panel.d.ts +16 -0
  15. package/dist/chart-options-panel.js +127 -0
  16. package/dist/chart-options.d.ts +49 -0
  17. package/dist/chart-options.js +157 -0
  18. package/dist/content-actions.d.ts +91 -0
  19. package/dist/content-actions.js +207 -0
  20. package/dist/content-controls.js +326 -0
  21. package/dist/data-grid.d.ts +37 -0
  22. package/dist/data-grid.js +1035 -0
  23. package/dist/design-controls.d.ts +43 -0
  24. package/dist/design-controls.js +1077 -0
  25. package/dist/design-options.d.ts +108 -0
  26. package/dist/design-options.js +412 -0
  27. package/dist/edit-helpers.js +52 -0
  28. package/dist/export.d.ts +77 -0
  29. package/dist/export.js +216 -0
  30. package/dist/find-panel.d.ts +44 -0
  31. package/dist/find-panel.js +431 -0
  32. package/dist/find-replace.d.ts +100 -0
  33. package/dist/find-replace.js +374 -0
  34. package/dist/grid-model.d.ts +135 -0
  35. package/dist/grid-model.js +836 -0
  36. package/dist/grid-text.d.ts +33 -0
  37. package/dist/grid-text.js +251 -0
  38. package/dist/image-crop.d.ts +59 -0
  39. package/dist/image-crop.js +336 -0
  40. package/dist/image-cropper.d.ts +29 -0
  41. package/dist/image-cropper.js +519 -0
  42. package/dist/index.d.ts +11 -1
  43. package/dist/index.js +104 -171
  44. package/dist/numbering-panel.d.ts +21 -0
  45. package/dist/numbering-panel.js +200 -0
  46. package/dist/numbering.d.ts +62 -0
  47. package/dist/numbering.js +223 -0
  48. package/dist/outline-view.d.ts +17 -0
  49. package/dist/outline-view.js +278 -0
  50. package/dist/outline.d.ts +56 -0
  51. package/dist/outline.js +271 -0
  52. package/dist/persistence-ui.d.ts +24 -0
  53. package/dist/persistence-ui.js +81 -0
  54. package/dist/persistence.d.ts +105 -0
  55. package/dist/persistence.js +429 -0
  56. package/dist/review-panel.d.ts +44 -0
  57. package/dist/review-panel.js +359 -0
  58. package/dist/review.d.ts +75 -0
  59. package/dist/review.js +170 -0
  60. package/dist/slide-manager.d.ts +44 -0
  61. package/dist/slide-manager.js +695 -0
  62. package/dist/slides.d.ts +96 -0
  63. package/dist/slides.js +433 -0
  64. package/dist/switches.d.ts +26 -0
  65. package/dist/switches.js +127 -43
  66. package/dist/table-options.d.ts +80 -0
  67. package/dist/table-options.js +419 -0
  68. package/dist/table-structure.d.ts +30 -0
  69. package/dist/table-structure.js +92 -0
  70. package/dist/template-panel.d.ts +31 -0
  71. package/dist/template-panel.js +377 -0
  72. package/dist/templates.d.ts +126 -0
  73. package/dist/templates.js +331 -0
  74. package/dist/zip.d.ts +4 -0
  75. package/dist/zip.js +71 -0
  76. package/package.json +150 -10
package/dist/switches.js CHANGED
@@ -13,7 +13,9 @@ import {
13
13
  validateOpfDocument,
14
14
  } from "./index.js";
15
15
  import { createContentBlock, prepareBlockReplace } from "./blocks.js";
16
+ import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
16
17
  import { populateLayoutPlaceholders } from "./layout-placeholders.js";
18
+ import { blockConversionTargets, prepareBlockConversion } from "./block-convert.js";
17
19
 
18
20
  /** The 14 pptx.gallery dimensions (gallery-support.md), in the gallery's order. */
19
21
  export const SWITCH_DIMENSIONS = Object.freeze([
@@ -59,20 +61,6 @@ const DESIGN_KEYS = Object.freeze({
59
61
  const REGION = /^(?:(?:top|middle|bottom)(?:\+(?:top|middle|bottom))*(?::(?:left|center|right)(?:\+(?:left|center|right))*)?|(?:left|center|right)(?:\+(?:left|center|right))*)$/;
60
62
  const BLOCK_KINDS = ["text", "list", "chart", "table", "metric", "quote", "code", "timeline", "group", "image", "video"];
61
63
 
62
- function fail(code, message, details) {
63
- return new OPFEditorError(code, message, details);
64
- }
65
-
66
- function same(a, b) {
67
- return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
68
- }
69
- function canonical(value) {
70
- if (Array.isArray(value)) return value.map(canonical);
71
- if (value && typeof value === "object")
72
- return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
73
- return value;
74
- }
75
-
76
64
  function recordList(source) {
77
65
  const records = Array.isArray(source) ? source : source?.records;
78
66
  return Array.isArray(records) ? records.filter((record) => record && typeof record.id === "string") : [];
@@ -123,25 +111,6 @@ function rootPatch(document, field, value) {
123
111
  return same(document[field], value) ? [] : createValuePatch(document, [field], value);
124
112
  }
125
113
 
126
- // Set (value) or remove (null) design keys at deck or slide scope.
127
- function designPatches(document, base, entries) {
128
- const design = getValueAtPath(document, base.length ? [...base, "design"] : ["design"]);
129
- const at = (key) => opfPathToJsonPointer([...base, "design", key]);
130
- const set = Object.entries(entries).filter(([, value]) => value !== undefined && value !== null);
131
- if (!design || typeof design !== "object" || Array.isArray(design))
132
- return set.length ? [{ op: "add", path: opfPathToJsonPointer([...base, "design"]), value: structuredClone(Object.fromEntries(set)) }] : [];
133
- const patches = [];
134
- for (const [key, value] of Object.entries(entries)) {
135
- if (value === undefined) continue;
136
- const present = Object.hasOwn(design, key);
137
- if (value === null) {
138
- if (present) patches.push({ op: "remove", path: at(key) });
139
- } else if (!present) patches.push({ op: "add", path: at(key), value: structuredClone(value) });
140
- else if (!same(design[key], value)) patches.push({ op: "replace", path: at(key), value: structuredClone(value) });
141
- }
142
- return patches;
143
- }
144
-
145
114
  // A deck-level switch does nothing for a slide that carries its own value for that key.
146
115
  function shadowedSlides(document, keys) {
147
116
  return (document.slides ?? []).flatMap((slide, index) => (keys.some((key) => slide?.design?.[key] !== undefined) ? [index] : []));
@@ -213,6 +182,7 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
213
182
  let scope = "deck";
214
183
  let shadowed = [];
215
184
  let slideIndex;
185
+ let conversionLoss;
216
186
 
217
187
  if (dimension === "layouts") {
218
188
  slideIndex = scopeIndex;
@@ -244,9 +214,17 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
244
214
  patches = [...catalogRecordPatches(document, dimension, options), ...(chart.type === value ? [] : createValuePatch(document, [...owner, "chart", "type"], value))];
245
215
  } else if (dimension === "blocks") {
246
216
  if (options.path === undefined) throw fail("missing-path", "Choose the block to replace with options.path.");
247
- const block = blockValue(value, options);
248
- const change = prepareBlockReplace(document, options.path, block);
249
- patches = change.changed ? change.patches : [];
217
+ // convert: true moves the block's own text into the new kind (block-convert.js) instead of replacing it.
218
+ if (options.convert) {
219
+ if (typeof value !== "string") throw fail("invalid-switch-value", "Convert a block to a content kind name.", { value });
220
+ const conversion = prepareBlockConversion(document, options.path, value, options.conversion);
221
+ patches = conversion.changed ? conversion.patches : [];
222
+ conversionLoss = conversion.loss;
223
+ } else {
224
+ const block = blockValue(value, options);
225
+ const change = prepareBlockReplace(document, options.path, block);
226
+ patches = change.changed ? change.patches : [];
227
+ }
250
228
  const parts = splitOpfPath(options.path);
251
229
  if (parts[0] === "slides") slideIndex = Number(parts[1]);
252
230
  scope = "block";
@@ -276,11 +254,18 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
276
254
  } else if (dimension === "color-schemes" || dimension === "font-schemes") {
277
255
  requireCatalogId(document, dimension, value, options);
278
256
  entries = { [DESIGN_KEYS[dimension][0]]: value };
257
+ if (dimension === "font-schemes") {
258
+ // An accent font is its own choice, not part of the scheme being left: it stays (in the object form).
259
+ const scopeBase = scopeIndex !== undefined && document.slides?.[scopeIndex] ? ["slides", String(scopeIndex)] : [];
260
+ const own = getValueAtPath(document, [...scopeBase, "design", "fontScheme"]);
261
+ if (own && typeof own === "object" && own.accent !== undefined) entries.fontScheme = { id: value, accent: structuredClone(own.accent) };
262
+ }
279
263
  patches = catalogRecordPatches(document, dimension, options);
280
264
  } else {
281
265
  // A background is an object or a shorthand string (theme slot or hex color); the schema
282
266
  // validates the candidate document, so a string that is neither is rejected below.
283
- const valid = dimension === "backgrounds" ? typeof value === "string" || (Boolean(value) && typeof value === "object" && !Array.isArray(value)) : Boolean(value) && typeof value === "object" && !Array.isArray(value);
267
+ // null removes the background so the theme's (or, on a slide, the deck's) shows again.
268
+ const valid = dimension === "backgrounds" ? value === null || typeof value === "string" || (Boolean(value) && typeof value === "object" && !Array.isArray(value)) : Boolean(value) && typeof value === "object" && !Array.isArray(value);
284
269
  if (!valid)
285
270
  throw fail("invalid-switch-value", `Switch ${dimension} to ${dimension === "backgrounds" ? "a background object or shorthand string" : "an object with " + DESIGN_KEYS[dimension].join(" and ")}.`, { value });
286
271
  if (dimension === "backgrounds") entries = { background: value };
@@ -309,12 +294,7 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
309
294
  }
310
295
  }
311
296
 
312
- const next = patches.length ? applyJsonPatch(document, patches) : document;
313
- if (patches.length) {
314
- const validation = validateOpfDocument(next);
315
- if (!validation.valid && before.valid)
316
- throw fail("invalid-opf-edit", validation.errors[0]?.message ?? "This switch produces an invalid document.", { issues: validation.errors, patches });
317
- }
297
+ const next = checkedDocument(document, patches, before);
318
298
  return {
319
299
  dimension,
320
300
  scope,
@@ -323,6 +303,7 @@ export function prepareDimensionSwitch(document, dimension, value, options = {})
323
303
  patches,
324
304
  changed: patches.length > 0,
325
305
  shadowed,
306
+ ...(conversionLoss ? { loss: conversionLoss } : {}),
326
307
  };
327
308
  }
328
309
 
@@ -342,3 +323,106 @@ export function switchDimension(editor, dimension, value, options = {}) {
342
323
  const change = editor.applyPatch(patches, { ...meta, source: meta?.source ?? "dimension-switch", dimension, scope: prepared.scope });
343
324
  return { ...change, ...summary };
344
325
  }
326
+
327
+ // --- options for pickers and current values ---------------------------------------------------
328
+
329
+ const labelOf = (record) => record.label ?? record.name ?? record.id;
330
+
331
+ /**
332
+ * The values a picker can offer for a catalog-backed dimension, in document order: the document's
333
+ * inline records first (they override), then caller-loaded records, then the bundled catalog,
334
+ * without duplicates. `blocks` lists the content kinds. `charts` lists every chart type; use
335
+ * `compatibleChartTypes` to narrow it to the types the chart's data can use.
336
+ */
337
+ export function listSwitchOptions(document, dimension, options = {}) {
338
+ if (dimension === "blocks") return BLOCK_KINDS.map((kind) => ({ id: kind, label: kind[0].toUpperCase() + kind.slice(1) }));
339
+ const kind = CATALOG_KIND[dimension];
340
+ if (!kind) return [];
341
+ const seen = new Set();
342
+ const out = [];
343
+ for (const source of [document?.catalogs?.[kind], options.catalogs?.[kind] ?? options.catalogSources?.[kind], bundledCatalogs[kind]])
344
+ for (const record of recordList(source)) {
345
+ if (seen.has(record.id)) continue;
346
+ seen.add(record.id);
347
+ out.push({ id: record.id, label: labelOf(record), record });
348
+ }
349
+ return out;
350
+ }
351
+
352
+ const SINGLE_SERIES_ONLY = new Set(["pieChart", "doughnutChart", "funnelChart", "treemapChart", "waterfallChart"]);
353
+ const MULTI_SERIES_CAPABLE = new Set(["barChart", "lineChart", "areaChart", "radarChart"]);
354
+ const DISTRIBUTION_ELEMENTS = new Set(["histogramChart", "boxWhiskerChart", "mapChart"]);
355
+
356
+ function chartDataShape(chart) {
357
+ const data = chart?.data;
358
+ if (!data || !Array.isArray(data.columns) || !Array.isArray(data.rows)) return undefined;
359
+ // The renderers read the first column as the category label and every further column as a series.
360
+ return { series: Math.max(0, data.columns.length - 1), categories: data.rows.length };
361
+ }
362
+
363
+ /**
364
+ * Chart types the chart's inline data can use as it is, from the chartTypes catalog: simple,
365
+ * non-geographic, non-distribution types whose series count fits the data (the first column labels
366
+ * the categories and each further column is a series; a type with N series needs exactly N value
367
+ * columns; column, bar, line, area and radar take any number). Data that is read from
368
+ * an external source returns every simple type. This is data-shape compatibility, not a claim that
369
+ * an engine draws the type. `path` or `slideIndex` picks the chart (default: the slide's first).
370
+ * Each entry has `current: true` for the chart's present type, which is always listed.
371
+ */
372
+ export function compatibleChartTypes(document, options = {}) {
373
+ const slideIndex = options.slideIndex ?? 0;
374
+ const owner = options.path ? splitOpfPath(options.path) : document.slides?.[slideIndex] ? findChartOwner(document, slideIndex) : undefined;
375
+ const chart = owner && getValueAtPath(document, [...owner, "chart"]);
376
+ if (!chart || typeof chart !== "object") return [];
377
+ const shape = chartDataShape(chart);
378
+ const result = [];
379
+ for (const option of listSwitchOptions(document, "charts", options)) {
380
+ const record = option.record;
381
+ const element = record.mappings?.openxml?.element;
382
+ const current = option.id === chart.type;
383
+ const simple = record.complexity === "simple" && record.mappings?.openxml?.composition !== "mixed" && !DISTRIBUTION_ELEMENTS.has(element);
384
+ const seriesOk =
385
+ !shape || !record.series || (record.series > 1 ? record.series === shape.series : shape.series === 1 || (MULTI_SERIES_CAPABLE.has(element) && !SINGLE_SERIES_ONLY.has(element)));
386
+ if (current || (simple && seriesOk)) result.push({ id: option.id, label: option.label, current, record });
387
+ }
388
+ return result;
389
+ }
390
+
391
+ /**
392
+ * The value a dimension currently has, for pickers: `{ value, scope }` where scope is "slide" when
393
+ * `slideIndex` names a slide whose own design sets it, else "deck". `value` is undefined when the
394
+ * dimension is unset. Catalog dimensions return the catalog id even when the document holds an
395
+ * inline object.
396
+ */
397
+ export function currentSwitchValue(document, dimension, options = {}) {
398
+ const idOf = (reference) => (reference && typeof reference === "object" && !Array.isArray(reference) ? reference.id : reference);
399
+ const design = (key) => {
400
+ const slide = options.slideIndex !== undefined ? document.slides?.[options.slideIndex]?.design?.[key] : undefined;
401
+ if (slide !== undefined) return { value: slide, scope: "slide" };
402
+ return document.design?.[key] === undefined ? { scope: "deck" } : { value: document.design[key], scope: "deck" };
403
+ };
404
+ if (dimension === "layouts") return { value: document.slides?.[options.slideIndex]?.layout, scope: "slide" };
405
+ if (dimension === "charts") {
406
+ const owner = options.path ? splitOpfPath(options.path) : findChartOwner(document, options.slideIndex ?? 0);
407
+ return { value: owner ? getValueAtPath(document, [...owner, "chart", "type"]) : undefined, scope: "slide" };
408
+ }
409
+ if (dimension === "blocks") return { value: undefined, scope: "block" };
410
+ if (dimension === "socials") {
411
+ const host = document[options.owner ?? "speaker"];
412
+ const target = Array.isArray(host) ? host[options.index ?? 0] : host;
413
+ return { value: target?.socials, scope: "deck" };
414
+ }
415
+ if (ROOT_FIELD[dimension]) return { value: document[ROOT_FIELD[dimension]], scope: "deck" };
416
+ if (dimension === "color-schemes") return { ...design("colorScheme"), value: idOf(design("colorScheme").value) };
417
+ if (dimension === "font-schemes") return { ...design("fontScheme"), value: idOf(design("fontScheme").value) };
418
+ if (dimension === "themes") return { ...design("theme"), value: idOf(design("theme").value) };
419
+ if (dimension === "backgrounds") return design("background");
420
+ const keys = DESIGN_KEYS[dimension];
421
+ if (keys) {
422
+ const found = keys.map((key) => design(key));
423
+ return { value: Object.fromEntries(keys.map((key, index) => [key, found[index].value])), scope: found.some((entry) => entry.scope === "slide") ? "slide" : "deck" };
424
+ }
425
+ return { scope: "deck" };
426
+ }
427
+
428
+ export { blockConversionTargets };
@@ -0,0 +1,80 @@
1
+ import type { EditorChange, EditorSession, JsonPatchOperation } from "./index.js";
2
+
3
+ export interface TableCellAddress {
4
+ section: "header" | "body";
5
+ /** Body row; ignored for the header section. */
6
+ row?: number;
7
+ column: number;
8
+ }
9
+ export interface TableStyle {
10
+ /** "theme": the renderer default (primary fill). "plain": the header looks like the body. "accent": accent fill. */
11
+ header: "theme" | "plain" | "accent";
12
+ /** Alternating body-row fill. */
13
+ banding: boolean;
14
+ /** "theme" keeps the default thin borders; "none" removes them; "horizontal" keeps row rules; "grid" draws every edge. */
15
+ borders: "theme" | "none" | "horizontal" | "grid";
16
+ }
17
+ export declare const TABLE_STYLE_PRESETS: Readonly<Record<"theme" | "banded" | "grid" | "minimal" | "open", Readonly<TableStyle>>>;
18
+ export declare const TABLE_HEADER_STYLES: readonly ["theme", "plain", "accent"];
19
+ export declare const TABLE_BORDER_STYLES: readonly ["theme", "none", "horizontal", "grid"];
20
+ export interface TableMerge {
21
+ section: "header" | "body";
22
+ row: number;
23
+ column: number;
24
+ rowSpan: number;
25
+ colSpan: number;
26
+ }
27
+ export interface TableCellState extends TableCellAddress {
28
+ value: unknown;
29
+ style: Record<string, unknown>;
30
+ colSpan: number;
31
+ rowSpan: number;
32
+ merged: boolean;
33
+ anchor: boolean;
34
+ covered: boolean;
35
+ anchorCell?: TableCellAddress;
36
+ columns: number;
37
+ rows: number;
38
+ }
39
+ export interface PreparedTableChange {
40
+ action: "merge" | "split" | "style" | "table-style";
41
+ tablePath: string;
42
+ document: unknown;
43
+ patches: JsonPatchOperation[];
44
+ changed: boolean;
45
+ [key: string]: unknown;
46
+ }
47
+ export interface TableChange extends Omit<EditorChange, "document" | "patches"> {
48
+ action: PreparedTableChange["action"];
49
+ tablePath: string;
50
+ document: unknown;
51
+ patches: JsonPatchOperation[];
52
+ changed: boolean;
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ /** The table path and cell a selection path points at, or undefined. */
57
+ export declare function parseTableCellPath(path: string): { tablePath: string; cell: Required<TableCellAddress> } | undefined;
58
+ /** Merge rectangles of the table. */
59
+ export declare function tableMerges(table: unknown): TableMerge[];
60
+ /** The state of one cell, including merge anchor/covered status. */
61
+ export declare function describeTableCell(table: unknown, cell: TableCellAddress): TableCellState;
62
+ /** Merge `span.colSpan` x `span.rowSpan` cells from `cell`. `join` keeps the text of covered cells by joining it into the anchor; without it, covered text refuses with `merge-would-lose-content`. */
63
+ export declare function prepareTableMerge(document: unknown, tablePath: string, cell: TableCellAddress, span: { colSpan?: number; rowSpan?: number }, options?: { join?: boolean }): PreparedTableChange;
64
+ export declare function prepareTableSplit(document: unknown, tablePath: string, cell: TableCellAddress): PreparedTableChange;
65
+ /** Merge style fields into the cells' styles (null removes a field, `style: null` clears). */
66
+ export declare function prepareTableCellStyle(document: unknown, tablePath: string, cells: TableCellAddress | TableCellAddress[], style: Record<string, unknown> | null): PreparedTableChange;
67
+ /** Apply `{ header, banding, borders }` (missing fields mean theme/false) or a named preset: sets the header fill, banded-row fill and borders of every cell and clears them elsewhere; `theme` removes a previous style. */
68
+ export declare function prepareTableStyle(document: unknown, tablePath: string, preset: keyof typeof TABLE_STYLE_PRESETS | Partial<TableStyle>): PreparedTableChange;
69
+ /** The current style per axis, or "custom" in every field when the fills and borders are not one this module writes. */
70
+ export declare function readTableStyle(document: unknown, tablePath: string): (TableStyle | { header: "custom"; banding: "custom"; borders: "custom" }) & { preset: keyof typeof TABLE_STYLE_PRESETS | "custom" };
71
+ export declare function mergeTableCells(editor: EditorSession, tablePath: string, cell: TableCellAddress, span: { colSpan?: number; rowSpan?: number }, options?: { join?: boolean; meta?: Record<string, unknown> }): TableChange;
72
+ export declare function splitTableCell(editor: EditorSession, tablePath: string, cell: TableCellAddress, meta?: Record<string, unknown>): TableChange;
73
+ export declare function setTableCellStyle(editor: EditorSession, tablePath: string, cells: TableCellAddress | TableCellAddress[], style: Record<string, unknown> | null, meta?: Record<string, unknown>): TableChange;
74
+ export declare function setTableStyle(editor: EditorSession, tablePath: string, preset: keyof typeof TABLE_STYLE_PRESETS | Partial<TableStyle>, meta?: Record<string, unknown>): TableChange;
75
+ /** {@link readTableStyle} for a table object. */
76
+ export declare function readTableStyleOfTable(table: unknown): (TableStyle | { header: "custom"; banding: "custom"; borders: "custom" }) & { preset: keyof typeof TABLE_STYLE_PRESETS | "custom" };
77
+ /** Apply a table style to a table object in place (the structure operations use it to keep banding, header fill and borders correct). */
78
+ export declare function applyTableStyleToTable(table: unknown, preset: keyof typeof TABLE_STYLE_PRESETS | Partial<TableStyle>): TableStyle;
79
+ // Rows, columns, header row and cell values (RR-24).
80
+ export * from "./table-structure.js";