@openpresentation/opf-editor 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,15 +34,18 @@ import {
34
34
  setLogoVariant,
35
35
  } from "./design-options.js";
36
36
  import { createContentControls } from "./content-controls.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";
37
39
  import { describeTableCell, mergeTableCells, parseTableCellPath, readTableStyle, 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) {
@@ -1020,6 +1197,13 @@ export function createDesignControls(container, options = {}) {
1020
1197
  }
1021
1198
  }
1022
1199
  }
1200
+ const imageUi = dynamic.image;
1201
+ if (imageUi) {
1202
+ // The slide's own image (a root `image`) is not an image block: it takes no framing fields.
1203
+ dynamic.imagePath = content?.key === "image" && blockPath.split(".").length > 2 ? blockPath : undefined;
1204
+ imageUi.details.hidden = !dynamic.imagePath;
1205
+ if (dynamic.imagePath) imageUi.load(dynamic.imagePath);
1206
+ }
1023
1207
  const tableUi = dynamic.table;
1024
1208
  if (tableUi) {
1025
1209
  const parsed = selected ? parseTableCellPath(selected) : undefined;
@@ -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;
@@ -41,19 +41,18 @@ function checkZoneField(key, value) {
41
41
  if (key === "dateFormat" && (typeof value !== "string" || !value.trim())) throw bad(`A date format uses tokens such as ${DATE_FORMAT_TOKENS.join(", ")}.`);
42
42
  }
43
43
 
44
- const SLIDE_IMAGE_POSITIONS = ["background", "top", "bottom", "left", "right"];
45
-
46
44
  const ENUMS = Object.freeze({
47
45
  titleAlignment: ["left", "center", "right"],
48
46
  contentAlignment: ["left", "center", "right"],
49
47
  contentDirection: ["horizontal", "vertical"],
50
48
  chartPrimary: ["none", "top", "bottom", "left", "right"],
51
49
  listBullet: ["character", "image"],
50
+ imageFit: ["cover", "contain", "stretch"],
52
51
  });
53
52
 
54
53
  /**
55
54
  * Descriptors for every design option, in the order a panel shows them. `type` is "enum", "boolean",
56
- * "font", "asset", "logo", "watermark", "slide-image" or "organization-logo"; `scopes` says whether
55
+ * "font", "asset", "logo", "watermark" or "organization-logo"; `scopes` says whether
57
56
  * the option applies to the deck, one slide, or both.
58
57
  */
59
58
  export const DESIGN_OPTIONS = Object.freeze([
@@ -63,11 +62,11 @@ export const DESIGN_OPTIONS = Object.freeze([
63
62
  { id: "chartPrimary", label: "Primary chart position", type: "enum", values: ENUMS.chartPrimary, scopes: ["deck", "slide"], path: "design.chartPrimary" },
64
63
  { id: "listBullet", label: "List bullets", type: "enum", values: ENUMS.listBullet, scopes: ["deck", "slide"], path: "design.listBullet" },
65
64
  { id: "contentBox", label: "Content box", type: "boolean", scopes: ["deck", "slide"], path: "design.contentBox" },
65
+ { id: "imageFit", label: "Image fit", type: "enum", values: ENUMS.imageFit, scopes: ["deck", "slide"], path: "design.imageFit" },
66
66
  { id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent" },
67
67
  { id: "logo", label: "Logo", type: "logo", scopes: ["deck", "slide"], path: "design.logo" },
68
68
  { id: "organizationLogo", label: "Organization logo", type: "organization-logo", scopes: ["deck"], path: "organization.logo" },
69
69
  { id: "watermark", label: "Watermark", type: "watermark", scopes: ["deck", "slide"], path: "design.watermark" },
70
- { id: "slideImage", label: "Slide image", type: "slide-image", scopes: ["deck", "slide"], path: "design.slideImage" },
71
70
  ]);
72
71
  const BY_ID = Object.fromEntries(DESIGN_OPTIONS.map((option) => [option.id, option]));
73
72
 
@@ -173,22 +172,6 @@ function mergedWatermark(existing, value) {
173
172
  return merged;
174
173
  }
175
174
 
176
- function mergedSlideImage(existing, value) {
177
- if (value === null) return null;
178
- if (typeof value === "string") return value;
179
- if (!isObject(value)) throw fail("invalid-design-value", "A slide image is an image source or an object with a position.", { value });
180
- const base = typeof existing === "string" ? { src: existing } : isObject(existing) ? (existing.position ? { ...existing } : { src: existing.src, ...(existing.alt ? { alt: existing.alt } : {}) }) : {};
181
- const merged = { ...base };
182
- for (const [key, entry] of Object.entries(value)) {
183
- if (entry === null || entry === undefined) delete merged[key];
184
- else merged[key] = entry;
185
- }
186
- if (merged.src === undefined) delete merged.src;
187
- if (merged.position === undefined) merged.position = "background";
188
- if (!SLIDE_IMAGE_POSITIONS.includes(merged.position)) throw fail("invalid-design-value", `Slide image position is one of ${SLIDE_IMAGE_POSITIONS.join(", ")}.`, { value });
189
- return merged;
190
- }
191
-
192
175
  function fontSchemeWithAccent(presentation, base, family) {
193
176
  // The slide's own font scheme, else the deck's: an object form is kept with its overrides.
194
177
  const own = ownDesign(presentation, base).fontScheme;
@@ -223,8 +206,8 @@ function collapseLogo(set) {
223
206
 
224
207
  /**
225
208
  * Compute the patch that sets one design option, without touching any session. `value === null`
226
- * removes the option at that scope so it is inherited again. Object-valued options (`watermark`,
227
- * `slideImage`) merge the fields you pass into the existing object; a `null` field removes it.
209
+ * removes the option at that scope so it is inherited again. The object-valued `watermark` merges the
210
+ * fields you pass into the existing object; a `null` field removes it.
228
211
  * Options: `slideIndex` (one slide instead of the deck), `clearSlideOverrides` (deck scope: also
229
212
  * remove slide-level values that hide it) and `index` (organizationLogo with several
230
213
  * organizations).
@@ -261,12 +244,9 @@ export function prepareDesignOption(presentation, option, value, options = {}) {
261
244
  } else if (descriptor.type === "logo") {
262
245
  if (value !== null && typeof value !== "string" && !isObject(value)) throw fail("invalid-design-value", "A logo is an image source, an asset object or a set of logo variants.", { option });
263
246
  entries = { logo: value };
264
- } else if (descriptor.type === "watermark") {
247
+ } else {
265
248
  const existing = ownDesign(presentation, base).watermark;
266
249
  entries = { watermark: mergedWatermark(existing, value) };
267
- } else {
268
- const existing = ownDesign(presentation, base).slideImage;
269
- entries = { slideImage: mergedSlideImage(existing, value) };
270
250
  }
271
251
  keys = Object.keys(entries);
272
252
  patches = designPatches(presentation, base, entries);
package/dist/export.d.ts CHANGED
@@ -46,6 +46,8 @@ export interface ExportOptions {
46
46
  scale?: number;
47
47
  /** The options the host draws its preview with (`catalogs`, `date`, ...); the fonts go in `fonts`. */
48
48
  renderOptions?: RenderSvgOptions;
49
+ /** Host catalogs (merged with `renderOptions.catalogs`). The deck is embedded first (core `embed`), like a saved file. */
50
+ catalogs?: readonly import("@openpresentation/opf").Catalog[];
49
51
  /** The renderer's fonts handle (`loadFonts()`): it measures the text, loads the faces the deck needs before anything is drawn, and its registry's faces are what gets embedded. */
50
52
  fonts?: EditorFonts;
51
53
  /** Faces to embed instead of the registry's. */