@openpresentation/opf-editor 0.12.1 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +71 -60
  2. package/dist/annotations.d.ts +16 -16
  3. package/dist/annotations.js +71 -79
  4. package/dist/assets.d.ts +7 -7
  5. package/dist/assets.js +15 -15
  6. package/dist/background-options.d.ts +2 -2
  7. package/dist/background-options.js +6 -6
  8. package/dist/block-controls.js +7 -7
  9. package/dist/block-convert.d.ts +7 -7
  10. package/dist/block-convert.js +22 -21
  11. package/dist/blocks.d.ts +8 -8
  12. package/dist/blocks.js +41 -40
  13. package/dist/canvas-fields.js +35 -12
  14. package/dist/canvas.d.ts +22 -30
  15. package/dist/canvas.js +57 -64
  16. package/dist/chart-data.d.ts +10 -10
  17. package/dist/chart-data.js +21 -21
  18. package/dist/chart-options-panel.d.ts +2 -2
  19. package/dist/chart-options-panel.js +82 -8
  20. package/dist/chart-options.d.ts +35 -9
  21. package/dist/chart-options.js +133 -40
  22. package/dist/checks.js +21 -0
  23. package/dist/color-authoring.js +1 -1
  24. package/dist/content-actions.d.ts +15 -15
  25. package/dist/content-actions.js +71 -70
  26. package/dist/content-controls.js +12 -12
  27. package/dist/data-grid.js +7 -8
  28. package/dist/data.d.ts +4 -4
  29. package/dist/data.js +5 -5
  30. package/dist/dataset-refs.d.ts +2 -2
  31. package/dist/dataset-refs.js +5 -5
  32. package/dist/design-controls.js +61 -44
  33. package/dist/design-options.d.ts +16 -14
  34. package/dist/design-options.js +86 -74
  35. package/dist/edit-helpers.js +8 -7
  36. package/dist/exact-source.js +5 -5
  37. package/dist/export.d.ts +5 -7
  38. package/dist/export.js +40 -14
  39. package/dist/find-panel.js +5 -5
  40. package/dist/find-replace.d.ts +3 -3
  41. package/dist/find-replace.js +23 -20
  42. package/dist/font-gate.js +49 -58
  43. package/dist/galleries.js +36 -52
  44. package/dist/grid-model.d.ts +25 -27
  45. package/dist/grid-model.js +92 -102
  46. package/dist/image-crop.d.ts +4 -4
  47. package/dist/image-crop.js +20 -20
  48. package/dist/image-cropper.js +3 -3
  49. package/dist/index.d.ts +28 -28
  50. package/dist/index.js +74 -90
  51. package/dist/json-options.js +17 -16
  52. package/dist/layout-handles.js +5 -5
  53. package/dist/layout-placeholders.js +4 -4
  54. package/dist/layout.d.ts +2 -2
  55. package/dist/layout.js +8 -7
  56. package/dist/numbering-panel.js +3 -5
  57. package/dist/numbering.d.ts +3 -5
  58. package/dist/numbering.js +14 -26
  59. package/dist/outline-view.js +24 -24
  60. package/dist/outline.d.ts +12 -12
  61. package/dist/outline.js +52 -50
  62. package/dist/persistence.d.ts +1 -1
  63. package/dist/persistence.js +19 -18
  64. package/dist/presentation-ids.js +2 -2
  65. package/dist/react.js +3 -3
  66. package/dist/review-panel.d.ts +30 -13
  67. package/dist/review-panel.js +154 -53
  68. package/dist/review.d.ts +36 -43
  69. package/dist/review.js +92 -53
  70. package/dist/rich-text-input.js +4 -3
  71. package/dist/rich-text-toolbar.js +5 -3
  72. package/dist/rich-text.js +5 -2
  73. package/dist/schema-inspector.d.ts +2 -2
  74. package/dist/schema-inspector.js +16 -11
  75. package/dist/schema.d.ts +1 -1
  76. package/dist/schema.js +25 -5
  77. package/dist/slide-manager.d.ts +2 -2
  78. package/dist/slide-manager.js +18 -18
  79. package/dist/slides.d.ts +20 -20
  80. package/dist/slides.js +99 -97
  81. package/dist/svelte.js +1 -1
  82. package/dist/switches.d.ts +10 -10
  83. package/dist/switches.js +124 -114
  84. package/dist/table-options.d.ts +8 -8
  85. package/dist/table-options.js +28 -27
  86. package/dist/table-structure.d.ts +10 -10
  87. package/dist/table-structure.js +21 -21
  88. package/dist/template-panel.d.ts +3 -3
  89. package/dist/template-panel.js +37 -13
  90. package/dist/templates.d.ts +22 -11
  91. package/dist/templates.js +48 -55
  92. package/dist/transfer.d.ts +4 -4
  93. package/dist/transfer.js +49 -59
  94. package/package.json +8 -6
  95. package/dist/font-defaults.js +0 -25
@@ -3,8 +3,9 @@
3
3
  // by default or on one slide with `slideIndex`, exactly like the dimension switches. Every
4
4
  // function has a `prepare…` form that returns the patch without touching a session, and a
5
5
  // session form that commits it with `meta.source: "design-option"`.
6
- import { getValueAtPath, opfPathToJsonPointer, validateOpfDocument } from "./index.js";
6
+ import { getValueAtPath, opfPathToJsonPointer } from "./index.js";
7
7
  import { checkedDocument, designPatches, fail, same } from "./edit-helpers.js";
8
+ import { checkFormat } from "./checks.js";
8
9
 
9
10
  /** The `design.logo` variant slots of a LogoSet, in schema order. */
10
11
  export const LOGO_VARIANTS = Object.freeze([
@@ -23,8 +24,8 @@ export const LOGO_VARIANTS = Object.freeze([
23
24
  ]);
24
25
  export const HEADER_FOOTER_ZONES = Object.freeze(["left", "center", "right"]);
25
26
  // Flag fields of a header/footer zone: false is stored as "absent" (so is a `date` of false).
26
- const ZONE_FLAGS = ["logo", "slideNumber", "organization", "socials", "section"];
27
- export const ZONE_FIELDS = Object.freeze(["logo", "text", "image", "slideNumber", "slideNumberFormat", "date", "dateFormat", "organization", "socials", "section"]);
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"]);
28
29
  /** Date tokens a `dateFormat` understands (English names, independent of the host locale). */
29
30
  export const DATE_FORMAT_TOKENS = Object.freeze(["yyyy", "yy", "MMMM", "MMM", "MM", "M", "dd", "d", "EEEE", "EEE"]);
30
31
  const ISO_DATE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
@@ -32,7 +33,7 @@ const ISO_DATE = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/;
32
33
  // Friendly validation of one zone field before the schema sees it.
33
34
  function checkZoneField(key, value) {
34
35
  const bad = (message) => fail("invalid-design-value", message, { field: key, value });
35
- if (["logo", "slideNumber", "organization", "socials", "section"].includes(key) && typeof value !== "boolean") throw bad(`${key} is true or false.`);
36
+ if (ZONE_FLAGS.includes(key) && typeof value !== "boolean") throw bad(`${key} is true or false.`);
36
37
  if (key === "text" && typeof value !== "string") throw bad("Text is a string.");
37
38
  if (key === "image" && typeof value !== "string" && !isObject(value)) throw bad("An image is a source, an asset reference or an asset object.");
38
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}.");
@@ -62,7 +63,7 @@ export const DESIGN_OPTIONS = Object.freeze([
62
63
  { id: "chartPrimary", label: "Primary chart position", type: "enum", values: ENUMS.chartPrimary, scopes: ["deck", "slide"], path: "design.chartPrimary" },
63
64
  { id: "listBullet", label: "List bullets", type: "enum", values: ENUMS.listBullet, scopes: ["deck", "slide"], path: "design.listBullet" },
64
65
  { id: "contentBox", label: "Content box", type: "boolean", scopes: ["deck", "slide"], path: "design.contentBox" },
65
- { id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent.family" },
66
+ { id: "accentFont", label: "Accent font", type: "font", scopes: ["deck", "slide"], path: "design.fontScheme.accent" },
66
67
  { id: "logo", label: "Logo", type: "logo", scopes: ["deck", "slide"], path: "design.logo" },
67
68
  { id: "organizationLogo", label: "Organization logo", type: "organization-logo", scopes: ["deck"], path: "organization.logo" },
68
69
  { id: "watermark", label: "Watermark", type: "watermark", scopes: ["deck", "slide"], path: "design.watermark" },
@@ -72,27 +73,32 @@ const BY_ID = Object.fromEntries(DESIGN_OPTIONS.map((option) => [option.id, opti
72
73
 
73
74
  const isObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
74
75
 
75
- function scopeOf(document, option, options) {
76
+ function scopeOf(presentation, option, options) {
76
77
  const index = options.slideIndex;
77
78
  if (index === undefined) return { scope: "deck", base: [] };
78
79
  if (!BY_ID[option]?.scopes.includes("slide")) throw fail("invalid-scope", `${BY_ID[option]?.label ?? option} applies to the whole presentation, not one slide.`, { option });
79
- if (!Number.isInteger(index) || !document.slides?.[index]) throw fail("slide-index-out-of-range", "Choose an existing slide for this option.", { slideIndex: index });
80
+ if (!Number.isInteger(index) || !presentation.slides?.[index]) throw fail("slide-index-out-of-range", "Choose an existing slide for this option.", { slideIndex: index });
80
81
  return { scope: "slide", base: ["slides", String(index)], slideIndex: index };
81
82
  }
82
83
 
83
- function ownDesign(document, base) {
84
- const design = getValueAtPath(document, base.length ? [...base, "design"] : ["design"]);
84
+ function ownDesign(presentation, base) {
85
+ const design = getValueAtPath(presentation, base.length ? [...base, "design"] : ["design"]);
85
86
  return isObject(design) ? design : {};
86
87
  }
87
88
 
88
- function primaryOrganization(document) {
89
- const list = Array.isArray(document.organization) ? document.organization : document.organization ? [document.organization] : [];
89
+ function primaryOrganization(presentation) {
90
+ const list = Array.isArray(presentation.organization) ? presentation.organization : presentation.organization ? [presentation.organization] : [];
90
91
  return list.find((entry) => entry?.role === "primary") ?? list[0];
91
92
  }
92
93
 
94
+ function firstSpeaker(presentation) {
95
+ const list = Array.isArray(presentation.speaker) ? presentation.speaker : presentation.speaker ? [presentation.speaker] : [];
96
+ return list[0];
97
+ }
98
+
93
99
  /** Whether a logo resolves for the slide: slide design, then deck design, then the primary organization. */
94
- export function hasResolvableLogo(document, slideIndex) {
95
- return Boolean(document.slides?.[slideIndex]?.design?.logo ?? document.design?.logo ?? primaryOrganization(document)?.logo);
100
+ export function hasResolvableLogo(presentation, slideIndex) {
101
+ return Boolean(presentation.slides?.[slideIndex]?.design?.logo ?? presentation.design?.logo ?? primaryOrganization(presentation)?.logo);
96
102
  }
97
103
 
98
104
  /**
@@ -100,21 +106,23 @@ export function hasResolvableLogo(document, slideIndex) {
100
106
  * footer zone with `logo: true`, or picture bullets. The renderers fall back to a glyph or report
101
107
  * unresolved content; the editor surfaces it before export.
102
108
  */
103
- export function designWarnings(document, slideIndex = 0) {
104
- const slide = document.slides?.[slideIndex];
105
- const design = { ...document.design, ...slide?.design };
109
+ export function designWarnings(presentation, slideIndex = 0) {
110
+ const slide = presentation.slides?.[slideIndex];
111
+ const design = { ...presentation.design, ...slide?.design };
106
112
  const warnings = [];
107
- const hasLogo = hasResolvableLogo(document, slideIndex);
113
+ const hasLogo = hasResolvableLogo(presentation, slideIndex);
108
114
  for (const which of ["header", "footer"])
109
115
  for (const zone of HEADER_FOOTER_ZONES)
110
116
  if (!hasLogo && isObject(design[which]) && design[which][zone]?.logo === true)
111
117
  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.` });
112
- const organization = primaryOrganization(document);
118
+ const organization = primaryOrganization(presentation);
113
119
  for (const which of ["header", "footer"])
114
120
  for (const zone of HEADER_FOOTER_ZONES) {
115
121
  const item = isObject(design[which]) ? design[which][zone] : undefined;
116
122
  if (item?.organization === true && !organization)
117
123
  warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.organization`, message: `The ${which} ${zone} zone shows the organization, but the presentation has none.` });
124
+ if (item?.speaker === true && !firstSpeaker(presentation)?.name)
125
+ 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.` });
118
126
  if (item?.socials === true && !organization?.socials)
119
127
  warnings.push({ code: "unresolved-content", path: `design.${which}.${zone}.socials`, message: `The ${which} ${zone} zone shows social profiles, but the organization has none.` });
120
128
  }
@@ -123,17 +131,17 @@ export function designWarnings(document, slideIndex = 0) {
123
131
  return warnings;
124
132
  }
125
133
 
126
- function shadowed(document, keys) {
127
- return (document.slides ?? []).flatMap((slide, index) => (keys.some((key) => slide?.design?.[key] !== undefined) ? [index] : []));
134
+ function shadowed(presentation, keys) {
135
+ return (presentation.slides ?? []).flatMap((slide, index) => (keys.some((key) => slide?.design?.[key] !== undefined) ? [index] : []));
128
136
  }
129
137
 
130
- function finish(document, patches, extra) {
131
- const before = validateOpfDocument(document);
132
- const next = checkedDocument(document, patches, before);
138
+ function finish(presentation, patches, extra) {
139
+ const before = checkFormat(presentation);
140
+ const next = checkedDocument(presentation, patches, before);
133
141
  const slideIndex = extra.slideIndex;
134
142
  return {
135
143
  ...extra,
136
- document: structuredClone(next),
144
+ presentation: structuredClone(next),
137
145
  patches,
138
146
  changed: patches.length > 0,
139
147
  warnings: designWarnings(next, slideIndex ?? 0),
@@ -145,16 +153,20 @@ function finish(document, patches, extra) {
145
153
  function mergedWatermark(existing, value) {
146
154
  if (value === null || value === false) return value;
147
155
  if (typeof value === "string") return value;
148
- if (!isObject(value)) throw fail("invalid-design-value", "A watermark is false, an image source or { src, opacity }.", { value });
156
+ if (!isObject(value)) throw fail("invalid-design-value", "A watermark is false, an image source, { src, opacity } or { text, opacity }.", { value });
149
157
  const base = typeof existing === "string" ? { src: existing } : isObject(existing) ? { ...existing } : {};
150
158
  const merged = { ...base };
151
159
  for (const [key, entry] of Object.entries(value)) {
152
160
  if (entry === null || entry === undefined) delete merged[key];
153
161
  else merged[key] = entry;
154
162
  }
163
+ // FA-13: a watermark is an image (src) or a text stamp (text), never both: the field set last replaces the other.
164
+ if (value.text !== undefined && value.text !== null && value.src === undefined) delete merged.src;
165
+ else if (value.src !== undefined && value.src !== null && value.text === undefined) delete merged.text;
166
+ if (merged.text !== undefined && (typeof merged.text !== "string" || !merged.text.trim())) throw fail("invalid-design-value", "Watermark text is a non-empty string.", { value });
155
167
  if (merged.opacity !== undefined && (typeof merged.opacity !== "number" || merged.opacity < 0 || merged.opacity > 1))
156
168
  throw fail("invalid-design-value", "Watermark opacity is a number from 0 to 1.", { value });
157
- if (merged.src === undefined && Object.keys(merged).length) throw fail("invalid-design-value", "Choose the watermark image before setting its opacity.", { value });
169
+ if (merged.src === undefined && merged.text === undefined && Object.keys(merged).length) throw fail("invalid-design-value", "Choose the watermark image before setting its opacity (or give it text instead).", { value });
158
170
  if (Object.keys(merged).length === 1 && typeof merged.src === "string") return merged.src;
159
171
  if (!Object.keys(merged).length) return null;
160
172
  if (merged.opacity === undefined) throw fail("invalid-design-value", "Set an opacity from 0 to 1 for the watermark.", { value });
@@ -177,17 +189,17 @@ function mergedSlideImage(existing, value) {
177
189
  return merged;
178
190
  }
179
191
 
180
- function fontSchemeWithAccent(document, base, family) {
192
+ function fontSchemeWithAccent(presentation, base, family) {
181
193
  // The slide's own font scheme, else the deck's: an object form is kept with its overrides.
182
- const own = ownDesign(document, base).fontScheme;
183
- const inherited = base.length ? document.design?.fontScheme : undefined;
194
+ const own = ownDesign(presentation, base).fontScheme;
195
+ const inherited = base.length ? presentation.design?.fontScheme : undefined;
184
196
  const source = own !== undefined ? own : inherited;
185
197
  const object = typeof source === "string" ? { id: source } : isObject(source) ? structuredClone(source) : {};
186
198
  if (family === null) {
187
199
  delete object.accent;
188
200
  } else {
189
201
  if (typeof family !== "string" || !family.trim()) throw fail("invalid-design-value", "Accent font is a non-empty family name, or null to clear it.", { value: family });
190
- object.accent = { ...(isObject(object.accent) ? object.accent : {}), family: family.trim() };
202
+ object.accent = family.trim();
191
203
  }
192
204
  const keys = Object.keys(object);
193
205
  if (!keys.length) return null;
@@ -217,17 +229,17 @@ function collapseLogo(set) {
217
229
  * remove slide-level values that hide it) and `index` (organizationLogo with several
218
230
  * organizations).
219
231
  */
220
- export function prepareDesignOption(document, option, value, options = {}) {
232
+ export function prepareDesignOption(presentation, option, value, options = {}) {
221
233
  const descriptor = BY_ID[option];
222
234
  if (!descriptor) throw fail("unknown-design-option", `Unknown design option: ${option}. Use one of ${DESIGN_OPTIONS.map((entry) => entry.id).join(", ")}.`, { option });
223
- if (!isObject(document)) throw fail("invalid-input", "Design options need an OPF document object.");
235
+ if (!isObject(presentation)) throw fail("invalid-input", "Design options need an OPF document object.");
224
236
  if (value === undefined) throw fail("invalid-design-value", "Pass a value, or null to remove the option.", { option });
225
- const { scope, base, slideIndex } = scopeOf(document, option, options);
237
+ const { scope, base, slideIndex } = scopeOf(presentation, option, options);
226
238
  let patches = [];
227
239
  let keys = [];
228
240
 
229
241
  if (descriptor.type === "organization-logo") {
230
- const organization = document.organization;
242
+ const organization = presentation.organization;
231
243
  const index = options.index ?? 0;
232
244
  const owner = Array.isArray(organization) ? organization[index] : organization;
233
245
  if (!isObject(owner)) throw fail("missing-owner", "Add an organization to the document before setting its logo.", { option });
@@ -245,38 +257,38 @@ export function prepareDesignOption(document, option, value, options = {}) {
245
257
  if (value !== null && typeof value !== "boolean") throw fail("invalid-design-value", `${descriptor.label} is true or false.`, { option, value });
246
258
  entries = { [option]: value };
247
259
  } else if (descriptor.type === "font") {
248
- entries = { fontScheme: fontSchemeWithAccent(document, base, value) };
260
+ entries = { fontScheme: fontSchemeWithAccent(presentation, base, value) };
249
261
  } else if (descriptor.type === "logo") {
250
262
  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 });
251
263
  entries = { logo: value };
252
264
  } else if (descriptor.type === "watermark") {
253
- const existing = ownDesign(document, base).watermark;
265
+ const existing = ownDesign(presentation, base).watermark;
254
266
  entries = { watermark: mergedWatermark(existing, value) };
255
267
  } else {
256
- const existing = ownDesign(document, base).slideImage;
268
+ const existing = ownDesign(presentation, base).slideImage;
257
269
  entries = { slideImage: mergedSlideImage(existing, value) };
258
270
  }
259
271
  keys = Object.keys(entries);
260
- patches = designPatches(document, base, entries);
272
+ patches = designPatches(presentation, base, entries);
261
273
  }
262
274
 
263
275
  let shadowedSlides = [];
264
276
  if (scope === "deck" && keys.length) {
265
- shadowedSlides = shadowed(document, keys);
277
+ shadowedSlides = shadowed(presentation, keys);
266
278
  if (options.clearSlideOverrides) {
267
279
  for (const index of shadowedSlides)
268
280
  for (const key of keys)
269
- if (document.slides[index].design?.[key] !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", key]) });
281
+ if (presentation.slides[index].design?.[key] !== undefined) patches.push({ op: "remove", path: opfPathToJsonPointer(["slides", String(index), "design", key]) });
270
282
  shadowedSlides = [];
271
283
  }
272
284
  }
273
- return finish(document, patches, { option, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: shadowedSlides });
285
+ return finish(presentation, patches, { option, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: shadowedSlides });
274
286
  }
275
287
 
276
288
  function apply(editor, prepared, meta = {}) {
277
- const { document, patches, ...summary } = prepared;
278
- void document;
279
- if (!prepared.changed) return { ...summary, document: editor.document, patches: [], inversePatches: [], validation: editor.validation };
289
+ const { presentation, patches, ...summary } = prepared;
290
+ void presentation;
291
+ if (!prepared.changed) return { ...summary, presentation: editor.presentation, patches: [], inversePatches: [], validation: editor.validation };
280
292
  const change = editor.applyPatch(patches, { ...meta, source: meta.source ?? "design-option", option: prepared.option, scope: prepared.scope });
281
293
  return { ...change, ...summary };
282
294
  }
@@ -289,30 +301,30 @@ function checkEditor(editor) {
289
301
  export function setDesignOption(editor, option, value, options = {}) {
290
302
  checkEditor(editor);
291
303
  const { meta, ...rest } = options;
292
- return apply(editor, prepareDesignOption(editor.document, option, value, rest), meta);
304
+ return apply(editor, prepareDesignOption(editor.presentation, option, value, rest), meta);
293
305
  }
294
306
 
295
307
  /**
296
308
  * Read one design option for a panel: `{ value, scope, inherited }` where `scope` is "slide" when the
297
309
  * slide's own design sets it, "deck" when the deck does, and "default" when neither does.
298
- * `accentFont` reads the family; `organizationLogo` reads the organization.
310
+ * `accentFont` reads the family name; `organizationLogo` reads the organization.
299
311
  */
300
- export function getDesignOption(document, option, options = {}) {
312
+ export function getDesignOption(presentation, option, options = {}) {
301
313
  const descriptor = BY_ID[option];
302
314
  if (!descriptor) throw fail("unknown-design-option", `Unknown design option: ${option}.`, { option });
303
315
  if (descriptor.type === "organization-logo") {
304
- const organization = document.organization;
316
+ const organization = presentation.organization;
305
317
  const owner = Array.isArray(organization) ? organization[options.index ?? 0] : organization;
306
318
  return { value: owner?.logo, scope: owner?.logo === undefined ? "default" : "deck", inherited: false };
307
319
  }
308
320
  const key = descriptor.type === "font" ? "fontScheme" : option;
309
321
  const pick = (design) => {
310
322
  const value = design?.[key];
311
- return descriptor.type === "font" ? (isObject(value) ? value.accent?.family : undefined) : value;
323
+ return descriptor.type === "font" ? (isObject(value) ? value.accent : undefined) : value;
312
324
  };
313
- const slideValue = options.slideIndex === undefined ? undefined : pick(document.slides?.[options.slideIndex]?.design);
325
+ const slideValue = options.slideIndex === undefined ? undefined : pick(presentation.slides?.[options.slideIndex]?.design);
314
326
  if (slideValue !== undefined) return { value: slideValue, scope: "slide", inherited: false };
315
- const deckValue = pick(document.design);
327
+ const deckValue = pick(presentation.design);
316
328
  if (deckValue !== undefined) return { value: deckValue, scope: "deck", inherited: options.slideIndex !== undefined };
317
329
  return { value: undefined, scope: "default", inherited: false };
318
330
  }
@@ -324,26 +336,26 @@ export function getDesignOption(document, option, options = {}) {
324
336
  * variant of `design.logo`. A single default logo stays a bare source; adding a second variant turns
325
337
  * it into a LogoSet, and clearing back to the default collapses it again.
326
338
  */
327
- export function prepareLogoVariant(document, variant, source, options = {}) {
339
+ export function prepareLogoVariant(presentation, variant, source, options = {}) {
328
340
  if (!LOGO_VARIANTS.includes(variant)) throw fail("invalid-design-value", `Logo variant is one of ${LOGO_VARIANTS.join(", ")}.`, { variant });
329
341
  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 });
330
342
  if (typeof source === "string" && !source.trim()) throw fail("invalid-design-value", "Enter an image source or asset reference, or clear the variant.", { variant });
331
- const { scope, base, slideIndex } = scopeOf(document, "logo", options);
332
- const set = logoSetOf(ownDesign(document, base).logo);
343
+ const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
344
+ const set = logoSetOf(ownDesign(presentation, base).logo);
333
345
  if (source === null) delete set[variant];
334
346
  else set[variant] = typeof source === "string" ? source.trim() : source;
335
- const patches = designPatches(document, base, { logo: collapseLogo(set) });
336
- return finish(document, patches, { option: "logo", variant, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: [] });
347
+ const patches = designPatches(presentation, base, { logo: collapseLogo(set) });
348
+ return finish(presentation, patches, { option: "logo", variant, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: [] });
337
349
  }
338
350
  /** Set or clear one logo variant as a single undoable transaction. */
339
351
  export function setLogoVariant(editor, variant, source, options = {}) {
340
352
  checkEditor(editor);
341
353
  const { meta, ...rest } = options;
342
- return apply(editor, prepareLogoVariant(editor.document, variant, source, rest), meta);
354
+ return apply(editor, prepareLogoVariant(editor.presentation, variant, source, rest), meta);
343
355
  }
344
356
  /** The variants `design.logo` sets at a scope: `{ variant: source }`, a bare logo reported as `default`. */
345
- export function readLogoVariants(document, options = {}) {
346
- const own = options.slideIndex === undefined ? document.design?.logo : document.slides?.[options.slideIndex]?.design?.logo;
357
+ export function readLogoVariants(presentation, options = {}) {
358
+ const own = options.slideIndex === undefined ? presentation.design?.logo : presentation.slides?.[options.slideIndex]?.design?.logo;
347
359
  return logoSetOf(own);
348
360
  }
349
361
 
@@ -351,7 +363,7 @@ export function readLogoVariants(document, options = {}) {
351
363
 
352
364
  /**
353
365
  * Compute the patch that edits one header or footer zone. `fields` merges into the zone (logo, text,
354
- * image, slideNumber, slideNumberFormat, date, dateFormat, organization, socials, section); `null`,
366
+ * image, slideNumber, slideNumberFormat, date, dateFormat, organization, speaker, socials, section); `null`,
355
367
  * `false` for a flag, or an empty string removes a field. A zone left empty is removed, then an empty
356
368
  * header or footer, so a slide never carries `{}`. A slide's own header or footer replaces the deck's
357
369
  * whole one, so the first edit on a slide that has none of its own starts from a copy of the deck's
@@ -359,15 +371,15 @@ export function readLogoVariants(document, options = {}) {
359
371
  * inheriting it again. Setting a field on a suppressed (`false`) header replaces the suppression.
360
372
  * `logo: true` reports a warning when no logo resolves.
361
373
  */
362
- export function prepareHeaderFooterZone(document, which, zone, fields, options = {}) {
374
+ export function prepareHeaderFooterZone(presentation, which, zone, fields, options = {}) {
363
375
  if (!["header", "footer"].includes(which)) throw fail("invalid-design-value", "Choose header or footer.", { which });
364
376
  if (!HEADER_FOOTER_ZONES.includes(zone)) throw fail("invalid-design-value", `Zone is one of ${HEADER_FOOTER_ZONES.join(", ")}.`, { zone });
365
377
  if (!isObject(fields)) throw fail("invalid-design-value", "Pass the zone fields to change.", { fields });
366
378
  const unknown = Object.keys(fields).filter((key) => !ZONE_FIELDS.includes(key));
367
379
  if (unknown.length) throw fail("invalid-design-value", `Unknown header/footer field: ${unknown[0]}.`, { fields });
368
- const { scope, base, slideIndex } = scopeOf(document, "logo", options);
369
- const own = ownDesign(document, base)[which];
370
- const inherited = base.length ? document.design?.[which] : undefined;
380
+ const { scope, base, slideIndex } = scopeOf(presentation, "logo", options);
381
+ const own = ownDesign(presentation, base)[which];
382
+ const inherited = base.length ? presentation.design?.[which] : undefined;
371
383
  const current = own !== undefined ? own : inherited;
372
384
  const container = isObject(current) ? structuredClone(current) : {};
373
385
  const item = isObject(container[zone]) ? container[zone] : {};
@@ -386,27 +398,27 @@ export function prepareHeaderFooterZone(document, which, zone, fields, options =
386
398
  let next = Object.keys(container).length ? container : null;
387
399
  // Emptied: a deck value that would show through again is hidden explicitly; a suppressed header stays suppressed.
388
400
  if (next === null && (own === false || isObject(inherited))) next = false;
389
- const patches = designPatches(document, base, { [which]: next });
390
- return finish(document, patches, { option: which, zone, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: [] });
401
+ const patches = designPatches(presentation, base, { [which]: next });
402
+ return finish(presentation, patches, { option: which, zone, scope, ...(slideIndex !== undefined ? { slideIndex } : {}), shadowed: [] });
391
403
  }
392
404
  /** Edit one header or footer zone as a single undoable transaction. */
393
405
  export function setHeaderFooterZone(editor, which, zone, fields, options = {}) {
394
406
  checkEditor(editor);
395
407
  const { meta, ...rest } = options;
396
- return apply(editor, prepareHeaderFooterZone(editor.document, which, zone, fields, rest), meta);
408
+ return apply(editor, prepareHeaderFooterZone(editor.presentation, which, zone, fields, rest), meta);
397
409
  }
398
410
  /**
399
411
  * One header or footer zone's fields as they apply at a scope: the slide's own header (or footer) when it
400
412
  * has one, else the deck's (`{}` when absent or suppressed). `headerFooterState` says which.
401
413
  */
402
- export function readHeaderFooterZone(document, which, zone, options = {}) {
403
- const own = options.slideIndex === undefined ? undefined : document.slides?.[options.slideIndex]?.design?.[which];
404
- const effective = own !== undefined ? own : document.design?.[which];
414
+ export function readHeaderFooterZone(presentation, which, zone, options = {}) {
415
+ const own = options.slideIndex === undefined ? undefined : presentation.slides?.[options.slideIndex]?.design?.[which];
416
+ const effective = own !== undefined ? own : presentation.design?.[which];
405
417
  return isObject(effective) && isObject(effective[zone]) ? structuredClone(effective[zone]) : {};
406
418
  }
407
419
  /** `{ own, inherited, hidden }` for a header or footer at a scope: whether the scope sets it itself, shows the deck's, or hides it with `false`. */
408
- export function headerFooterState(document, which, options = {}) {
409
- const own = options.slideIndex === undefined ? document.design?.[which] : document.slides?.[options.slideIndex]?.design?.[which];
410
- const inherited = options.slideIndex !== undefined && own === undefined && document.design?.[which] !== undefined;
411
- return { own: own !== undefined, inherited, hidden: (own !== undefined ? own : options.slideIndex !== undefined ? document.design?.[which] : undefined) === false };
420
+ export function headerFooterState(presentation, which, options = {}) {
421
+ const own = options.slideIndex === undefined ? presentation.design?.[which] : presentation.slides?.[options.slideIndex]?.design?.[which];
422
+ const inherited = options.slideIndex !== undefined && own === undefined && presentation.design?.[which] !== undefined;
423
+ return { own: own !== undefined, inherited, hidden: (own !== undefined ? own : options.slideIndex !== undefined ? presentation.design?.[which] : undefined) === false };
412
424
  }
@@ -1,6 +1,7 @@
1
1
  // Helpers shared by the dimension switches, the design options, the block conversions and the
2
2
  // table options: the error constructor, structural equality and the design-key patch rules.
3
- import { OPFEditorError, applyJsonPatch, getValueAtPath, opfPathToJsonPointer, validateOpfDocument } from "./index.js";
3
+ import { OPFEditorError, applyJsonPatch, getValueAtPath, opfPathToJsonPointer } from "./index.js";
4
+ import { firstErrorMessage, errorFindings, checkFormat } from "./checks.js";
4
5
 
5
6
  export function fail(code, message, details) {
6
7
  return new OPFEditorError(code, message, details);
@@ -19,8 +20,8 @@ export function same(a, b) {
19
20
  }
20
21
 
21
22
  // Set (value) or remove (null) design keys at deck or slide scope.
22
- export function designPatches(document, base, entries) {
23
- const design = getValueAtPath(document, base.length ? [...base, "design"] : ["design"]);
23
+ export function designPatches(presentation, base, entries) {
24
+ const design = getValueAtPath(presentation, base.length ? [...base, "design"] : ["design"]);
24
25
  const at = (key) => opfPathToJsonPointer([...base, "design", key]);
25
26
  const set = Object.entries(entries).filter(([, value]) => value !== undefined && value !== null);
26
27
  if (!design || typeof design !== "object" || Array.isArray(design))
@@ -41,12 +42,12 @@ export function designPatches(document, base, entries) {
41
42
  * Validate a candidate patch the way every switch does: the result must be valid OPF unless the
42
43
  * input document was already invalid (then nothing new may be reported as the cause).
43
44
  */
44
- export function checkedDocument(document, patches, before) {
45
- const next = patches.length ? applyJsonPatch(document, patches) : document;
45
+ export function checkedDocument(presentation, patches, before) {
46
+ const next = patches.length ? applyJsonPatch(presentation, patches) : presentation;
46
47
  if (patches.length) {
47
- const validation = validateOpfDocument(next);
48
+ const validation = checkFormat(next);
48
49
  if (!validation.valid && before.valid)
49
- throw fail("invalid-opf-edit", validation.errors[0]?.message ?? "This change produces an invalid document.", { issues: validation.errors, patches });
50
+ throw fail("invalid-opf-edit", firstErrorMessage(validation, "This change produces an invalid document."), { issues: errorFindings(validation), patches });
50
51
  }
51
52
  return next;
52
53
  }
@@ -228,11 +228,11 @@ function typingSignature(previous, ops) {
228
228
  /**
229
229
  * Change only the affected values. Keeps authored indentation, escaping and all unrelated
230
230
  * metadata/content byte-for-byte, including across canvas Escape, Undo and Redo.
231
- * Throws when `source` is not valid JSON. The result always parses to `document`; if an
231
+ * Throws when `source` is not valid JSON. The result always parses to `presentation`; if an
232
232
  * in-place edit cannot achieve that (for example the source repeats an object key), the whole
233
233
  * document is written as indented JSON instead.
234
234
  */
235
- export function updateJsonSource(source, document, memory = sharedMemory) {
235
+ export function updateJsonSource(source, presentation, memory = sharedMemory) {
236
236
  const previous = JSON.parse(source);
237
237
  const previousKey = JSON.stringify(previous);
238
238
  memory.limited = source.length > MAX_EXACT_SOURCE_LENGTH;
@@ -243,9 +243,9 @@ export function updateJsonSource(source, document, memory = sharedMemory) {
243
243
  memory.last = null;
244
244
  }
245
245
  const ops = [];
246
- diff(previous, document, [], ops);
246
+ diff(previous, presentation, [], ops);
247
247
  if (!ops.length) return source;
248
- const key = JSON.stringify(document);
248
+ const key = JSON.stringify(presentation);
249
249
  const remembered = recall(memory, key);
250
250
  if (remembered !== undefined && equalOutside(source, remembered, maskPaths(ops))) {
251
251
  memory.last = null;
@@ -306,7 +306,7 @@ export function updateJsonSource(source, document, memory = sharedMemory) {
306
306
  valid = JSON.stringify(parsed) === key || canonical(parsed) === canonical(JSON.parse(key));
307
307
  } catch { /* Fall back below. */ }
308
308
  if (!valid) {
309
- result = JSON.stringify(document, null, indent).replaceAll("\n", eol);
309
+ result = JSON.stringify(presentation, null, indent).replaceAll("\n", eol);
310
310
  if (/\n$/.test(source)) result += eol;
311
311
  }
312
312
 
package/dist/export.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RenderSvgOptions } from "@openpresentation/opf-render";
2
- import type { FontGate } from "./canvas.js";
2
+ import type { EditorFonts } from "./canvas.js";
3
3
 
4
4
  export type ExportFormat = "pdf" | "png" | "svg";
5
5
  export declare const EXPORT_FORMATS: Readonly<Record<ExportFormat, { extension: string; type: string; label: string }>>;
@@ -44,12 +44,10 @@ export interface ExportOptions {
44
44
  pdfMode?: "vector" | "raster";
45
45
  /** PNG pixel density (and the raster PDF's), 1 to 4; default 2. */
46
46
  scale?: number;
47
- /** The options the host draws its preview with (`textMeasurement`, `catalogs`, ...). */
47
+ /** The options the host draws its preview with (`catalogs`, `date`, ...); the fonts go in `fonts`. */
48
48
  renderOptions?: RenderSvgOptions;
49
- /** A font gate: the faces the deck needs load before anything is drawn. */
50
- fonts?: Pick<FontGate, "pending" | "ensure">;
51
- /** The browser font registry; its faces are what gets embedded. */
52
- registry?: object;
49
+ /** The renderer's fonts handle (`loadFonts()`): it measures the text, loads the faces the deck needs before anything is drawn, and its registry's faces are what gets embedded. */
50
+ fonts?: EditorFonts;
53
51
  /** Faces to embed instead of the registry's. */
54
52
  embeddedFonts?: Array<Record<string, unknown>>;
55
53
  /** PDF document properties beyond the title and subject taken from the deck. */
@@ -71,7 +69,7 @@ export interface ExportResult {
71
69
  slides: number[];
72
70
  }
73
71
  /**
74
- * Draw the deck with the renderer the preview uses and convert it. Rejects with an error whose `code` is `export-aborted`, `export-no-slides`,
72
+ * 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`,
75
73
  * `export-format`, `export-unavailable` (PDF and PNG need the renderer's `export-browser` entry), `fonts-unavailable` or a renderer code.
76
74
  */
77
75
  export declare function exportDeck(deck: unknown, options: ExportOptions): Promise<ExportResult>;
package/dist/export.js CHANGED
@@ -1,13 +1,14 @@
1
1
  // RR-23: download a deck as PDF, PNG or SVG from the editor, next to the PowerPoint export. Everything runs in the page:
2
- // the slides are drawn by the same renderer the preview uses (`renderSvgDeck`, with the editor's own text measurement), so
3
- // a download is the preview, not a second layout. Fonts are the registry's: the faces the document needs load through the
4
- // font gate first, and only faces the registry holds (bundled or hash-pinned, permissively licensed) are embedded, as
2
+ // the slides are drawn by the same renderer the preview uses (`renderSvg`, with the fonts handle's text measurement), so
3
+ // a download is the preview, not a second layout. Fonts are the renderer's fonts handle's: the faces the document needs load through
4
+ // the handle first, and only faces its registry holds (bundled or hash-pinned, permissively licensed) are embedded, as
5
5
  // @font-face data in each SVG and as subsets in the PDF. No system font is read and nothing is fetched at export time.
6
6
  //
7
- // const result = await exportDeck(editor.document, { format: "pdf", renderOptions, fonts: gate, registry, signal, onProgress });
7
+ // const result = await exportDeck(editor.presentation, { format: "pdf", fonts, renderOptions, signal, onProgress });
8
8
  // // result.download is { name, type, bytes }: one PDF, one PNG or SVG, or a ZIP of the slides.
9
9
 
10
- import { renderSvgDeck } from "@openpresentation/opf-render/svg";
10
+ import { renderSvg } from "@openpresentation/opf-render/svg";
11
+ import { fontGate } from "./font-gate.js";
11
12
  import { createZip } from "./zip.js";
12
13
 
13
14
  export const EXPORT_FORMATS = Object.freeze({
@@ -69,6 +70,19 @@ function exportError(code, message, cause) {
69
70
  return error;
70
71
  }
71
72
 
73
+ // The faces a PDF may embed: the same permissive-license list as the SVG (`embeddableFonts`), as the bytes the converter reads. The fonts
74
+ // handle itself is never given to `svgToPdf`: it would embed every face the registry holds, whatever its license.
75
+ function pdfFontData(faces) {
76
+ return faces.map((face) => {
77
+ const text = String(face.dataUrl ?? "");
78
+ const base64 = text.slice(text.indexOf(",") + 1);
79
+ const binary = atob(base64);
80
+ const data = new Uint8Array(binary.length);
81
+ for (let index = 0; index < binary.length; index += 1) data[index] = binary.charCodeAt(index);
82
+ return { family: face.family, data };
83
+ });
84
+ }
85
+
72
86
  const fontLabel = (face) => `${face.family} ${face.weight}${face.italic ? " italic" : ""}`;
73
87
 
74
88
  function slideOf(path) {
@@ -110,12 +124,17 @@ async function loadConverters(options) {
110
124
  * - `slides`: "current" (with `slideIndex`), "all" (default; hidden slides only with `includeHidden`) or an array of slide numbers.
111
125
  * - `pdfMode`: "vector" (default: selectable text, vector shapes) or "raster" (each slide an image).
112
126
  * - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
113
- * - `renderOptions`: the render options the host draws with (`textMeasurement`, `catalogs`, ...), the same as its preview.
114
- * - `fonts`: a font gate (`createFontGate`); the faces the deck needs load before anything is drawn.
115
- * - `registry`: the browser font registry; its faces are what gets embedded.
127
+ * - `renderOptions`: the render options the host draws with (`catalogs`, `date`, ...), the same as its preview.
128
+ * - `fonts`: the renderer's fonts handle (`loadFonts()` from `@openpresentation/opf-render/fonts-browser`), the one the preview uses. Its
129
+ * `textMeasurement` lays the slides out, the faces the deck needs load through its `ensure` before anything is drawn, and its registry's
130
+ * faces are what gets embedded: the SVG carries the faces its slides draw, and the PDF embeds the registry's faces (script faces included).
131
+ * Both apply the same license rule (OFL-1.1, Apache-2.0, MIT or UFL-1.0): a face whose license text names none of them is left out of the SVG
132
+ * and the PDF and reported once as `export-font-license`. Text that needed it is drawn with another face the registry holds and
133
+ * reported by the converter (`pdf-font-substituted`, `pdf-glyph-missing`); when no face is left at all the PDF rejects with
134
+ * `export-fonts-unlicensed` (the converter's error is its `cause`).
116
135
  * - `signal`, `onProgress({ stage, done, total, message })`, `onDiagnostic(diagnostic)`.
117
136
  * Resolves `{ download, files, diagnostics, slides }`. Rejects with an error whose `code` is `export-aborted`,
118
- * `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code.
137
+ * `export-fonts-unlicensed`, `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code.
119
138
  */
120
139
  export async function exportDeck(deck, options = {}) {
121
140
  const format = EXPORT_FORMATS[options.format];
@@ -133,20 +152,22 @@ export async function exportDeck(deck, options = {}) {
133
152
  const renderOptions = options.renderOptions ?? {};
134
153
 
135
154
  check();
136
- if (options.fonts?.pending?.(deck, renderOptions)?.length) {
155
+ const gate = fontGate(options.fonts);
156
+ if (gate?.pending(deck, renderOptions).length) {
137
157
  progress("fonts", 0, 1, "Loading fonts…");
138
- try { await options.fonts.ensure(deck, { signal, renderOptions }); }
158
+ try { await gate.ensure(deck, { signal, renderOptions }); }
139
159
  catch (error) { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); throw error; }
140
160
  }
141
161
  check();
142
162
  const converters = format.extension === "svg" ? null : await loadConverters(options);
143
163
 
144
- const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.registry, (face) => note({ code: "export-font-license", severity: "warning", message: `${fontLabel(face)} is not embedded: its license is not one of OFL-1.1, Apache-2.0, MIT or UFL-1.0.`, family: face.family }));
164
+ const dropped = [];
165
+ const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.fonts?.registry, (face) => { dropped.push(face); note({ code: "export-font-license", severity: "warning", message: `${fontLabel(face)} is not embedded: its license is not one of OFL-1.1, Apache-2.0, MIT or UFL-1.0.`, family: face.family }); });
145
166
  progress("render", 0, indexes.length, "Drawing slides…");
146
167
  await yieldToHost();
147
- const rendered = renderSvgDeck(deck, {
168
+ const rendered = renderSvg(deck, {
148
169
  ...renderOptions,
149
- embeddedFonts,
170
+ fonts: { textMeasurement: options.fonts?.textMeasurement, embeddedFonts },
150
171
  // The PDF reports problems by element path; the SVG and PNG are the plain drawing.
151
172
  trace: format.extension === "pdf",
152
173
  onDiagnostic: (diagnostic) => {
@@ -186,12 +207,17 @@ export async function exportDeck(deck, options = {}) {
186
207
  scale,
187
208
  metadata,
188
209
  signal,
210
+ fontData: pdfFontData(embeddedFonts),
189
211
  ...(options.fallbackFamily ? { defaultFontFamily: options.fallbackFamily } : {}),
190
212
  onDiagnostic: (diagnostic) => { const described = describeDiagnostic(diagnostic, "pdf"); if (keep(described)) note(described); },
191
213
  onProgress: ({ page, pages }) => progress("convert", page, pages, `Page ${page} of ${pages}`),
192
214
  });
193
215
  } catch (error) {
194
216
  if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled.");
217
+ // The license rule can leave the converter with no face for some text (every face of a family was dropped): say why, not only what.
218
+ if (dropped.length && /font face/i.test(String(error?.message))) {
219
+ throw exportError("export-fonts-unlicensed", `The PDF could not be written: ${dropped.map(fontLabel).join(", ")} ${dropped.length === 1 ? "is" : "are"} not embedded because the license is not one of OFL-1.1, Apache-2.0, MIT or UFL-1.0, and no other face is available. ${error.message}`, error);
220
+ }
195
221
  throw error;
196
222
  }
197
223
  files = [{ name: exportFileName(deck, "pdf"), type: format.type, bytes }];