@openpresentation/opf-editor 0.17.0 → 0.18.1

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,30 +5,24 @@
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 { listBuiltinVariables } from "@openpresentation/opf";
8
+ import { LOGO_SHAPES, listBuiltinVariables } from "@openpresentation/opf";
9
+ import { resolveLogo } from "@openpresentation/opf/composition";
9
10
  import { checkFormat } from "./checks.js";
10
11
 
11
- /** The `design.logo` variant slots of a LogoSet, in schema order. */
12
- export const LOGO_VARIANTS = Object.freeze([
13
- "default",
14
- "light",
15
- "dark",
16
- "stacked",
17
- "stackedLight",
18
- "stackedDark",
19
- "icon",
20
- "iconLight",
21
- "iconDark",
22
- "wordmark",
23
- "wordmarkLight",
24
- "wordmarkDark",
25
- ]);
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"]);
26
19
  export const HEADER_FOOTER_ZONES = Object.freeze(["left", "center", "right"]);
27
20
  // Flag fields of a header/footer zone: false is stored as "absent" (so is a `date` of false).
28
- const ZONE_FLAGS = ["logo", "socials"];
29
- export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "date", "dateFormat", "socials"]);
30
- // FA-31: generated values are `{{ }}` variables inside a zone's `text`. The keys 0.16 had for them are gone; say what to write instead.
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.
31
24
  const REMOVED_ZONE_FIELDS = Object.freeze({
25
+ logo: "Use Insert logo (insertZoneLogo): the zone's image is var:organization.logo.icon.",
32
26
  organization: "Write {{organization.name}} in the text.",
33
27
  speaker: "Write {{speaker.name}} in the text.",
34
28
  section: "Write {{slide.section}} in the text.",
@@ -74,7 +68,7 @@ const ENUMS = Object.freeze({
74
68
 
75
69
  /**
76
70
  * Descriptors for every design option, in the order a panel shows them. `type` is "enum", "boolean",
77
- * "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
78
72
  * the option applies to the deck, one slide, or both.
79
73
  */
80
74
  export const DESIGN_OPTIONS = Object.freeze([
@@ -87,7 +81,6 @@ export const DESIGN_OPTIONS = Object.freeze([
87
81
  { id: "imageFit", label: "Image fit", type: "enum", values: ENUMS.imageFit, scopes: ["deck", "slide"], path: "design.imageFit" },
88
82
  { id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent" },
89
83
  { id: "logo", label: "Logo", type: "logo", scopes: ["deck", "slide"], path: "design.logo" },
90
- { id: "organizationLogo", label: "Organization logo", type: "organization-logo", scopes: ["deck"], path: "organization.logo" },
91
84
  { id: "watermark", label: "Watermark", type: "watermark", scopes: ["deck", "slide"], path: "design.watermark" },
92
85
  ]);
93
86
  const BY_ID = Object.fromEntries(DESIGN_OPTIONS.map((option) => [option.id, option]));
@@ -112,9 +105,53 @@ function primaryOrganization(presentation) {
112
105
  return list.find((entry) => entry?.role === "primary") ?? list[0];
113
106
  }
114
107
 
115
- /** Whether a logo resolves for the slide: slide design, then deck design, then the primary organization. */
116
- export function hasResolvableLogo(presentation, slideIndex) {
117
- return Boolean(presentation.slides?.[slideIndex]?.design?.logo ?? presentation.design?.logo ?? primaryOrganization(presentation)?.logo);
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
+ }
118
155
  }
119
156
 
120
157
  /** The variable names a zone's `text` uses (`{{name}}` and `{{name|format}}`); a token with a backslash before it is literal text. */
@@ -124,21 +161,41 @@ function zoneTextVariables(text) {
124
161
  return names;
125
162
  }
126
163
 
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}`;
177
+ }
178
+
127
179
  /**
128
- * Plain-language warnings for settings that need content the document does not have: a logo (a header or footer
129
- * zone with `logo: true`, picture bullets), the organization's social profiles, or a value a zone's `text` asks for
130
- * with a variable (`{{organization.name}}` without an organization, `{{slide.section}}` on a slide without a section).
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).
131
184
  * The renderers fall back to a glyph or report unresolved content; the editor surfaces it before export.
132
185
  */
133
186
  export function designWarnings(presentation, slideIndex = 0) {
134
187
  const slide = presentation.slides?.[slideIndex];
135
188
  const design = { ...presentation.design, ...slide?.design };
136
189
  const warnings = [];
137
- 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.
138
191
  for (const which of ["header", "footer"])
139
- for (const zone of HEADER_FOOTER_ZONES)
140
- if (!hasLogo && isObject(design[which]) && design[which][zone]?.logo === true)
141
- 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.` });
142
199
  const organization = primaryOrganization(presentation);
143
200
  let builtins;
144
201
  for (const which of ["header", "footer"])
@@ -160,8 +217,8 @@ export function designWarnings(presentation, slideIndex = 0) {
160
217
  warnings.push({ code: "unresolved-content", path, message: `The ${which} ${zone} zone shows {{${name}}}, but the presentation has no value for it.` });
161
218
  }
162
219
  }
163
- if (!hasLogo && design.listBullet === "image")
164
- 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." });
165
222
  return warnings;
166
223
  }
167
224
 
@@ -225,27 +282,29 @@ function fontSchemeWithAccent(presentation, base, family) {
225
282
  return object;
226
283
  }
227
284
 
228
- function logoSetOf(existing) {
229
- if (existing === undefined) return {};
230
- if (typeof existing === "string" || (isObject(existing) && Object.hasOwn(existing, "src"))) return { default: existing };
231
- return isObject(existing) ? { ...existing } : {};
232
- }
233
- function collapseLogo(set) {
234
- const keys = Object.keys(set);
235
- if (!keys.length) return null;
236
- if (keys.length === 1 && keys[0] === "default") return set.default;
237
- return set;
238
- }
239
-
240
285
  // --- design option ----------------------------------------------------------------------------
241
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
+
242
301
  /**
243
302
  * Compute the patch that sets one design option, without touching any session. `value === null`
244
303
  * removes the option at that scope so it is inherited again. The object-valued `watermark` merges the
245
304
  * fields you pass into the existing object; a `null` field removes it.
246
- * Options: `slideIndex` (one slide instead of the deck), `clearSlideOverrides` (deck scope: also
247
- * remove slide-level values that hide it) and `index` (organizationLogo with several
248
- * 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.
249
308
  */
250
309
  export function prepareDesignOption(presentation, option, value, options = {}) {
251
310
  const descriptor = BY_ID[option];
@@ -253,39 +312,23 @@ export function prepareDesignOption(presentation, option, value, options = {}) {
253
312
  if (!isObject(presentation)) throw fail("invalid-input", "Design options need an OPF document object.");
254
313
  if (value === undefined) throw fail("invalid-design-value", "Pass a value, or null to remove the option.", { option });
255
314
  const { scope, base, slideIndex } = scopeOf(presentation, option, options);
256
- let patches = [];
257
- let keys = [];
258
-
259
- if (descriptor.type === "organization-logo") {
260
- const organization = presentation.organization;
261
- const index = options.index ?? 0;
262
- const owner = Array.isArray(organization) ? organization[index] : organization;
263
- if (!isObject(owner)) throw fail("missing-owner", "Add an organization to the document before setting its logo.", { option });
264
- const path = Array.isArray(organization) ? ["organization", String(index), "logo"] : ["organization", "logo"];
265
- const present = Object.hasOwn(owner, "logo");
266
- if (value === null) patches = present ? [{ op: "remove", path: opfPathToJsonPointer(path) }] : [];
267
- else if (!present) patches = [{ op: "add", path: opfPathToJsonPointer(path), value: structuredClone(value) }];
268
- 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) };
269
326
  } else {
270
- let entries;
271
- if (descriptor.type === "enum") {
272
- if (value !== null && !ENUMS[option].includes(value)) throw fail("invalid-design-value", `${descriptor.label} is one of ${ENUMS[option].join(", ")}.`, { option, value });
273
- entries = { [option]: value };
274
- } else if (descriptor.type === "boolean") {
275
- if (value !== null && typeof value !== "boolean") throw fail("invalid-design-value", `${descriptor.label} is true or false.`, { option, value });
276
- entries = { [option]: value };
277
- } else if (descriptor.type === "font") {
278
- entries = { fontScheme: fontSchemeWithAccent(presentation, base, value) };
279
- } else if (descriptor.type === "logo") {
280
- 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 });
281
- entries = { logo: value };
282
- } else {
283
- const existing = ownDesign(presentation, base).watermark;
284
- entries = { watermark: mergedWatermark(existing, value) };
285
- }
286
- keys = Object.keys(entries);
287
- patches = designPatches(presentation, base, entries);
327
+ const existing = ownDesign(presentation, base).watermark;
328
+ entries = { watermark: mergedWatermark(existing, value) };
288
329
  }
330
+ const keys = Object.keys(entries);
331
+ const patches = designPatches(presentation, base, entries);
289
332
 
290
333
  let shadowedSlides = [];
291
334
  if (scope === "deck" && keys.length) {
@@ -322,16 +365,11 @@ export function setDesignOption(editor, option, value, options = {}) {
322
365
  /**
323
366
  * Read one design option for a panel: `{ value, scope, inherited }` where `scope` is "slide" when the
324
367
  * slide's own design sets it, "deck" when the deck does, and "default" when neither does.
325
- * `accentFont` reads the family name; `organizationLogo` reads the organization.
368
+ * `accentFont` reads the family name; `logo` reads `design.logo` (a reference, or false).
326
369
  */
327
370
  export function getDesignOption(presentation, option, options = {}) {
328
371
  const descriptor = BY_ID[option];
329
372
  if (!descriptor) throw fail("unknown-design-option", `Unknown design option: ${option}.`, { option });
330
- if (descriptor.type === "organization-logo") {
331
- const organization = presentation.organization;
332
- const owner = Array.isArray(organization) ? organization[options.index ?? 0] : organization;
333
- return { value: owner?.logo, scope: owner?.logo === undefined ? "default" : "deck", inherited: false };
334
- }
335
373
  const key = descriptor.type === "font" ? "fontScheme" : option;
336
374
  const pick = (design) => {
337
375
  const value = design?.[key];
@@ -344,48 +382,120 @@ export function getDesignOption(presentation, option, options = {}) {
344
382
  return { value: undefined, scope: "default", inherited: false };
345
383
  }
346
384
 
347
- // --- 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
+ }
348
409
 
349
410
  /**
350
- * Compute the patch that sets (a source, asset reference or Asset object) or clears (`null`) one logo
351
- * variant of `design.logo`. A single default logo stays a bare source; adding a second variant turns
352
- * 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).
353
416
  */
354
- export function prepareLogoVariant(presentation, variant, source, options = {}) {
355
- if (!LOGO_VARIANTS.includes(variant)) throw fail("invalid-design-value", `Logo variant is one of ${LOGO_VARIANTS.join(", ")}.`, { variant });
356
- 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 });
357
- if (typeof source === "string" && !source.trim()) throw fail("invalid-design-value", "Enter an image source or asset reference, or clear the variant.", { variant });
358
- const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
359
- const set = logoSetOf(ownDesign(presentation, base).logo);
360
- if (source === null) delete set[variant];
361
- else set[variant] = typeof source === "string" ? source.trim() : source;
362
- const patches = designPatches(presentation, base, { logo: collapseLogo(set) });
363
- return finish(presentation, patches, { option: "logo", variant, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: [] });
364
- }
365
- /** Set or clear one logo variant as a single undoable transaction. */
366
- 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 = {}) {
367
448
  checkEditor(editor);
368
449
  const { meta, ...rest } = options;
369
- return apply(editor, prepareLogoVariant(editor.presentation, variant, source, rest), meta);
450
+ return apply(editor, prepareOrganizationLogo(editor.presentation, shape, source, rest), meta);
370
451
  }
371
- /** The variants `design.logo` sets at a scope: `{ variant: source }`, a bare logo reported as `default`. */
372
- export function readLogoVariants(presentation, options = {}) {
373
- const own = options.slideIndex === undefined ? presentation.design?.logo : presentation.slides?.[options.slideIndex]?.design?.logo;
374
- 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 };
375
484
  }
376
485
 
377
486
  // --- header and footer zones ------------------------------------------------------------------
378
487
 
379
488
  /**
380
- * Compute the patch that edits one header or footer zone. `fields` merges into the zone (logo, text,
489
+ * Compute the patch that edits one header or footer zone. `fields` merges into the zone (text,
381
490
  * image, date, dateFormat, socials); `null`, `false` for a flag, or an empty string removes a field.
382
491
  * Generated values (slide number, slide count, section, organization, speaker, deck name) are `{{ }}`
383
- * variables in `text` (`ZONE_VALUES`, `insertZoneValue`). A zone left empty is removed, then an empty
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
384
494
  * header or footer, so a slide never carries `{}`. A slide's own header or footer replaces the deck's
385
495
  * whole one, so the first edit on a slide that has none of its own starts from a copy of the deck's
386
496
  * (the other zones stay); a slide emptied that way hides the furniture (`false`) instead of
387
497
  * inheriting it again. Setting a field on a suppressed (`false`) header replaces the suppression.
388
- * `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.
389
499
  */
390
500
  export function prepareHeaderFooterZone(presentation, which, zone, fields, options = {}) {
391
501
  if (!["header", "footer"].includes(which)) throw fail("invalid-design-value", "Choose header or footer.", { which });
@@ -440,6 +550,26 @@ export function prepareZoneValue(presentation, which, zone, name, options = {})
440
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 });
441
551
  return prepareHeaderFooterZone(presentation, which, zone, { text: `${text.slice(0, from)}${value.token}${text.slice(to)}` }, rest);
442
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
+ }
443
573
  /** Insert a value's token into one zone's `text` as a single undoable transaction. */
444
574
  export function insertZoneValue(editor, which, zone, name, options = {}) {
445
575
  checkEditor(editor);
package/dist/export.d.ts CHANGED
@@ -1,59 +1,65 @@
1
- import type { RenderSvgOptions } from "@openpresentation/opf-render";
1
+ import type { Catalog, ConvertResult, ConvertedFile, Finding, SlideSelection } from "@openpresentation/opf";
2
+ import type { ToSvgOptions } from "@openpresentation/opf-render";
2
3
  import type { EditorFonts } from "./canvas.js";
3
4
 
5
+ /** Core's result types: `convert` resolves exactly what core's in-memory `convert` resolves. */
6
+ export type { ConvertResult, ConvertedFile };
7
+
4
8
  export type ExportFormat = "pdf" | "png" | "svg";
5
9
  export declare const EXPORT_FORMATS: Readonly<Record<ExportFormat, { extension: string; type: string; label: string }>>;
6
10
  /** Largest PNG scale offered (4: a 1280 x 720 slide is 5120 x 2880 pixels). */
7
11
  export declare const MAX_PNG_SCALE: 4;
8
12
 
9
13
  /**
10
- * The download name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg dropped), else the slugified `name`, else "presentation",
14
+ * The file name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg dropped), else the slugified `name`, else "presentation",
11
15
  * then `suffix` and `.extension`.
12
16
  */
13
17
  export declare function exportFileName(deck: { filename?: string; name?: string } | undefined, extension: string, suffix?: string): string;
14
- /** Slide numbers (0-based) an export covers; hidden slides are skipped in an "all" export unless `includeHidden`. */
15
- export declare function slidesToExport(deck: { slides?: Array<{ hidden?: boolean }> }, options?: { slides?: "current" | "all" | number[]; slideIndex?: number; includeHidden?: boolean }): number[];
18
+ /**
19
+ * The slide numbers (one-based, ascending) a conversion covers: the `slides` selection (core's `parseSlideSelection`; hidden slides it
20
+ * names are included), else every slide that is not hidden (every slide with `includeHidden`). `[]` for a deck without slides. A malformed
21
+ * selection or a slide past the end throws core's `OPFApiError` `invalid-option`.
22
+ */
23
+ export declare function slidesToConvert(deck: { slides?: Array<{ hidden?: boolean }> }, options?: { slides?: SlideSelection; includeHidden?: boolean }): number[];
16
24
  /** The registry's faces marked to embed only where a slide draws them; a face with a non-permissive license text is left out (`onSkip` is told). */
17
25
  export declare function embeddableFonts(registry: object | undefined, onSkip?: (face: { family: string; weight: number; italic?: boolean; license?: string }) => void): Array<Record<string, unknown>>;
18
26
 
19
- export interface ExportDiagnostic {
20
- code: string;
21
- severity: "info" | "warning";
22
- message: string;
23
- /** 0-based slide number, when the note belongs to one slide. */
24
- slide?: number;
25
- path?: string;
26
- family?: string;
27
- }
28
- export declare function describeDiagnostic(diagnostic: { code?: string; message?: string; path?: string; family?: string; [key: string]: unknown }, source?: "render" | "pdf"): ExportDiagnostic;
29
-
30
- export interface ExportProgress {
27
+ export interface ConvertProgress {
31
28
  stage: "fonts" | "render" | "convert" | "archive" | "done";
32
29
  done: number;
33
30
  total: number;
34
31
  message?: string;
35
32
  }
36
- export interface ExportFile { name: string; type: string; bytes: Uint8Array }
37
- export interface ExportOptions {
33
+ export interface ConvertOptions {
38
34
  format: ExportFormat;
39
- /** "all" (default), "current" (with `slideIndex`) or slide numbers (0-based). */
40
- slides?: "current" | "all" | number[];
41
- slideIndex?: number;
35
+ /** The slides, counted from 1: `3`, `"1-3"`, `"1,3-5"`, `"2-"`, `[1, 3]` (core's `SlideSelection`). Exactly those slides, hidden or not. Omitted: every slide that is not hidden. */
36
+ slides?: SlideSelection;
37
+ /** With `slides` omitted, hidden slides too. */
42
38
  includeHidden?: boolean;
43
- /** "vector" (default): selectable text and vector shapes; "raster": an image per slide. */
44
- pdfMode?: "vector" | "raster";
39
+ /** PDF only: each page an image of its slide (larger, text not selectable). Default: selectable text and vector shapes. */
40
+ raster?: boolean;
45
41
  /**
46
42
  * Raster PDF only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry imports no PDF library (opf-render 0.16), so the host passes it;
47
- * without it a `pdfMode: "raster"` PDF rejects with the renderer's `converter-missing`. Vector PDF, PNG and SVG need no option.
43
+ * without it a `raster: true` PDF rejects with the renderer's `converter-missing`. Vector PDF, PNG and SVG need no option.
48
44
  */
49
45
  pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
46
+ /** PNG and SVG only: one ZIP archive of the slide files (its `entries` name them), even for one slide. Default: one file per slide. */
47
+ zip?: boolean;
48
+ /** The base name of the files (`name.pdf`, `name-001.png`, `name.zip`). Default: the deck's `filename`, else its slugified `name`, else "presentation". */
49
+ name?: string;
50
50
  /** PNG pixel density (and the raster PDF's), 1 to 4; default 2. */
51
51
  scale?: number;
52
52
  /** The options the host draws its preview with (`catalogs`, `date`, ...); the fonts go in `fonts`. */
53
- renderOptions?: RenderSvgOptions;
53
+ renderOptions?: ToSvgOptions;
54
54
  /** Host catalogs (merged with `renderOptions.catalogs`). The deck is embedded first (core `embed`), like a saved file. */
55
- catalogs?: readonly import("@openpresentation/opf").Catalog[];
56
- /** 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. */
55
+ catalogs?: readonly Catalog[];
56
+ /**
57
+ * 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.
58
+ * Create a browser handle with `loadFonts({ subsetWasm, shapeWasm })` (the URLs, bytes or compiled modules of harfbuzzjs's `harfbuzz-subset.wasm` and `harfbuzz.wasm`, which the host
59
+ * serves) and `convert` uses them: an SVG embeds each face cut to the glyphs its slide draws (a few KB to tens of KB instead of whole faces of hundreds of KB to MBs), and
60
+ * `renderOptions.text: "paths"` draws outlines shaped by HarfBuzz instead of fontkit. A Node handle (`/fonts-node`) always has both. Only SVG files are subset (a PNG is a picture, and a PDF cuts its own
61
+ * subsets); a PNG takes the outlines, and a PDF is always drawn as selectable text (`text: "paths"` is ignored for it).
62
+ */
57
63
  fonts?: EditorFonts;
58
64
  /** Faces to embed instead of the registry's. */
59
65
  embeddedFonts?: Array<Record<string, unknown>>;
@@ -61,22 +67,23 @@ export interface ExportOptions {
61
67
  metadata?: Record<string, unknown>;
62
68
  /** The family the PDF uses when a requested one is missing; default "Roboto". */
63
69
  fallbackFamily?: string;
64
- /** Replace the renderer's `export-browser` entry (tests, or a host with its own converter). */
65
- convert?: { svgToPdf(svgs: string[], options?: object): Promise<Uint8Array>; svgToPng(svg: string, options?: object): Promise<Uint8Array> };
70
+ /** Replace the renderer's `export-browser` entry (tests, or a host with its own converter). Each takes the SVG the renderer drew. */
71
+ converters?: { toPdf(svgs: string[], options?: object): Promise<Uint8Array>; toPng(svg: string, options?: object): Promise<Uint8Array> };
66
72
  signal?: AbortSignal;
67
- onProgress?: (progress: ExportProgress) => void;
68
- onDiagnostic?: (diagnostic: ExportDiagnostic) => void;
69
- }
70
- export interface ExportResult {
71
- /** One PDF, PNG or SVG, or a ZIP of several. */
72
- download: ExportFile;
73
- files: ExportFile[];
74
- diagnostics: ExportDiagnostic[];
75
- /** The slide numbers (0-based) exported. */
76
- slides: number[];
73
+ onProgress?: (progress: ConvertProgress) => void;
74
+ /** Each finding as it is made (also in the result's `findings`). */
75
+ onFinding?: (finding: Finding) => void;
77
76
  }
78
77
  /**
79
- * Draw the deck with the renderer the preview uses and convert it. Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed` (the license rule left the PDF converter no face for some text), `export-no-slides`,
80
- * `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable` or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
78
+ * Draw the deck with the renderer the preview uses and convert it, in memory: core's `convert(deck, { format })` for the editor.
79
+ *
80
+ * `files`: one PDF (`pages`, and the one-based `slides` it shows); one PNG or SVG per slide (`slide`, one-based, `id`, `width`, `height`);
81
+ * or with `zip` one archive (`entries`). `findings`: core's Finding shape, `ruleId` `render/<code>` (the renderer), `pdf/<code>` (the PDF
82
+ * converter) or `fonts/export-font-license` (a face left out under the license rule); `slide` on a finding is zero-based, as the Finding
83
+ * schema defines it.
84
+ *
85
+ * Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed` (the license rule left the PDF converter no face for some text), `export-no-slides`,
86
+ * `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable`, `invalid-option` (a malformed slide selection or a
87
+ * slide past the end, from core's `parseSlideSelection`; `zip` with PDF; `raster` with PNG or SVG) or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
81
88
  */
82
- export declare function exportDeck(deck: unknown, options: ExportOptions): Promise<ExportResult>;
89
+ export declare function convert(deck: unknown, options: ConvertOptions): Promise<ConvertResult>;