@openpresentation/opf-editor 0.17.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.
- package/README.md +50 -42
- package/dist/assets.js +1 -1
- package/dist/canvas.d.ts +4 -4
- package/dist/canvas.js +4 -4
- package/dist/data.d.ts +3 -3
- package/dist/data.js +3 -3
- package/dist/design-controls.js +157 -44
- package/dist/design-options.d.ts +78 -22
- package/dist/design-options.js +240 -110
- package/dist/export.d.ts +42 -41
- package/dist/export.js +151 -87
- package/dist/font-gate.js +1 -1
- package/dist/review.d.ts +6 -1
- package/dist/review.js +37 -1
- package/dist/template-panel.d.ts +1 -1
- package/dist/template-panel.js +7 -4
- package/dist/templates.js +1 -1
- package/package.json +6 -5
package/dist/design-options.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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 = ["
|
|
29
|
-
export const ZONE_FIELDS = Object.freeze(["
|
|
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", "
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
*
|
|
130
|
-
* with a variable (`{{organization.name}}` without an
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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 (
|
|
164
|
-
warnings.push({ code: "unresolved-logo", path: "design.listBullet", message: "Picture bullets use the logo, but no 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)
|
|
247
|
-
* remove slide-level values that hide it)
|
|
248
|
-
*
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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; `
|
|
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
|
|
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 (
|
|
351
|
-
*
|
|
352
|
-
*
|
|
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
|
|
355
|
-
if (!
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
if (source
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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,
|
|
450
|
+
return apply(editor, prepareOrganizationLogo(editor.presentation, shape, source, rest), meta);
|
|
370
451
|
}
|
|
371
|
-
/**
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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 (
|
|
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`)
|
|
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
|
|
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,58 +1,58 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
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
|
-
/**
|
|
15
|
-
|
|
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
|
|
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
|
|
37
|
-
export interface ExportOptions {
|
|
33
|
+
export interface ConvertOptions {
|
|
38
34
|
format: ExportFormat;
|
|
39
|
-
/** "
|
|
40
|
-
slides?:
|
|
41
|
-
|
|
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
|
-
/**
|
|
44
|
-
|
|
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 `
|
|
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?:
|
|
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
|
|
55
|
+
catalogs?: readonly Catalog[];
|
|
56
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. */
|
|
57
57
|
fonts?: EditorFonts;
|
|
58
58
|
/** Faces to embed instead of the registry's. */
|
|
@@ -61,22 +61,23 @@ export interface ExportOptions {
|
|
|
61
61
|
metadata?: Record<string, unknown>;
|
|
62
62
|
/** The family the PDF uses when a requested one is missing; default "Roboto". */
|
|
63
63
|
fallbackFamily?: string;
|
|
64
|
-
/** Replace the renderer's `export-browser` entry (tests, or a host with its own converter). */
|
|
65
|
-
|
|
64
|
+
/** Replace the renderer's `export-browser` entry (tests, or a host with its own converter). Each takes the SVG the renderer drew. */
|
|
65
|
+
converters?: { toPdf(svgs: string[], options?: object): Promise<Uint8Array>; toPng(svg: string, options?: object): Promise<Uint8Array> };
|
|
66
66
|
signal?: AbortSignal;
|
|
67
|
-
onProgress?: (progress:
|
|
68
|
-
|
|
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[];
|
|
67
|
+
onProgress?: (progress: ConvertProgress) => void;
|
|
68
|
+
/** Each finding as it is made (also in the result's `findings`). */
|
|
69
|
+
onFinding?: (finding: Finding) => void;
|
|
77
70
|
}
|
|
78
71
|
/**
|
|
79
|
-
* Draw the deck with the renderer the preview uses and convert it
|
|
80
|
-
*
|
|
72
|
+
* Draw the deck with the renderer the preview uses and convert it, in memory: core's `convert(deck, { format })` for the editor.
|
|
73
|
+
*
|
|
74
|
+
* `files`: one PDF (`pages`, and the one-based `slides` it shows); one PNG or SVG per slide (`slide`, one-based, `id`, `width`, `height`);
|
|
75
|
+
* or with `zip` one archive (`entries`). `findings`: core's Finding shape, `ruleId` `render/<code>` (the renderer), `pdf/<code>` (the PDF
|
|
76
|
+
* converter) or `fonts/export-font-license` (a face left out under the license rule); `slide` on a finding is zero-based, as the Finding
|
|
77
|
+
* schema defines it.
|
|
78
|
+
*
|
|
79
|
+
* 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`, `invalid-option` (a malformed slide selection or a
|
|
81
|
+
* 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
82
|
*/
|
|
82
|
-
export declare function
|
|
83
|
+
export declare function convert(deck: unknown, options: ConvertOptions): Promise<ConvertResult>;
|