@openpresentation/opf-editor 0.16.0 → 0.18.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.
@@ -5,27 +5,44 @@
5
5
  // session form that commits it with `meta.source: "design-option"`.
6
6
  import { getValueAtPath, opfPathToJsonPointer } from "./index.js";
7
7
  import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
8
+ import { LOGO_SHAPES, listBuiltinVariables } from "@openpresentation/opf";
9
+ import { resolveLogo } from "@openpresentation/opf/composition";
8
10
  import { checkFormat } from "./checks.js";
9
11
 
10
- /** The `design.logo` variant slots of a LogoSet, in schema order. */
11
- export const LOGO_VARIANTS = Object.freeze([
12
- "default",
13
- "light",
14
- "dark",
15
- "stacked",
16
- "stackedLight",
17
- "stackedDark",
18
- "icon",
19
- "iconLight",
20
- "iconDark",
21
- "wordmark",
22
- "wordmarkLight",
23
- "wordmarkDark",
24
- ]);
12
+ // RR-71 (OPF 0.18): logos live on the organization. `organization.logo` is one image for every shape, or up to four shapes
13
+ // (full, stacked, icon, wordmark), each one image or an { onLight, onDark } pair. They are placed with whole-field
14
+ // `var:organization(.<id>)?.logo(.<shape>)?` references: in a header or footer zone's `image`, and in `design.logo`, which
15
+ // picks the organization and shape that covers, sections and picture bullets draw (or `false` for none).
16
+ export { LOGO_SHAPES };
17
+ /** The backgrounds a logo shape can be split by: one image for both, or one for light and one for dark backgrounds. */
18
+ export const LOGO_BACKGROUNDS = Object.freeze(["both", "onLight", "onDark"]);
25
19
  export const HEADER_FOOTER_ZONES = Object.freeze(["left", "center", "right"]);
26
20
  // Flag fields of a header/footer zone: false is stored as "absent" (so is a `date` of false).
27
- const ZONE_FLAGS = ["logo", "slideNumber", "organization", "speaker", "socials", "section"];
28
- export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "slideNumber", "slideNumberFormat", "date", "dateFormat", "organization", "speaker", "socials", "section"]);
21
+ const ZONE_FLAGS = ["socials"];
22
+ export const ZONE_FIELDS = Object.freeze(["text", "image", "date", "dateFormat", "socials"]);
23
+ // FA-31, RR-71: generated values are `{{ }}` variables inside a zone's `text` and the logo is the zone's `image`. The keys 0.16 and 0.17 had for them are gone; say what to write instead.
24
+ const REMOVED_ZONE_FIELDS = Object.freeze({
25
+ logo: "Use Insert logo (insertZoneLogo): the zone's image is var:organization.logo.icon.",
26
+ organization: "Write {{organization.name}} in the text.",
27
+ speaker: "Write {{speaker.name}} in the text.",
28
+ section: "Write {{slide.section}} in the text.",
29
+ slideNumber: "Write {{slide.number}} in the text.",
30
+ slideNumberFormat: "Write {{slide.number}} and {{deck.slideCount}} in the text.",
31
+ });
32
+ /**
33
+ * The values the "Insert value" menu of a header or footer zone offers, in menu order. `token` is what lands in the zone's `text`.
34
+ * `{{slide.number}}`, `{{deck.slideCount}}` and `{{slide.section}}` vary per slide; the others come from the deck's own fields.
35
+ */
36
+ export const ZONE_VALUES = Object.freeze(
37
+ [
38
+ { name: "slide.number", label: "Slide number" },
39
+ { name: "deck.slideCount", label: "Slide count" },
40
+ { name: "slide.section", label: "Section" },
41
+ { name: "organization.name", label: "Organization" },
42
+ { name: "speaker.name", label: "Speaker" },
43
+ { name: "deck.name", label: "Deck name" },
44
+ ].map((entry) => Object.freeze({ ...entry, token: `{{${entry.name}}}` })),
45
+ );
29
46
  /** Date tokens a `dateFormat` understands (English names, independent of the host locale). */
30
47
  export const DATE_FORMAT_TOKENS = Object.freeze(["yyyy", "yy", "MMMM", "MMM", "MM", "M", "dd", "d", "EEEE", "EEE"]);
31
48
  const ISO_DATE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
@@ -36,7 +53,6 @@ function checkZoneField(key, value) {
36
53
  if (ZONE_FLAGS.includes(key) && typeof value !== "boolean") throw bad(`${key} is true or false.`);
37
54
  if (key === "text" && typeof value !== "string") throw bad("Text is a string.");
38
55
  if (key === "image" && typeof value !== "string" && !isObject(value)) throw bad("An image is a source, an asset reference or an asset object.");
39
- if (key === "slideNumberFormat" && (typeof value !== "string" || !value.includes("{current}"))) throw bad("The slide number format must contain {current}, for example Page {current} of {total}.");
40
56
  if (key === "date" && typeof value !== "boolean" && typeof value !== "string") throw bad("A date is true (the current date) or a fixed date.");
41
57
  if (key === "dateFormat" && (typeof value !== "string" || !value.trim())) throw bad(`A date format uses tokens such as ${DATE_FORMAT_TOKENS.join(", ")}.`);
42
58
  }
@@ -52,7 +68,7 @@ const ENUMS = Object.freeze({
52
68
 
53
69
  /**
54
70
  * Descriptors for every design option, in the order a panel shows them. `type` is "enum", "boolean",
55
- * "font", "asset", "logo", "watermark" or "organization-logo"; `scopes` says whether
71
+ * "font", "logo" (`design.logo`: a logo reference or false) or "watermark"; `scopes` says whether
56
72
  * the option applies to the deck, one slide, or both.
57
73
  */
58
74
  export const DESIGN_OPTIONS = Object.freeze([
@@ -65,7 +81,6 @@ export const DESIGN_OPTIONS = Object.freeze([
65
81
  { id: "imageFit", label: "Image fit", type: "enum", values: ENUMS.imageFit, scopes: ["deck", "slide"], path: "design.imageFit" },
66
82
  { id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent" },
67
83
  { id: "logo", label: "Logo", type: "logo", scopes: ["deck", "slide"], path: "design.logo" },
68
- { id: "organizationLogo", label: "Organization logo", type: "organization-logo", scopes: ["deck"], path: "organization.logo" },
69
84
  { id: "watermark", label: "Watermark", type: "watermark", scopes: ["deck", "slide"], path: "design.watermark" },
70
85
  ]);
71
86
  const BY_ID = Object.fromEntries(DESIGN_OPTIONS.map((option) => [option.id, option]));
@@ -90,43 +105,120 @@ function primaryOrganization(presentation) {
90
105
  return list.find((entry) => entry?.role === "primary") ?? list[0];
91
106
  }
92
107
 
93
- function firstSpeaker(presentation) {
94
- const list = Array.isArray(presentation.speaker) ? presentation.speaker : presentation.speaker ? [presentation.speaker] : [];
95
- return list[0];
108
+ // --- logo references ----------------------------------------------------------------------------
109
+
110
+ const LOGO_REFERENCE = /^var:organization(?:\.([A-Za-z0-9_-]+))?\.logo(?:\.(full|stacked|icon|wordmark))?$/;
111
+ /**
112
+ * Parse a logo reference (`var:organization.logo`, `var:organization.logo.icon`, `var:organization.beta.logo.wordmark`) into
113
+ * `{ organization?, shape? }` (the organization id, and the shape when the reference names one), or null for anything else.
114
+ */
115
+ export function parseLogoReference(text) {
116
+ const match = typeof text === "string" ? LOGO_REFERENCE.exec(text) : null;
117
+ if (!match) return null;
118
+ return { ...(match[1] !== undefined ? { organization: match[1] } : {}), ...(match[2] !== undefined ? { shape: match[2] } : {}) };
119
+ }
120
+ /**
121
+ * The `var:` reference for an organization's logo: `organization` is the organization's id (omit it for the primary
122
+ * organization) and `shape` one of `LOGO_SHAPES` (omit it to leave the shape to where the logo is drawn).
123
+ */
124
+ export function logoReference({ organization, shape } = {}) {
125
+ if (organization !== undefined && !(typeof organization === "string" && /^[A-Za-z0-9_-]+$/.test(organization)))
126
+ throw fail("invalid-design-value", "An organization is addressed by its id (letters, digits, - and _).", { organization });
127
+ if (shape !== undefined && !LOGO_SHAPES.includes(shape)) throw fail("invalid-design-value", `Logo shape is one of ${LOGO_SHAPES.join(", ")}.`, { shape });
128
+ return `var:organization${organization !== undefined ? `.${organization}` : ""}.logo${shape !== undefined ? `.${shape}` : ""}`;
129
+ }
130
+
131
+ /**
132
+ * The deck's organizations as a panel lists them: `{ index, id, name, primary, hasLogo }`. `id` is what a `var:` reference
133
+ * addresses (an organization without one is only reachable as the primary organization); `primary` marks the organization
134
+ * the unset logo and `var:organization.logo` mean (`role: "primary"`, else the first).
135
+ */
136
+ export function listOrganizations(presentation) {
137
+ const raw = presentation?.organization;
138
+ const entries = Array.isArray(raw) ? raw : isObject(raw) ? [raw] : [];
139
+ const explicit = entries.findIndex((entry) => isObject(entry) && entry.role === "primary");
140
+ const primary = explicit >= 0 ? explicit : entries.findIndex(isObject);
141
+ return entries.flatMap((entry, index) =>
142
+ isObject(entry)
143
+ ? [{ index, ...(typeof entry.id === "string" ? { id: entry.id } : {}), ...(typeof entry.name === "string" ? { name: entry.name } : {}), primary: index === primary, hasLogo: entry.logo !== undefined }]
144
+ : [],
145
+ );
146
+ }
147
+
148
+ /** Whether a logo resolves for the slide: its `design.logo`, then the deck's, then the primary organization's (`shape`: the shape asked for, default full). */
149
+ export function hasResolvableLogo(presentation, slideIndex, { shape } = {}) {
150
+ try {
151
+ return resolveLogo(presentation, presentation.slides?.[slideIndex], { slideIndex, ...(shape ? { shape } : {}) }) !== null;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
157
+ /** The variable names a zone's `text` uses (`{{name}}` and `{{name|format}}`); a token with a backslash before it is literal text. */
158
+ function zoneTextVariables(text) {
159
+ const names = [];
160
+ for (const match of text.matchAll(/(\\?)\{\{\s*([A-Za-z0-9_.-]+)\s*(?:\|[^{}]*)?\}\}/g)) if (!match[1]) names.push(match[2]);
161
+ return names;
96
162
  }
97
163
 
98
- /** Whether a logo resolves for the slide: slide design, then deck design, then the primary organization. */
99
- export function hasResolvableLogo(presentation, slideIndex) {
100
- return Boolean(presentation.slides?.[slideIndex]?.design?.logo ?? presentation.design?.logo ?? primaryOrganization(presentation)?.logo);
164
+ function logoResolves(presentation, slide, slideIndex, options) {
165
+ try {
166
+ return resolveLogo(presentation, slide, { slideIndex, ...options }) !== null;
167
+ } catch {
168
+ return false;
169
+ }
170
+ }
171
+ function describeLogoReference(presentation, text) {
172
+ const parsed = parseLogoReference(text);
173
+ const organizations = listOrganizations(presentation);
174
+ const owner = parsed?.organization !== undefined ? organizations.find((entry) => entry.id === parsed.organization) : organizations.find((entry) => entry.primary);
175
+ const who = parsed?.organization !== undefined ? (owner?.name ?? `organization ${parsed.organization}`) : "the primary organization";
176
+ return `the ${parsed?.shape ?? "full"} logo of ${who}`;
101
177
  }
102
178
 
103
179
  /**
104
- * Plain-language warnings for settings that need a logo the document does not have: a header or
105
- * footer zone with `logo: true`, or picture bullets. The renderers fall back to a glyph or report
106
- * unresolved content; the editor surfaces it before export.
180
+ * Plain-language warnings for settings that need content the document does not have: a logo (a header or footer zone whose
181
+ * `image` is a logo reference, `design.logo` naming an organization, picture bullets) that no organization provides, the
182
+ * organization's social profiles, or a value a zone's `text` asks for with a variable (`{{organization.name}}` without an
183
+ * organization, `{{slide.section}}` on a slide without a section).
184
+ * The renderers fall back to a glyph or report unresolved content; the editor surfaces it before export.
107
185
  */
108
186
  export function designWarnings(presentation, slideIndex = 0) {
109
187
  const slide = presentation.slides?.[slideIndex];
110
188
  const design = { ...presentation.design, ...slide?.design };
111
189
  const warnings = [];
112
- const hasLogo = hasResolvableLogo(presentation, slideIndex);
190
+ // RR-71: a zone's image that is a logo reference needs the organization to have that logo; an override that names an organization never falls back to another.
113
191
  for (const which of ["header", "footer"])
114
- for (const zone of HEADER_FOOTER_ZONES)
115
- if (!hasLogo && isObject(design[which]) && design[which][zone]?.logo === true)
116
- warnings.push({ code: "unresolved-logo", path: `design.${which}.${zone}.logo`, message: `The ${which} ${zone} zone shows the logo, but no logo is set. Add a logo or an organization logo.` });
192
+ for (const zone of HEADER_FOOTER_ZONES) {
193
+ const image = isObject(design[which]) ? design[which][zone]?.image : undefined;
194
+ if (parseLogoReference(image) && !logoResolves(presentation, slide, slideIndex, { reference: image }))
195
+ warnings.push({ code: "unresolved-logo", path: `design.${which}.${zone}.image`, message: `The ${which} ${zone} zone shows ${describeLogoReference(presentation, image)}, but it has no logo. Add the organization's logo.` });
196
+ }
197
+ if (typeof design.logo === "string" && !logoResolves(presentation, slide, slideIndex, {}))
198
+ warnings.push({ code: "unresolved-logo", path: slide?.design?.logo !== undefined ? `slides.${slideIndex}.design.logo` : "design.logo", message: `The logo setting points at ${describeLogoReference(presentation, design.logo)}, but it has no logo, so covers and sections draw none. Add the organization's logo.` });
117
199
  const organization = primaryOrganization(presentation);
200
+ let builtins;
118
201
  for (const which of ["header", "footer"])
119
202
  for (const zone of HEADER_FOOTER_ZONES) {
120
203
  const item = isObject(design[which]) ? design[which][zone] : undefined;
121
- if (item?.organization === true && !organization)
122
- warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.organization`, message: `The ${which} ${zone} zone shows the organization, but the presentation has none.` });
123
- if (item?.speaker === true && !firstSpeaker(presentation)?.name)
124
- warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.speaker`, message: `The ${which} ${zone} zone shows the speaker, but the presentation has no named speaker.` });
125
204
  if (item?.socials === true && !organization?.socials)
126
205
  warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.socials`, message: `The ${which} ${zone} zone shows social profiles, but the organization has none.` });
206
+ // FA-31: a value the zone's text asks for and the document cannot give (the core validator reports the same as variable-builtin-missing).
207
+ const path = `design.${which}.${zone}.text`;
208
+ for (const name of new Set(typeof item?.text === "string" ? zoneTextVariables(item.text) : [])) {
209
+ if (name === "slide.section") {
210
+ if (!(typeof slide?.section === "string" && slide.section !== ""))
211
+ warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{slide.section}}, but this slide has no section.` });
212
+ continue;
213
+ }
214
+ builtins ??= listBuiltinVariables(presentation);
215
+ const entry = builtins.find((candidate) => candidate.name === name);
216
+ if (entry?.scope === "deck" && !entry.available)
217
+ warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{${name}}}, but the presentation has no value for it.` });
218
+ }
127
219
  }
128
- if (!hasLogo && design.listBullet === "image")
129
- warnings.push({ code: "unresolved-logo", path: "design.listBullet", message: "Picture bullets use the logo, but no logo is set, so lists draw the character bullet. Add a logo or an organization logo." });
220
+ if (design.listBullet === "image" && !logoResolves(presentation, slide, slideIndex, { shape: "icon" }))
221
+ warnings.push({ code: "unresolved-logo", path: "design.listBullet", message: "Picture bullets use the organization's icon logo, but no logo resolves, so lists draw the character bullet. Add an organization logo." });
130
222
  return warnings;
131
223
  }
132
224
 
@@ -190,27 +282,29 @@ function fontSchemeWithAccent(presentation, base, family) {
190
282
  return object;
191
283
  }
192
284
 
193
- function logoSetOf(existing) {
194
- if (existing === undefined) return {};
195
- if (typeof existing === "string" || (isObject(existing) && Object.hasOwn(existing, "src"))) return { default: existing };
196
- return isObject(existing) ? { ...existing } : {};
197
- }
198
- function collapseLogo(set) {
199
- const keys = Object.keys(set);
200
- if (!keys.length) return null;
201
- if (keys.length === 1 && keys[0] === "default") return set.default;
202
- return set;
203
- }
204
-
205
285
  // --- design option ----------------------------------------------------------------------------
206
286
 
287
+ /**
288
+ * The value `design.logo` takes for a choice: `null` (unset), `false`, a `var:` logo reference, or `{ organization?, shape? }`
289
+ * (an organization id and shape, written as the reference). A reference to an organization the deck does not have is refused.
290
+ */
291
+ function checkedLogoChoice(presentation, value) {
292
+ if (value === null || value === false) return value;
293
+ const reference = isObject(value) ? logoReference(value) : value;
294
+ const parsed = parseLogoReference(reference);
295
+ if (!parsed) throw fail("invalid-design-value", "The logo is null (the primary organization's), false (none) or a reference such as var:organization.logo.icon or var:organization.beta.logo.", { option: "logo", value });
296
+ if (parsed.organization !== undefined && !listOrganizations(presentation).some((entry) => entry.id === parsed.organization))
297
+ throw fail("unknown-organization", `The presentation has no organization with the id '${parsed.organization}'.`, { organization: parsed.organization });
298
+ return reference;
299
+ }
300
+
207
301
  /**
208
302
  * Compute the patch that sets one design option, without touching any session. `value === null`
209
303
  * removes the option at that scope so it is inherited again. The object-valued `watermark` merges the
210
304
  * fields you pass into the existing object; a `null` field removes it.
211
- * Options: `slideIndex` (one slide instead of the deck), `clearSlideOverrides` (deck scope: also
212
- * remove slide-level values that hide it) and `index` (organizationLogo with several
213
- * organizations).
305
+ * Options: `slideIndex` (one slide instead of the deck) and `clearSlideOverrides` (deck scope: also
306
+ * remove slide-level values that hide it). The `logo` option is `design.logo`: `null` (the primary organization's logo),
307
+ * `false` (no logo), a reference such as `"var:organization.beta.logo.icon"`, or `{ organization?, shape? }` for one.
214
308
  */
215
309
  export function prepareDesignOption(presentation, option, value, options = {}) {
216
310
  const descriptor = BY_ID[option];
@@ -218,39 +312,23 @@ export function prepareDesignOption(presentation, option, value, options = {}) {
218
312
  if (!isObject(presentation)) throw fail("invalid-input", "Design options need an OPF document object.");
219
313
  if (value === undefined) throw fail("invalid-design-value", "Pass a value, or null to remove the option.", { option });
220
314
  const { scope, base, slideIndex } = scopeOf(presentation, option, options);
221
- let patches = [];
222
- let keys = [];
223
-
224
- if (descriptor.type === "organization-logo") {
225
- const organization = presentation.organization;
226
- const index = options.index ?? 0;
227
- const owner = Array.isArray(organization) ? organization[index] : organization;
228
- if (!isObject(owner)) throw fail("missing-owner", "Add an organization to the document before setting its logo.", { option });
229
- const path = Array.isArray(organization) ? ["organization", String(index), "logo"] : ["organization", "logo"];
230
- const present = Object.hasOwn(owner, "logo");
231
- if (value === null) patches = present ? [{ op: "remove", path: opfPathToJsonPointer(path) }] : [];
232
- else if (!present) patches = [{ op: "add", path: opfPathToJsonPointer(path), value: structuredClone(value) }];
233
- else if (!same(owner.logo, value)) patches = [{ op: "replace", path: opfPathToJsonPointer(path), value: structuredClone(value) }];
315
+ let entries;
316
+ if (descriptor.type === "enum") {
317
+ if (value !== null && !ENUMS[option].includes(value)) throw fail("invalid-design-value", `${descriptor.label} is one of ${ENUMS[option].join(", ")}.`, { option, value });
318
+ entries = { [option]: value };
319
+ } else if (descriptor.type === "boolean") {
320
+ if (value !== null && typeof value !== "boolean") throw fail("invalid-design-value", `${descriptor.label} is true or false.`, { option, value });
321
+ entries = { [option]: value };
322
+ } else if (descriptor.type === "font") {
323
+ entries = { fontScheme: fontSchemeWithAccent(presentation, base, value) };
324
+ } else if (descriptor.type === "logo") {
325
+ entries = { logo: checkedLogoChoice(presentation, value) };
234
326
  } else {
235
- let entries;
236
- if (descriptor.type === "enum") {
237
- if (value !== null && !ENUMS[option].includes(value)) throw fail("invalid-design-value", `${descriptor.label} is one of ${ENUMS[option].join(", ")}.`, { option, value });
238
- entries = { [option]: value };
239
- } else if (descriptor.type === "boolean") {
240
- if (value !== null && typeof value !== "boolean") throw fail("invalid-design-value", `${descriptor.label} is true or false.`, { option, value });
241
- entries = { [option]: value };
242
- } else if (descriptor.type === "font") {
243
- entries = { fontScheme: fontSchemeWithAccent(presentation, base, value) };
244
- } else if (descriptor.type === "logo") {
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 });
246
- entries = { logo: value };
247
- } else {
248
- const existing = ownDesign(presentation, base).watermark;
249
- entries = { watermark: mergedWatermark(existing, value) };
250
- }
251
- keys = Object.keys(entries);
252
- patches = designPatches(presentation, base, entries);
327
+ const existing = ownDesign(presentation, base).watermark;
328
+ entries = { watermark: mergedWatermark(existing, value) };
253
329
  }
330
+ const keys = Object.keys(entries);
331
+ const patches = designPatches(presentation, base, entries);
254
332
 
255
333
  let shadowedSlides = [];
256
334
  if (scope === "deck" && keys.length) {
@@ -287,16 +365,11 @@ export function setDesignOption(editor, option, value, options = {}) {
287
365
  /**
288
366
  * Read one design option for a panel: `{ value, scope, inherited }` where `scope` is "slide" when the
289
367
  * slide's own design sets it, "deck" when the deck does, and "default" when neither does.
290
- * `accentFont` reads the family name; `organizationLogo` reads the organization.
368
+ * `accentFont` reads the family name; `logo` reads `design.logo` (a reference, or false).
291
369
  */
292
370
  export function getDesignOption(presentation, option, options = {}) {
293
371
  const descriptor = BY_ID[option];
294
372
  if (!descriptor) throw fail("unknown-design-option", `Unknown design option: ${option}.`, { option });
295
- if (descriptor.type === "organization-logo") {
296
- const organization = presentation.organization;
297
- const owner = Array.isArray(organization) ? organization[options.index ?? 0] : organization;
298
- return { value: owner?.logo, scope: owner?.logo === undefined ? "default" : "deck", inherited: false };
299
- }
300
373
  const key = descriptor.type === "font" ? "fontScheme" : option;
301
374
  const pick = (design) => {
302
375
  const value = design?.[key];
@@ -309,54 +382,127 @@ export function getDesignOption(presentation, option, options = {}) {
309
382
  return { value: undefined, scope: "default", inherited: false };
310
383
  }
311
384
 
312
- // --- logo variants ----------------------------------------------------------------------------
385
+ // --- organization logo ------------------------------------------------------------------------
386
+
387
+ const isAsset = (value) => (typeof value === "string" && value.trim() !== "") || (isObject(value) && typeof value.src === "string");
388
+ const isToneSplit = (value) => isObject(value) && !Object.hasOwn(value, "src");
389
+
390
+ /** The organization an edit or a read addresses: its index, or its id; omitted, the primary organization. Returns the list entry. */
391
+ function organizationTarget(presentation, organization, option = "organization") {
392
+ const list = listOrganizations(presentation);
393
+ if (!list.length) throw fail("missing-owner", "Add an organization to the document before setting its logo.", { option });
394
+ if (organization === undefined || organization === null) return list.find((entry) => entry.primary) ?? list[0];
395
+ const found = typeof organization === "number" ? list.find((entry) => entry.index === organization) : list.find((entry) => entry.id === organization);
396
+ if (!found) throw fail("unknown-organization", `The presentation has no organization ${typeof organization === "number" ? `at index ${organization}` : `with the id '${organization}'`}.`, { organization });
397
+ return found;
398
+ }
399
+
400
+ /** A shape's value when it is edited for one background (or both); null clears. */
401
+ function editedShape(current, background, source) {
402
+ if (background === "both") return source === null ? undefined : source;
403
+ // A plain image is both backgrounds: editing one background keeps the other as it was.
404
+ const tones = isAsset(current) ? { onLight: current, onDark: current } : isToneSplit(current) ? { ...current } : {};
405
+ if (source === null) delete tones[background];
406
+ else tones[background] = source;
407
+ return Object.keys(tones).length ? tones : undefined;
408
+ }
313
409
 
314
410
  /**
315
- * Compute the patch that sets (a source, asset reference or Asset object) or clears (`null`) one logo
316
- * variant of `design.logo`. A single default logo stays a bare source; adding a second variant turns
317
- * it into a LogoSet, and clearing back to the default collapses it again.
411
+ * Compute the patch that sets (an image source, asset reference or Asset object) or clears (`null`) the organization's logo.
412
+ * `shape` is `"all"` (one image for every shape: `organization.logo` becomes that image) or one of `LOGO_SHAPES`. `options.background`
413
+ * is `"both"` (default: one image for the shape), `"onLight"` or `"onDark"` (the image for light or dark backgrounds only; the
414
+ * other keeps what the shape had). A bare logo is the full logo for every shape, so setting one shape keeps it as `full`; a lone
415
+ * plain `full` collapses back to the bare image. `options.organization` is the organization's index or id (default: the primary one).
318
416
  */
319
- export function prepareLogoVariant(presentation, variant, source, options = {}) {
320
- if (!LOGO_VARIANTS.includes(variant)) throw fail("invalid-design-value", `Logo variant is one of ${LOGO_VARIANTS.join(", ")}.`, { variant });
321
- if (source !== null && typeof source !== "string" && !isObject(source)) throw fail("invalid-design-value", "A logo variant is an image source, an asset object, or null to clear it.", { variant });
322
- if (typeof source === "string" && !source.trim()) throw fail("invalid-design-value", "Enter an image source or asset reference, or clear the variant.", { variant });
323
- const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
324
- const set = logoSetOf(ownDesign(presentation, base).logo);
325
- if (source === null) delete set[variant];
326
- else set[variant] = typeof source === "string" ? source.trim() : source;
327
- const patches = designPatches(presentation, base, { logo: collapseLogo(set) });
328
- return finish(presentation, patches, { option: "logo", variant, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: [] });
329
- }
330
- /** Set or clear one logo variant as a single undoable transaction. */
331
- export function setLogoVariant(editor, variant, source, options = {}) {
417
+ export function prepareOrganizationLogo(presentation, shape, source, options = {}) {
418
+ if (!isObject(presentation)) throw fail("invalid-input", "Organization logos need an OPF document object.");
419
+ if (shape !== "all" && !LOGO_SHAPES.includes(shape)) throw fail("invalid-design-value", `Logo shape is all or one of ${LOGO_SHAPES.join(", ")}.`, { shape });
420
+ const background = options.background ?? "both";
421
+ if (!LOGO_BACKGROUNDS.includes(background)) throw fail("invalid-design-value", `Logo background is one of ${LOGO_BACKGROUNDS.join(", ")}.`, { background });
422
+ if (shape === "all" && background !== "both") throw fail("invalid-design-value", "One image for every shape has no light or dark variant. Choose a shape to set one.", { shape, background });
423
+ if (source !== null && !isAsset(source)) throw fail("invalid-design-value", "A logo is an image source, an asset reference, an Asset object, or null to clear it.", { shape });
424
+ const clean = typeof source === "string" ? source.trim() : source;
425
+ const target = organizationTarget(presentation, options.organization, "organizationLogo");
426
+ const owner = Array.isArray(presentation.organization) ? presentation.organization[target.index] : presentation.organization;
427
+ const existing = owner.logo;
428
+ let logo;
429
+ if (shape === "all") logo = clean === null ? undefined : clean;
430
+ else {
431
+ const shapes = existing === undefined ? {} : isAsset(existing) ? { full: existing } : structuredClone(existing);
432
+ const next = editedShape(shapes[shape], background, clean);
433
+ if (next === undefined) delete shapes[shape];
434
+ else shapes[shape] = next;
435
+ const keys = Object.keys(shapes);
436
+ logo = !keys.length ? undefined : keys.length === 1 && keys[0] === "full" && isAsset(shapes.full) ? shapes.full : shapes;
437
+ }
438
+ const path = opfPathToJsonPointer(Array.isArray(presentation.organization) ? ["organization", String(target.index), "logo"] : ["organization", "logo"]);
439
+ const present = Object.hasOwn(owner, "logo");
440
+ let patches = [];
441
+ if (logo === undefined) patches = present ? [{ op: "remove", path }] : [];
442
+ else if (!present) patches = [{ op: "add", path, value: structuredClone(logo) }];
443
+ else if (!same(existing, logo)) patches = [{ op: "replace", path, value: structuredClone(logo) }];
444
+ return finish(presentation, patches, { option: "organizationLogo", shape, background, organization: target.index, scope: "deck", shadowed: [] });
445
+ }
446
+ /** Set or clear the organization's logo (see {@link prepareOrganizationLogo}) as a single undoable transaction. */
447
+ export function setOrganizationLogo(editor, shape, source, options = {}) {
332
448
  checkEditor(editor);
333
449
  const { meta, ...rest } = options;
334
- return apply(editor, prepareLogoVariant(editor.presentation, variant, source, rest), meta);
450
+ return apply(editor, prepareOrganizationLogo(editor.presentation, shape, source, rest), meta);
335
451
  }
336
- /** The variants `design.logo` sets at a scope: `{ variant: source }`, a bare logo reported as `default`. */
337
- export function readLogoVariants(presentation, options = {}) {
338
- const own = options.slideIndex === undefined ? presentation.design?.logo : presentation.slides?.[options.slideIndex]?.design?.logo;
339
- return logoSetOf(own);
452
+ /**
453
+ * An organization's logo as a panel shows it: `{ organization, all, shapes }`. `organization` is its list entry (`listOrganizations`),
454
+ * `all` the bare image when one image serves every shape, and `shapes` the shapes the logo sets, each `{ both }` (one image) or
455
+ * `{ onLight?, onDark? }`. `null` when the deck has no organization.
456
+ */
457
+ export function readOrganizationLogo(presentation, options = {}) {
458
+ if (!listOrganizations(presentation).length) return null;
459
+ const target = organizationTarget(presentation, options.organization);
460
+ const owner = Array.isArray(presentation.organization) ? presentation.organization[target.index] : presentation.organization;
461
+ const logo = owner.logo;
462
+ const shapes = {};
463
+ if (isToneSplit(logo))
464
+ for (const shape of LOGO_SHAPES) {
465
+ const value = logo[shape];
466
+ if (isAsset(value)) shapes[shape] = { both: value };
467
+ else if (isObject(value)) shapes[shape] = { ...(value.onLight !== undefined ? { onLight: value.onLight } : {}), ...(value.onDark !== undefined ? { onDark: value.onDark } : {}) };
468
+ }
469
+ return { organization: target, ...(isAsset(logo) ? { all: logo } : {}), shapes };
470
+ }
471
+
472
+ /**
473
+ * The `design.logo` choice at a scope as a panel shows it: `{ mode, organization?, shape?, value, scope, inherited }`. `mode` is
474
+ * "primary" (unset), "none" (`false`), "reference" (a `var:` reference to an organization and/or shape) or "custom" (any other value,
475
+ * for example one left over from an older document). `organization` is the id the reference names (absent for the primary one).
476
+ */
477
+ export function readLogoChoice(presentation, options = {}) {
478
+ const option = getDesignOption(presentation, "logo", options);
479
+ const { value } = option;
480
+ if (value === undefined) return { mode: "primary", ...option };
481
+ if (value === false) return { mode: "none", ...option };
482
+ const parsed = parseLogoReference(value);
483
+ return parsed ? { mode: "reference", ...parsed, ...option } : { mode: "custom", ...option };
340
484
  }
341
485
 
342
486
  // --- header and footer zones ------------------------------------------------------------------
343
487
 
344
488
  /**
345
- * Compute the patch that edits one header or footer zone. `fields` merges into the zone (logo, text,
346
- * image, slideNumber, slideNumberFormat, date, dateFormat, organization, speaker, socials, section); `null`,
347
- * `false` for a flag, or an empty string removes a field. A zone left empty is removed, then an empty
489
+ * Compute the patch that edits one header or footer zone. `fields` merges into the zone (text,
490
+ * image, date, dateFormat, socials); `null`, `false` for a flag, or an empty string removes a field.
491
+ * Generated values (slide number, slide count, section, organization, speaker, deck name) are `{{ }}`
492
+ * variables in `text` (`ZONE_VALUES`, `insertZoneValue`); the logo is the zone's `image`, a `var:organization.logo.icon`
493
+ * reference (`insertZoneLogo`). A zone left empty is removed, then an empty
348
494
  * header or footer, so a slide never carries `{}`. A slide's own header or footer replaces the deck's
349
495
  * whole one, so the first edit on a slide that has none of its own starts from a copy of the deck's
350
496
  * (the other zones stay); a slide emptied that way hides the furniture (`false`) instead of
351
497
  * inheriting it again. Setting a field on a suppressed (`false`) header replaces the suppression.
352
- * `logo: true` reports a warning when no logo resolves.
498
+ * An `image` that is a logo reference reports a warning when the organization has no logo.
353
499
  */
354
500
  export function prepareHeaderFooterZone(presentation, which, zone, fields, options = {}) {
355
501
  if (!["header", "footer"].includes(which)) throw fail("invalid-design-value", "Choose header or footer.", { which });
356
502
  if (!HEADER_FOOTER_ZONES.includes(zone)) throw fail("invalid-design-value", `Zone is one of ${HEADER_FOOTER_ZONES.join(", ")}.`, { zone });
357
503
  if (!isObject(fields)) throw fail("invalid-design-value", "Pass the zone fields to change.", { fields });
358
504
  const unknown = Object.keys(fields).filter((key) => !ZONE_FIELDS.includes(key));
359
- if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}.`, { fields });
505
+ if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}.${Object.hasOwn(REMOVED_ZONE_FIELDS, unknown[0]) ? ` ${REMOVED_ZONE_FIELDS[unknown[0]]}` : ""}`, { fields });
360
506
  const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
361
507
  const own = ownDesign(presentation, base)[which];
362
508
  const inherited = base.length ? presentation.design?.[which] : undefined;
@@ -387,6 +533,49 @@ export function setHeaderFooterZone(editor, which, zone, fields, options = {}) {
387
533
  const { meta, ...rest } = options;
388
534
  return apply(editor, prepareHeaderFooterZone(editor.presentation, which, zone, fields, rest), meta);
389
535
  }
536
+ /**
537
+ * Compute the patch that inserts a value's token (`ZONE_VALUES`, for example `slide.number` for `{{slide.number}}`) into a zone's
538
+ * `text` at UTF-16 offsets `start` and `end` (a selection is replaced; default: the end of the text). The text is the one the scope
539
+ * shows (the slide's own zone, else the deck's), or `options.text` when a panel holds edits it has not committed yet.
540
+ */
541
+ export function prepareZoneValue(presentation, which, zone, name, options = {}) {
542
+ const value = ZONE_VALUES.find((entry) => entry.name === name);
543
+ if (!value) throw fail("invalid-design-value", `Insert one of ${ZONE_VALUES.map((entry) => entry.name).join(", ")}.`, { value: name });
544
+ const { start, end, text: typed, ...rest } = options;
545
+ if (typed !== undefined && typeof typed !== "string") throw fail("invalid-design-value", "Text is a string.", { text: typed });
546
+ const current = typed ?? readHeaderFooterZone(presentation, which, zone, rest).text;
547
+ const text = typeof current === "string" ? current : "";
548
+ const from = start === undefined ? text.length : start;
549
+ const to = end === undefined ? from : end;
550
+ if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || to < from || to > text.length) throw fail("invalid-selection", "The selection is outside the text.", { start: from, end: to });
551
+ return prepareHeaderFooterZone(presentation, which, zone, { text: `${text.slice(0, from)}${value.token}${text.slice(to)}` }, rest);
552
+ }
553
+ /**
554
+ * Compute the patch that makes a zone show an organization's logo: its `image` becomes the reference
555
+ * `var:organization.logo.<shape>` (or `var:organization.<id>.logo.<shape>` with `options.organization`, an organization id).
556
+ * `options.shape` is one of `LOGO_SHAPES` (default `icon`, the mark that fits a zone); `full` is written as plain `var:organization.logo`.
557
+ * It replaces an image the zone had. Which image is drawn (light or dark artwork) follows each slide's background.
558
+ */
559
+ export function prepareZoneLogo(presentation, which, zone, options = {}) {
560
+ const { shape = "icon", organization, ...rest } = options;
561
+ if (!LOGO_SHAPES.includes(shape)) throw fail("invalid-design-value", `Logo shape is one of ${LOGO_SHAPES.join(", ")}.`, { shape });
562
+ if (organization !== undefined && !listOrganizations(presentation).some((entry) => entry.id === organization))
563
+ throw fail("unknown-organization", `The presentation has no organization with the id '${organization}'.`, { organization });
564
+ const reference = logoReference({ ...(organization !== undefined ? { organization } : {}), ...(shape !== "full" ? { shape } : {}) });
565
+ return prepareHeaderFooterZone(presentation, which, zone, { image: reference }, rest);
566
+ }
567
+ /** Make a zone show an organization's logo as a single undoable transaction. See {@link prepareZoneLogo}. */
568
+ export function insertZoneLogo(editor, which, zone, options = {}) {
569
+ checkEditor(editor);
570
+ const { meta, ...rest } = options;
571
+ return apply(editor, prepareZoneLogo(editor.presentation, which, zone, rest), meta);
572
+ }
573
+ /** Insert a value's token into one zone's `text` as a single undoable transaction. */
574
+ export function insertZoneValue(editor, which, zone, name, options = {}) {
575
+ checkEditor(editor);
576
+ const { meta, ...rest } = options;
577
+ return apply(editor, prepareZoneValue(editor.presentation, which, zone, name, rest), meta);
578
+ }
390
579
  /**
391
580
  * One header or footer zone's fields as they apply at a scope: the slide's own header (or footer) when it
392
581
  * has one, else the deck's (`{}` when absent or suppressed). `headerFooterState` says which.