@openpresentation/opf-editor 0.14.2 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +93 -35
  2. package/dist/assets.js +1 -1
  3. package/dist/background-options.d.ts +10 -3
  4. package/dist/background-options.js +38 -13
  5. package/dist/canvas.d.ts +8 -1
  6. package/dist/canvas.js +25 -10
  7. package/dist/catalog-control.js +21 -0
  8. package/dist/catalogs.d.ts +48 -0
  9. package/dist/catalogs.js +217 -0
  10. package/dist/checks.js +9 -7
  11. package/dist/content-actions.d.ts +2 -2
  12. package/dist/content-actions.js +3 -3
  13. package/dist/content-controls.js +9 -9
  14. package/dist/design-controls.d.ts +12 -5
  15. package/dist/design-controls.js +286 -82
  16. package/dist/design-options.d.ts +4 -4
  17. package/dist/design-options.js +6 -26
  18. package/dist/export.d.ts +8 -1
  19. package/dist/export.js +14 -4
  20. package/dist/find-replace.js +1 -0
  21. package/dist/font-gate.js +0 -11
  22. package/dist/galleries.js +32 -67
  23. package/dist/image-crop.js +8 -9
  24. package/dist/image-options.d.ts +45 -0
  25. package/dist/image-options.js +195 -0
  26. package/dist/index.d.ts +26 -8
  27. package/dist/index.js +107 -51
  28. package/dist/json-editor.js +2 -2
  29. package/dist/json-menu.js +2 -2
  30. package/dist/json-options.d.ts +3 -3
  31. package/dist/json-options.js +37 -47
  32. package/dist/layout-handles.js +13 -0
  33. package/dist/layout-placeholders.js +15 -0
  34. package/dist/react.d.ts +1 -0
  35. package/dist/react.js +6 -2
  36. package/dist/review-panel.js +8 -2
  37. package/dist/schema-inspector.js +1 -1
  38. package/dist/schema.js +1 -1
  39. package/dist/slide-manager.js +4 -2
  40. package/dist/slides.d.ts +1 -1
  41. package/dist/slides.js +6 -5
  42. package/dist/svelte.d.ts +1 -0
  43. package/dist/svelte.js +4 -4
  44. package/dist/switches.d.ts +31 -16
  45. package/dist/switches.js +194 -92
  46. package/dist/table-options.d.ts +6 -1
  47. package/dist/table-options.js +43 -0
  48. package/dist/transfer.d.ts +12 -1
  49. package/dist/transfer.js +87 -131
  50. package/package.json +22 -9
@@ -34,15 +34,18 @@ import {
34
34
  setLogoVariant,
35
35
  } from "./design-options.js";
36
36
  import { createContentControls } from "./content-controls.js";
37
- import { describeTableCell, mergeTableCells, parseTableCellPath, readTableStyle, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
37
+ import { IMAGE_EDGES, IMAGE_FITS, IMAGE_SHAPES, readImageTreatments, setImageTreatment } from "./image-options.js";
38
+ import { applyCatalogUpdate, catalogRecordAt, checkCatalogUpdates, mergeCatalogs, moveToCustom, referenceFindings } from "./catalogs.js";
39
+ import { describeTableCell, mergeTableCells, parseTableCellPath, readTableAlt, readTableStyle, setTableAlt, setTableCellStyle, setTableStyle, splitTableCell } from "./table-options.js";
38
40
 
39
41
  /** The sections a panel can show. `selection` and `table` follow the current selection; the rest follow the deck or the current slide. */
40
- export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "slide-image", "header-footer", "brand", "layout-options", "info", "selection", "table", "slide-content"]);
42
+ export const DESIGN_CONTROL_SECTIONS = Object.freeze(["look", "background", "header-footer", "brand", "layout-options", "info", "selection", "image", "table", "slide-content", "catalog"]);
41
43
 
42
44
  const SECTION_TITLES = {
43
45
  look: "Look and language",
44
46
  background: "Background",
45
- "slide-image": "Slide image",
47
+ image: "Selected image",
48
+ catalog: "Catalog records",
46
49
  "header-footer": "Header and footer",
47
50
  brand: "Logo, watermark and bullets",
48
51
  "layout-options": "Alignment and arrangement",
@@ -51,7 +54,7 @@ const SECTION_TITLES = {
51
54
  table: "Table",
52
55
  "slide-content": "Slide structure",
53
56
  };
54
- const OPEN_BY_DEFAULT = new Set(["look", "selection", "table"]);
57
+ const OPEN_BY_DEFAULT = new Set(["look", "selection", "image", "table"]);
55
58
  const LOGO_VARIANT_LABELS = {
56
59
  default: "Default",
57
60
  light: "Light (for dark backgrounds)",
@@ -75,8 +78,6 @@ const CELL_FILLS = [
75
78
  ["background", "Background"],
76
79
  ["text", "Text color"],
77
80
  ];
78
- const SHAPES = ["rectangle", "rounded", "circle", "hexagon"];
79
- const POSITIONS = ["background", "top", "bottom", "left", "right"];
80
81
  const BLOCK_REPLACEMENTS = ["text", "list", "chart", "table", "metric", "quote", "code", "timeline", "group"];
81
82
 
82
83
  let instances = 0;
@@ -89,8 +90,9 @@ const titleCase = (text) => text.charAt(0).toUpperCase() + text.slice(1);
89
90
  *
90
91
  * Options: `editor` (an editor session), `getSlideIndex()` and `getSelectedPath()` (the host's current
91
92
  * slide and selection; call `refresh()` when they change), `sections` (default: all), `scope`
92
- * ("deck" or "slide", the initial "Applies to" choice), `catalogs`/`catalogSources` (caller-loaded
93
- * catalog records), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
93
+ * ("deck" or "slide", the initial "Applies to" choice), `catalogs` (host catalogs, `Catalog[]`, merged after the session's
94
+ * registered ones), `vocabularies` (display metadata for languages, chart types and social platforms: `catalogDisplay`
95
+ * from `@openpresentation/opf/catalog`), `onChange(change)`, `onStatus(message, { error })` and `onSelectPath(path)`, called
94
96
  * after a content conversion or replacement with the path to keep selected (the block, or the inline
95
97
  * payload field), because the host's old selection path may no longer exist.
96
98
  */
@@ -103,7 +105,8 @@ export function createDesignControls(container, options = {}) {
103
105
  const sections = (options.sections ?? DESIGN_CONTROL_SECTIONS).filter((name) => DESIGN_CONTROL_SECTIONS.includes(name));
104
106
  const getSlide = () => options.getSlideIndex?.() ?? 0;
105
107
  const getSelected = () => options.getSelectedPath?.();
106
- const catalogOptions = () => ({ catalogs: options.catalogs, catalogSources: options.catalogSources });
108
+ // One catalog list for every picker and switch: the session's registered catalogs, then the panel's own `catalogs`.
109
+ const catalogOptions = () => ({ catalogs: mergeCatalogs(editor.catalogs, options.catalogs), vocabularies: options.vocabularies });
107
110
  const state = { scope: options.scope === "slide" ? "slide" : "deck" };
108
111
  const syncs = [];
109
112
  let destroyed = false;
@@ -151,8 +154,10 @@ export function createDesignControls(container, options = {}) {
151
154
  try {
152
155
  const change = action();
153
156
  const warnings = change?.warnings?.length ? ` ${change.warnings.map((warning) => warning.message).join(" ")}` : "";
157
+ // An edit of a catalog's record forked it into the deck's own records: say so (FA-23).
158
+ const notice = change?.notice ? ` ${change.notice}` : "";
154
159
  if (change && change.changed === false) say("Already set. Nothing changed.");
155
- else say(`${typeof done === "function" ? done(change) : (done ?? "Changed.")}${warnings} Undo restores the previous state.`);
160
+ else say(`${typeof done === "function" ? done(change) : (done ?? "Changed.")}${warnings}${notice} Undo restores the previous state.`);
156
161
  options.onChange?.(change);
157
162
  } catch (error) {
158
163
  complain(messageOf(error));
@@ -270,6 +275,40 @@ export function createDesignControls(container, options = {}) {
270
275
 
271
276
  const button = (label, onClick, { quiet = false, title } = {}) => h("button", { type: "button", class: quiet ? "quiet" : "secondary", title, onclick: onClick }, label);
272
277
 
278
+ /**
279
+ * The overlay of an image background or an image block: a scrim over the whole picture, or a band along one edge.
280
+ * `value()` is the overlay to write (undefined without a color), `set(overlay)` loads one. `onCommit` (optional) runs on a change.
281
+ */
282
+ function overlayFields(name, label, { onCommit } = {}) {
283
+ const commit = () => onCommit?.();
284
+ const color = textField(`${name}-color`, `${label} color`, { list: true, placeholder: "dark1 or #000000", help: "A theme color such as dark1, a hex color, or var:<id>. Clear it to remove the overlay.", onCommit: commit });
285
+ color.setList(COLOR_NAMES);
286
+ const opacity = numberField(`${name}-opacity`, `${label} opacity (0 to 1)`, { min: 0, max: 1, step: 0.05, onCommit: commit });
287
+ const edge = selectField(`${name}-edge`, `${label} band`, { empty: "Whole picture", help: "Cover only a band along one edge, for example a caption strip.", onChange: commit });
288
+ edge.setOptions(IMAGE_EDGES.map((value) => ({ value, label: `Along the ${value}` })));
289
+ const size = numberField(`${name}-size`, `${label} band size (0.05 to 1)`, { min: 0.05, max: 1, step: 0.05, onCommit: commit });
290
+ const wrap = h("fieldset", { class: "opf-dc-fieldset", "data-role": name }, h("legend", {}, label), color.wrap, opacity.wrap, edge.wrap, size.wrap);
291
+ return {
292
+ wrap,
293
+ value() {
294
+ const ref = color.input.value.trim();
295
+ if (!ref) return undefined;
296
+ return {
297
+ color: ref,
298
+ opacity: opacity.input.value === "" ? 0.4 : Number(opacity.input.value),
299
+ ...(edge.select.value ? { edge: edge.select.value, ...(size.input.value === "" ? {} : { size: Number(size.input.value) }) } : {}),
300
+ };
301
+ },
302
+ set(overlay) {
303
+ color.set(overlay?.color ?? "");
304
+ opacity.set(overlay?.opacity === undefined ? "" : String(overlay.opacity));
305
+ edge.set(overlay?.edge ?? "");
306
+ size.set(overlay?.size === undefined ? "" : String(overlay.size));
307
+ size.wrap.hidden = !overlay?.edge && !edge.select.value;
308
+ },
309
+ };
310
+ }
311
+
273
312
  /**
274
313
  * Source text, file upload and alt text for one image. `apply(ref | null)` commits a typed source (null removes it);
275
314
  * `build(ref, document)` prepares the change an upload makes, after its asset patch, in the same undo step.
@@ -354,7 +393,7 @@ export function createDesignControls(container, options = {}) {
354
393
  const scopeIndex = () => (state.scope === "slide" ? getSlide() : undefined);
355
394
  const scoped = (extra = {}) => (state.scope === "slide" ? { slideIndex: getSlide(), ...extra } : extra);
356
395
  const sourceNote = (scope, inherited) => (state.scope === "slide" ? (scope === "slide" ? "set on this slide" : inherited ? "from the presentation" : "default") : "");
357
- const scopeNeeded = sections.some((name) => ["look", "background", "slide-image", "header-footer", "brand", "layout-options"].includes(name));
396
+ const scopeNeeded = sections.some((name) => ["look", "background", "header-footer", "brand", "layout-options"].includes(name));
358
397
  let scopeField;
359
398
  if (scopeNeeded) {
360
399
  scopeField = selectField("scope", "Applies to", {
@@ -393,12 +432,12 @@ export function createDesignControls(container, options = {}) {
393
432
  body.append(field.wrap);
394
433
  syncs.push(() => {
395
434
  field.setOptions(listSwitchOptions(editor.presentation, dimension, catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
396
- const current = currentSwitchValue(editor.presentation, dimension, perSlide ? scoped() : {});
435
+ const current = currentSwitchValue(editor.presentation, dimension, { ...catalogOptions(), ...(perSlide ? scoped() : {}) });
397
436
  field.set(current.value, perSlide ? sourceNote(current.scope, current.value !== undefined) : "");
398
437
  });
399
438
  return field;
400
439
  };
401
- catalogField("theme", "Theme", "themes", { help: "A theme also sets its color scheme, font scheme and background." });
440
+ catalogField("theme", "Theme", "themes", { help: "A theme brings its own color scheme, font scheme and background: choosing one removes those overrides here." });
402
441
  catalogField("color-scheme", "Color scheme", "color-schemes");
403
442
  catalogField("font-scheme", "Font scheme", "font-schemes", { help: "Fonts the preview and export use. The preview shows an open look-alike where a font is not bundled." });
404
443
  catalogField("language", "Language", "languages", { perSlide: false, help: "Sets the language for the whole presentation, including its script fonts." });
@@ -410,7 +449,7 @@ export function createDesignControls(container, options = {}) {
410
449
  body.append(slideSize.wrap);
411
450
  syncs.push(() => {
412
451
  slideSize.setOptions(listSwitchOptions(editor.presentation, "slide-sizes").map((entry) => ({ value: entry.id, label: entry.label })));
413
- const { value } = currentSwitchValue(editor.presentation, "slide-sizes");
452
+ const { value } = currentSwitchValue(editor.presentation, "slide-sizes", catalogOptions());
414
453
  slideSize.set(value && typeof value === "object" ? "Custom size" : value);
415
454
  });
416
455
 
@@ -477,14 +516,45 @@ export function createDesignControls(container, options = {}) {
477
516
  gradientPanel.append(angle.wrap, stopsBox, addStopButton);
478
517
 
479
518
  const imagePanel = panel("image", "Image");
480
- const fit = selectField("bg-fit", "Fit", { help: "Cover fills the slide, contain shows the whole image, tile repeats it." });
519
+ const fit = selectField("bg-fit", "Fit", { help: "Cover fills the slide around the focus point, contain shows the whole image, stretch fills it exactly, tile repeats it." });
481
520
  fit.setOptions(IMAGE_BACKGROUND_FITS.map((value) => ({ value, label: titleCase(value) })));
521
+ const bgAlt = textField("bg-alt", "Background alt text", {
522
+ help: "Describe the photo when it carries meaning: PowerPoint then gets a picture at the back that screen readers read. Leave it empty for a decorative background.",
523
+ });
524
+ const focusX = numberField("bg-focus-x", "Focus across (0 left to 1 right)", { min: 0, max: 1, step: 0.05 });
525
+ const focusY = numberField("bg-focus-y", "Focus down (0 top to 1 bottom)", { min: 0, max: 1, step: 0.05 });
526
+ const overlay = overlayFields("bg-overlay", "Overlay");
527
+ const bgRecolor = selectField("bg-recolor", "Recolor", { empty: "None", help: "Grayscale, or a duotone from a dark to a light color. Changes the picture's pixels only, not the overlay." });
528
+ bgRecolor.setOptions([
529
+ { value: "grayscale", label: "Grayscale" },
530
+ { value: "duotone", label: "Duotone" },
531
+ ]);
532
+ const bgDuoDark = textField("bg-duo-dark", "Background duotone dark color", { list: true, placeholder: "dark1" });
533
+ const bgDuoLight = textField("bg-duo-light", "Background duotone light color", { list: true, placeholder: "accent1" });
534
+ bgDuoDark.setList(COLOR_NAMES);
535
+ bgDuoLight.setList(COLOR_NAMES);
536
+ const showDuotone = () => { for (const field of [bgDuoDark, bgDuoLight]) field.wrap.hidden = bgRecolor.select.value !== "duotone"; };
537
+ bgRecolor.select.addEventListener("change", showDuotone);
538
+ const recolorValue = () => (bgRecolor.select.value === "grayscale" ? "grayscale" : bgRecolor.select.value === "duotone" ? { dark: bgDuoDark.input.value.trim(), light: bgDuoLight.input.value.trim() } : undefined);
539
+ const imageSpec = (src) => {
540
+ const x = focusX.input.value, y = focusY.input.value;
541
+ return {
542
+ type: "image",
543
+ src,
544
+ ...(fit.select.value ? { fit: fit.select.value } : {}),
545
+ ...(bgAlt.input.value.trim() ? { alt: bgAlt.input.value.trim() } : {}),
546
+ ...(x !== "" || y !== "" ? { focus: { x: x === "" ? 0.5 : Number(x), y: y === "" ? 0.5 : Number(y) } } : {}),
547
+ ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }),
548
+ ...(recolorValue() ? { recolor: recolorValue() } : {}),
549
+ ...(overlay.value() ? { overlay: overlay.value() } : {}),
550
+ };
551
+ };
482
552
  const bgImage = imageCluster("bg-image", "Background image", {
483
- apply: (ref) => (ref === null ? setBackground(editor, null, scoped()) : setBackground(editor, { type: "image", image: { src: ref, fit: fit.select.value || undefined }, ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }) }, scoped())),
484
- build: (ref, doc) => prepareBackground(doc, { type: "image", image: { src: ref, fit: fit.select.value || undefined }, ...(opacityValue() === undefined ? {} : { opacity: opacityValue() }) }, scoped()),
553
+ apply: (ref) => (ref === null ? setBackground(editor, null, scoped()) : setBackground(editor, imageSpec(ref), scoped())),
554
+ build: (ref, doc) => prepareBackground(doc, imageSpec(ref), scoped()),
485
555
  help: "An asset reference, a web address or a data address. Press Enter or choose a file to apply it with the settings here.",
486
556
  });
487
- imagePanel.append(bgImage.wrap, fit.wrap);
557
+ imagePanel.append(bgImage.wrap, fit.wrap, bgAlt.wrap, focusX.wrap, focusY.wrap, bgRecolor.wrap, bgDuoDark.wrap, bgDuoLight.wrap, overlay.wrap);
488
558
 
489
559
  const patternPanel = panel("pattern", "Pattern");
490
560
  const preset = selectField("bg-preset", "Pattern", { help: "The 54 PowerPoint presets. PPTX export writes them as native pattern fills." });
@@ -506,7 +576,7 @@ export function createDesignControls(container, options = {}) {
506
576
  if (type === "solid") return withOpacity({ type: "solid", color: solidColor.input.value.trim() });
507
577
  if (type === "gradient")
508
578
  return withOpacity({ type: "gradient", gradient: { ...(angle.input.value === "" ? {} : { angle: Number(angle.input.value) }), stops: stopRows.map((row) => ({ color: row.color.input.value.trim(), position: row.position.input.value })) } });
509
- if (type === "image") return withOpacity({ type: "image", image: { src: bgImage.source.input.value.trim(), fit: fit.select.value } });
579
+ if (type === "image") return imageSpec(bgImage.source.input.value.trim());
510
580
  return withOpacity({ type: "pattern", pattern: { preset: preset.select.value, foregroundColor: patternFg.input.value.trim() || undefined, backgroundColor: patternBg.input.value.trim() || undefined } });
511
581
  };
512
582
  const showType = () => {
@@ -540,6 +610,15 @@ export function createDesignControls(container, options = {}) {
540
610
  renderStops();
541
611
  fit.set(current.type === "image" ? (current.fit ?? "cover") : "cover");
542
612
  bgImage.set(current.type === "image" ? (current.src ?? "") : "", "", target());
613
+ bgAlt.set(current.type === "image" ? (current.alt ?? "") : "");
614
+ focusX.set(current.type === "image" && current.focus ? String(current.focus.x) : "");
615
+ focusY.set(current.type === "image" && current.focus ? String(current.focus.y) : "");
616
+ overlay.set(current.type === "image" ? current.overlay : undefined);
617
+ const recolor = current.type === "image" ? current.recolor : undefined;
618
+ bgRecolor.set(recolor === "grayscale" ? "grayscale" : recolor && typeof recolor === "object" ? "duotone" : "");
619
+ bgDuoDark.set(recolor && typeof recolor === "object" ? recolor.dark : "");
620
+ bgDuoLight.set(recolor && typeof recolor === "object" ? recolor.light : "");
621
+ showDuotone();
543
622
  preset.set(current.type === "pattern" ? current.preset : "pct5");
544
623
  patternFg.set(current.type === "pattern" ? (current.foregroundColor ?? "") : "");
545
624
  patternBg.set(current.type === "pattern" ? (current.backgroundColor ?? "") : "");
@@ -563,62 +642,6 @@ export function createDesignControls(container, options = {}) {
563
642
  });
564
643
  }
565
644
 
566
- // --- slide image --------------------------------------------------------------------------------
567
-
568
- if (built["slide-image"]) {
569
- const { body } = built["slide-image"];
570
- const image = (fields, done) => run(() => setDesignOption(editor, "slideImage", fields, scoped()), done ?? "Slide image changed.");
571
- const position = selectField("image-position", "Position", {
572
- empty: "None",
573
- help: "Where the slide image sits. Choose None to remove it.",
574
- onChange: (value) => (value === "" ? run(() => setDesignOption(editor, "slideImage", null, scoped()), "Slide image removed.") : image({ position: value }, `Slide image placed ${value}.`)),
575
- });
576
- position.setOptions(POSITIONS.map((value) => ({ value, label: titleCase(value) })));
577
- const source = imageCluster("image", "Slide image", {
578
- apply: (ref) => setDesignOption(editor, "slideImage", { src: ref }, scoped()),
579
- build: (ref, doc) => prepareDesignOption(doc, "slideImage", { src: ref }, scoped()),
580
- });
581
- const fill = selectField("image-fill", "Fit", { empty: "Presentation default", onChange: (value) => image({ fill: value === "" ? null : value }, "Image fit changed.") });
582
- fill.setOptions([
583
- { value: "crop", label: "Crop to fill" },
584
- { value: "fit", label: "Fit whole image" },
585
- ]);
586
- const shape = selectField("image-shape", "Shape", { empty: "Rectangle", onChange: (value) => image({ shape: value === "" ? null : value }, "Image shape changed.") });
587
- shape.setOptions(SHAPES.filter((value) => value !== "rectangle").map((value) => ({ value, label: titleCase(value) })));
588
- const size = numberField("image-size", "Size (share of the slide, 0.1 to 0.9)", {
589
- min: 0.1,
590
- max: 0.9,
591
- step: 0.05,
592
- onCommit: (value) => image({ size: value }, "Image size changed."),
593
- });
594
- const inset = checkField("image-inset", "Inset inside the slide padding", { onChange: (checked) => image({ inset: checked ? true : null }, "Image inset changed.") });
595
- const placeholderFill = selectField("image-placeholder-fill", "Image placeholders", {
596
- empty: "Presentation default",
597
- help: "How images fill their layout placeholders across the presentation.",
598
- onChange: (value) => run(() => switchDimension(editor, "image-treatments", { imageFill: value === "" ? null : value }), "Image placeholder fill changed."),
599
- });
600
- placeholderFill.setOptions([
601
- { value: "crop", label: "Crop to fill" },
602
- { value: "fit", label: "Fit whole image" },
603
- ]);
604
- body.append(position.wrap, source.wrap, fill.wrap, shape.wrap, size.wrap, inset.wrap, placeholderFill.wrap);
605
- syncs.push(() => {
606
- const option = getDesignOption(editor.presentation, "slideImage", scoped());
607
- const value = option.value;
608
- const object = value && typeof value === "object" && !Array.isArray(value) && value.position ? value : undefined;
609
- const note = sourceNote(option.scope, option.value !== undefined);
610
- position.set(object?.position ?? (value === undefined ? "" : "(source only)"), note);
611
- source.set(stringOf(value), "", target());
612
- fill.set(object?.fill ?? "");
613
- shape.set(object?.shape && object.shape !== "rectangle" ? object.shape : "");
614
- size.set(object?.size === undefined ? "" : String(object.size));
615
- inset.set(object?.inset === true);
616
- // Fit, shape, size and inset only make sense once the image has a position.
617
- for (const control of [fill, shape, size, inset]) control.wrap.hidden = !object;
618
- placeholderFill.set(editor.presentation.design?.imageFill ?? "");
619
- });
620
- }
621
-
622
645
  // --- header and footer --------------------------------------------------------------------------
623
646
 
624
647
  if (built["header-footer"]) {
@@ -824,6 +847,7 @@ export function createDesignControls(container, options = {}) {
824
847
  enumField("contentAlignment", "Content alignment", ["left", "center", "right"], {});
825
848
  enumField("contentDirection", "Content direction", ["horizontal", "vertical"], { horizontal: "Horizontal (row)", vertical: "Vertical (column)" }, "The axis parallel content is arranged along when a slide sets no arrangement of its own.");
826
849
  enumField("chartPrimary", "Primary chart position", ["none", "top", "bottom", "left", "right"], { none: "None (equal)" }, "Where the main chart sits next to supporting content.");
850
+ enumField("imageFit", "Image fit", IMAGE_FITS, { cover: "Cover (crop to fill)", contain: "Contain (whole image)", stretch: "Stretch to the frame" }, "How pictures fill their frames when an image block does not choose its own fit.");
827
851
  const box = selectField("opt-contentBox", "Content box", {
828
852
  empty: "Default",
829
853
  onChange: (value) => run(() => setDesignOption(editor, "contentBox", value === "" ? null : value === "yes", scoped()), "Content box changed."),
@@ -851,7 +875,7 @@ export function createDesignControls(container, options = {}) {
851
875
  field.set(currentSwitchValue(editor.presentation, dimension).value);
852
876
  });
853
877
  };
854
- single("narrative", "Narrative", "narratives", "The narrative plan the deck points at (a catalog id; a custom one is a record in the JSON source). The slides do not change, and keep their beat links.");
878
+ single("narrative", "Narrative", "narratives", "The narrative plan the deck points at (a catalog reference; a custom one is a record embedded in the document's catalogs). The slides do not change, and keep their beat links.");
855
879
  single("tone", "Tone", "tones");
856
880
  single("purpose", "Purpose", "purposes", "What the deck is for. Authoring metadata: the slides do not change. A goal written in the JSON source shows as custom.");
857
881
  const audience = selectField("audience", "Audience", {
@@ -866,7 +890,7 @@ export function createDesignControls(container, options = {}) {
866
890
  body.append(audience.wrap);
867
891
  syncs.push(() => {
868
892
  audience.setOptions(listSwitchOptions(editor.presentation, "audiences", catalogOptions()).map((entry) => ({ value: entry.id, label: entry.label })));
869
- // The root audience is a string, one inline Audience object or an array of both: the picker shows the catalog ids.
893
+ // The root audience is a string, one inline Audience object or an array of both: the picker shows the references.
870
894
  const value = editor.presentation.audience;
871
895
  const entries = Array.isArray(value) ? value : value === undefined ? [] : [value];
872
896
  audience.set(entries.map((entry) => (entry && typeof entry === "object" ? entry.id : entry)).filter((id) => typeof id === "string"));
@@ -935,6 +959,159 @@ export function createDesignControls(container, options = {}) {
935
959
 
936
960
  if (built["slide-content"]) built["slide-content"].body.append(contentControls.slideNode);
937
961
 
962
+ // --- selected image (FA-22): fit, focus, treatments and placement of one image block -----------------------------
963
+
964
+ if (built.image) {
965
+ const { body, details } = built.image;
966
+ const summary = h("p", { class: "opf-dc-help", "data-role": "image-summary" });
967
+ const treat = (fields, done) => run(() => setImageTreatment(editor, dynamic.imagePath, fields), done);
968
+ const fit = selectField("img-fit", "Fit", {
969
+ empty: "Presentation default",
970
+ help: "Cover fills the frame around the focus point, contain shows the whole picture, stretch fills the frame exactly.",
971
+ onChange: (value) => treat({ fit: value === "" ? null : value }, "Image fit changed."),
972
+ });
973
+ fit.setOptions(IMAGE_FITS.map((value) => ({ value, label: titleCase(value) })));
974
+ const focusCommit = () => {
975
+ const x = focusX.input.value, y = focusY.input.value;
976
+ treat({ focus: x === "" && y === "" ? null : { x: x === "" ? 0.5 : Number(x), y: y === "" ? 0.5 : Number(y) } }, "Focus point changed.");
977
+ };
978
+ const focusX = numberField("img-focus-x", "Focus across (0 left to 1 right)", { min: 0, max: 1, step: 0.05, help: "The point a cover fit keeps in view. Clear both to center it.", onCommit: focusCommit });
979
+ const focusY = numberField("img-focus-y", "Focus down (0 top to 1 bottom)", { min: 0, max: 1, step: 0.05, onCommit: focusCommit });
980
+ const shape = selectField("img-shape", "Shape", { empty: "Rectangle", onChange: (value) => treat({ shape: value === "" ? null : value }, "Image shape changed.") });
981
+ shape.setOptions(IMAGE_SHAPES.filter((value) => value !== "rectangle").map((value) => ({ value, label: titleCase(value) })));
982
+ const radius = numberField("img-radius", "Corner radius (0 to 0.5 of the shorter side)", { min: 0, max: 0.5, step: 0.02, onCommit: (value) => treat({ cornerRadius: value }, "Corner radius changed.") });
983
+ const aspect = numberField("img-aspect", "Frame aspect ratio (width / height)", { min: 0.1, max: 10, step: 0.01, help: "For example 1.78 for 16:9, 1 for a square. Clear it to use the whole region.", onCommit: (value) => treat({ aspectRatio: value }, "Frame aspect ratio changed.") });
984
+ const opacity = numberField("img-opacity", "Opacity (0 to 1)", { min: 0, max: 1, step: 0.05, onCommit: (value) => treat({ opacity: value }, "Image opacity changed.") });
985
+ const borderCommit = () => {
986
+ const ref = borderColor.input.value.trim();
987
+ treat({ border: ref ? { color: ref, width: borderWidth.input.value === "" ? 2 : Number(borderWidth.input.value) } : null }, ref ? "Border changed." : "Border removed.");
988
+ };
989
+ const borderColor = textField("img-border-color", "Border color", { list: true, placeholder: "dark1 or #1F2937", help: "Clear it to remove the border.", onCommit: borderCommit });
990
+ borderColor.setList(COLOR_NAMES);
991
+ const borderWidth = numberField("img-border-width", "Border width (0 to 64)", { min: 0, max: 64, step: 1, onCommit: borderCommit });
992
+ const recolorCommit = () => {
993
+ const kind = recolor.select.value;
994
+ if (kind === "duotone" && (!duoDark.input.value.trim() || !duoLight.input.value.trim())) return say("Enter the dark and light duotone colors to apply it.");
995
+ treat({ recolor: kind === "" ? null : kind === "grayscale" ? "grayscale" : { dark: duoDark.input.value.trim(), light: duoLight.input.value.trim() } }, "Recolor changed.");
996
+ };
997
+ const recolor = selectField("img-recolor", "Recolor", { empty: "None", onChange: recolorCommit });
998
+ recolor.setOptions([
999
+ { value: "grayscale", label: "Grayscale" },
1000
+ { value: "duotone", label: "Duotone" },
1001
+ ]);
1002
+ const duoDark = textField("img-duo-dark", "Duotone dark color", { list: true, placeholder: "dark1", onCommit: recolorCommit });
1003
+ const duoLight = textField("img-duo-light", "Duotone light color", { list: true, placeholder: "accent1", onCommit: recolorCommit });
1004
+ duoDark.setList(COLOR_NAMES);
1005
+ duoLight.setList(COLOR_NAMES);
1006
+ const overlay = overlayFields("img-overlay", "Overlay", { onCommit: () => treat({ overlay: overlay.value() ?? null }, "Overlay changed.") });
1007
+ const placementCommit = () => {
1008
+ const edge = placeEdge.select.value;
1009
+ treat(
1010
+ { placement: edge === "" ? null : { edge, ...(placeSize.input.value === "" ? {} : { size: Number(placeSize.input.value) }), ...(placeInset.input.checked ? { inset: true } : {}) } },
1011
+ edge === "" ? "The image flows with the content again." : `The image bleeds to the ${edge} edge.`,
1012
+ );
1013
+ };
1014
+ const placeEdge = selectField("img-place-edge", "Bleed to an edge", {
1015
+ empty: "No: flows with the content",
1016
+ help: "The image takes a band along that slide edge, edge to edge, and the title and content use the rest of the slide.",
1017
+ onChange: placementCommit,
1018
+ });
1019
+ const placeSize = numberField("img-place-size", "Share of the slide (0.1 to 0.9)", { min: 0.1, max: 0.9, step: 0.05, onCommit: placementCommit });
1020
+ const placeInset = checkField("img-place-inset", "Inset inside the slide padding", { onChange: placementCommit });
1021
+ const placement = h("fieldset", { class: "opf-dc-fieldset", "data-role": "img-placement" }, h("legend", {}, "Placement"), placeEdge.wrap, placeSize.wrap, placeInset.wrap);
1022
+ body.append(summary, fit.wrap, focusX.wrap, focusY.wrap, shape.wrap, radius.wrap, aspect.wrap, opacity.wrap, borderColor.wrap, borderWidth.wrap, recolor.wrap, duoDark.wrap, duoLight.wrap, overlay.wrap, placement);
1023
+ dynamic.image = {
1024
+ details,
1025
+ load(path) {
1026
+ const current = readImageTreatments(editor.presentation, path);
1027
+ summary.textContent = `Image block (${current.path})`;
1028
+ fit.set(current.fit ?? "");
1029
+ focusX.set(current.focus ? String(current.focus.x) : "");
1030
+ focusY.set(current.focus ? String(current.focus.y) : "");
1031
+ shape.set(current.shape && current.shape !== "rectangle" ? current.shape : "");
1032
+ radius.set(current.cornerRadius === undefined ? "" : String(current.cornerRadius));
1033
+ radius.wrap.hidden = current.shape !== "rounded";
1034
+ aspect.set(current.aspectRatio === undefined ? "" : String(current.aspectRatio));
1035
+ opacity.set(current.opacity === undefined ? "" : String(current.opacity));
1036
+ borderColor.set(current.border?.color ?? "");
1037
+ borderWidth.set(current.border?.width === undefined ? "" : String(current.border.width));
1038
+ const duotone = current.recolor && typeof current.recolor === "object";
1039
+ recolor.set(current.recolor === "grayscale" ? "grayscale" : duotone ? "duotone" : "");
1040
+ duoDark.set(duotone ? current.recolor.dark : "");
1041
+ duoLight.set(duotone ? current.recolor.light : "");
1042
+ for (const field of [duoDark, duoLight]) field.wrap.hidden = !(duotone || recolor.select.value === "duotone");
1043
+ overlay.set(current.overlay);
1044
+ placement.hidden = !current.placeable;
1045
+ placeEdge.setOptions(IMAGE_EDGES.map((edge) => ({ value: edge, label: titleCase(edge), disabled: current.usedEdges.includes(edge), title: current.usedEdges.includes(edge) ? "Another image already bleeds to this edge." : undefined })));
1046
+ placeEdge.set(current.placement?.edge ?? "");
1047
+ placeSize.set(current.placement?.size === undefined ? "" : String(current.placement.size));
1048
+ placeInset.set(current.placement?.inset === true);
1049
+ for (const control of [placeSize, placeInset]) control.wrap.hidden = !current.placement;
1050
+ },
1051
+ };
1052
+ }
1053
+
1054
+ // --- catalog records (FA-23): reference findings and the explicit "Update from catalog" ---------------------------
1055
+
1056
+ if (built.catalog) {
1057
+ const { body } = built.catalog;
1058
+ const findings = h("ul", { class: "opf-dc-warnings", "aria-label": "Catalog reference problems", "data-role": "reference-findings" });
1059
+ const intro = h("p", { class: "opf-dc-help" }, "The document embeds every catalog record it uses when it is saved. Updating replaces an embedded record with the catalog's current one, only after you approve it.");
1060
+ const updates = h("ul", { class: "opf-dc-summary", "aria-label": "Catalog updates", "data-role": "catalog-updates" });
1061
+ const review = { changes: [] };
1062
+ const check = button("Check for catalog updates", () => {
1063
+ try {
1064
+ const update = checkCatalogUpdates(editor.presentation, catalogOptions());
1065
+ review.changes = update.changes;
1066
+ renderUpdates();
1067
+ say(update.changes.length ? `${update.changes.length} embedded record${update.changes.length === 1 ? " differs" : "s differ"} from the catalog. Choose what to update.` : "Every embedded record matches its catalog.");
1068
+ } catch (error) {
1069
+ complain(messageOf(error));
1070
+ }
1071
+ });
1072
+ const applyButton = button("Apply selected updates", () => {
1073
+ const refs = [...updates.querySelectorAll("input[type=checkbox]:checked")].map((input) => review.changes[Number(input.value)]).filter(Boolean).map(({ kind, reference }) => ({ kind, reference }));
1074
+ if (!refs.length) return complain("Choose at least one update to apply.");
1075
+ run(() => {
1076
+ const change = applyCatalogUpdate(editor, { ...catalogOptions(), refs });
1077
+ review.changes = [];
1078
+ renderUpdates();
1079
+ return change;
1080
+ }, (change) => `Updated ${change.changes?.length ?? 0} record${change.changes?.length === 1 ? "" : "s"} from the catalog.`);
1081
+ });
1082
+ const renderUpdates = () => {
1083
+ updates.replaceChildren(
1084
+ ...review.changes.map((change, index) => {
1085
+ const id = nextId("catalog-update");
1086
+ const fields = [...new Set([...Object.keys(change.embedded ?? {}), ...Object.keys(change.current ?? {})])].filter((key) => JSON.stringify(change.embedded?.[key]) !== JSON.stringify(change.current?.[key]));
1087
+ return h("li", {}, h("input", { type: "checkbox", id, value: String(index) }), h("label", { for: id }, `${change.kind} ${change.reference} (${change.source}): ${fields.length ? `changes ${fields.join(", ")}` : "changed"}`));
1088
+ }),
1089
+ );
1090
+ applyButton.hidden = review.changes.length === 0;
1091
+ };
1092
+ renderUpdates();
1093
+ body.append(intro, findings, h("div", { class: "opf-dc-actions" }, check, applyButton), updates);
1094
+ syncs.push(() => {
1095
+ const list = referenceFindings(editor.validation);
1096
+ findings.replaceChildren(
1097
+ ...list.map((finding) => {
1098
+ const item = h("li", { "data-rule": finding.ruleId }, `${finding.path ? `${finding.path}: ` : ""}${finding.message}`);
1099
+ // A record the registered catalog does not publish belongs to the document: offer to move it to custom.
1100
+ const target = finding.ruleId === "opf/catalog-record-not-in-source" ? catalogRecordAt(finding.path) : undefined;
1101
+ if (target)
1102
+ item.append(
1103
+ " ",
1104
+ button(`Move ${target.id} to this document's own records`, () =>
1105
+ run(() => moveToCustom(editor, target, catalogOptions()), (change) => `Moved ${target.kind} ${target.id} to catalogs.custom${change.to.id === target.id ? "" : ` as ${change.to.id}`}; its references follow.`),
1106
+ ),
1107
+ );
1108
+ return item;
1109
+ }),
1110
+ );
1111
+ findings.hidden = list.length === 0;
1112
+ });
1113
+ }
1114
+
938
1115
  // --- table --------------------------------------------------------------------------------------
939
1116
 
940
1117
  if (built.table) {
@@ -981,8 +1158,19 @@ export function createDesignControls(container, options = {}) {
981
1158
  const align = selectField("cell-align", "Cell text alignment", { empty: "Default", onChange: (value) => run(() => setTableCellStyle(editor, dynamic.tablePath, dynamic.cell, { align: value === "" ? null : value }), "Cell alignment changed.") });
982
1159
  align.setOptions(["left", "center", "right"].map((value) => ({ value, label: titleCase(value) })));
983
1160
  const cellBox = h("div", { class: "opf-dc-cell" }, cellLabel, columns.wrap, rows.wrap, join.wrap, h("div", { class: "opf-dc-actions" }, merge, split), fill.wrap, align.wrap);
984
- body.append(summary, headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox);
985
- dynamic.table = { details, summary, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
1161
+ // FA-27: the table's text alternative, like a chart's. It belongs to the table, so a dataset-backed table has it too.
1162
+ const alt = textField("table-alt", "Alt text", {
1163
+ help: "What the table shows: its point and the key numbers, not \"a table\". Optional, because a screen reader reads a table cell by cell. Press Enter to apply.",
1164
+ placeholder: "What the table shows",
1165
+ onCommit: (value) => run(() => setTableAlt(editor, dynamic.tablePath, { alt: value }), "Table alt text changed."),
1166
+ });
1167
+ alt.input.setAttribute("aria-label", "Table alt text");
1168
+ const decorative = checkField("table-decorative", "Decorative (no alt text)", {
1169
+ onChange: (checked) => run(() => setTableAlt(editor, dynamic.tablePath, { decorative: checked }), checked ? "Table marked decorative." : "The table is no longer decorative."),
1170
+ });
1171
+ const styleControls = [headerStyle.wrap, banding.wrap, borders.wrap, resetStyle, cellBox];
1172
+ body.append(summary, alt.wrap, decorative.wrap, ...styleControls);
1173
+ dynamic.table = { details, summary, alt, decorative, styleControls, headerStyle, banding, borders, cellBox, cellLabel, columns, rows, merge, split, fill, align, join };
986
1174
  }
987
1175
 
988
1176
  // --- sync -----------------------------------------------------------------------------------------
@@ -1020,18 +1208,34 @@ export function createDesignControls(container, options = {}) {
1020
1208
  }
1021
1209
  }
1022
1210
  }
1211
+ const imageUi = dynamic.image;
1212
+ if (imageUi) {
1213
+ // The slide's own image (a root `image`) is not an image block: it takes no framing fields.
1214
+ dynamic.imagePath = content?.key === "image" && blockPath.split(".").length > 2 ? blockPath : undefined;
1215
+ imageUi.details.hidden = !dynamic.imagePath;
1216
+ if (dynamic.imagePath) imageUi.load(dynamic.imagePath);
1217
+ }
1023
1218
  const tableUi = dynamic.table;
1024
1219
  if (tableUi) {
1025
1220
  const parsed = selected ? parseTableCellPath(selected) : undefined;
1026
1221
  const tablePath = parsed?.tablePath ?? (content?.key === "table" ? `${blockPath}.table` : undefined);
1027
1222
  dynamic.tablePath = tablePath;
1028
1223
  dynamic.cell = parsed?.cell;
1029
- // A table that shows a shared dataset (RR-54) has no cell styles or merges: the table panel is for inline tables.
1224
+ // A table that shows a shared dataset (RR-54) has no cell styles or merges: the style and merge controls are for inline tables.
1225
+ // Its alt text (FA-27) belongs to the table and not to the data, so the panel still opens for it.
1030
1226
  const tableValue = tablePath ? editor.get(tablePath) : undefined;
1031
- tableUi.details.hidden = !tablePath || !Array.isArray(tableValue?.rows);
1227
+ const inline = Array.isArray(tableValue?.rows);
1228
+ tableUi.details.hidden = !tablePath || !(inline || typeof tableValue?.dataset === "string");
1032
1229
  if (tablePath && !tableUi.details.hidden) {
1033
- const table = tableValue;
1034
1230
  tableUi.summary.textContent = `Table (${tablePath})`;
1231
+ const textAlternative = readTableAlt(tableValue);
1232
+ tableUi.alt.set(textAlternative.alt);
1233
+ tableUi.alt.input.disabled = textAlternative.decorative;
1234
+ tableUi.decorative.set(textAlternative.decorative);
1235
+ for (const node of tableUi.styleControls) node.hidden = !inline;
1236
+ }
1237
+ if (tablePath && !tableUi.details.hidden && inline) {
1238
+ const table = tableValue;
1035
1239
  const style = readTableStyle(document_, tablePath);
1036
1240
  dynamic.tableStyle = style.preset === "custom" ? { header: "theme", banding: false, borders: "theme" } : { header: style.header, banding: style.banding, borders: style.borders };
1037
1241
  tableUi.headerStyle.set(style.header);
@@ -7,15 +7,15 @@ export type DesignOptionId =
7
7
  | "chartPrimary"
8
8
  | "listBullet"
9
9
  | "contentBox"
10
+ | "imageFit"
10
11
  | "accentFont"
11
12
  | "logo"
12
13
  | "organizationLogo"
13
- | "watermark"
14
- | "slideImage";
14
+ | "watermark";
15
15
  export interface DesignOptionDescriptor {
16
16
  id: DesignOptionId;
17
17
  label: string;
18
- type: "enum" | "boolean" | "font" | "logo" | "organization-logo" | "watermark" | "slide-image";
18
+ type: "enum" | "boolean" | "font" | "logo" | "organization-logo" | "watermark";
19
19
  /** Allowed values of an enum option. */
20
20
  values?: readonly string[];
21
21
  scopes: ("deck" | "slide")[];
@@ -72,7 +72,7 @@ export interface DesignOptionChange extends Omit<EditorChange, "presentation" |
72
72
  warnings: DesignWarning[];
73
73
  }
74
74
 
75
- /** Compute the patch for one option. `null` removes it at that scope; object options (watermark, slideImage) merge the fields passed. */
75
+ /** Compute the patch for one option. `null` removes it at that scope; the watermark object merges the fields passed. `imageFit` is the default fit of image blocks (cover, contain, stretch). */
76
76
  export declare function prepareDesignOption(presentation: unknown, option: DesignOptionId, value: unknown, options?: DesignOptionOptions): PreparedDesignOption;
77
77
  /** Set one option as a single undoable transaction. */
78
78
  export declare function setDesignOption(editor: EditorSession, option: DesignOptionId, value: unknown, options?: DesignOptionOptions): DesignOptionChange;