@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/dist/export.js CHANGED
@@ -1,13 +1,16 @@
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 (`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
- // @font-face data in each SVG and as subsets in the PDF. No system font is read and nothing is fetched at export time.
1
+ // RR-23, RR-73: `convert(deck, { format })` turns the open deck into PDF, PNG or SVG files in the page, next to the PowerPoint
2
+ // export. It has the name and the result of core's in-memory `convert` (`{ files, findings }`, each PNG or SVG file carrying its
3
+ // one-based `slide`). The slides are drawn by the same renderer the preview uses (`toSvg`, with the fonts handle's text measurement),
4
+ // so a file is the preview, not a second layout. Fonts are the renderer's fonts handle's: the faces the document needs load through
5
+ // the handle first, and only faces its registry holds (bundled or hash-pinned, permissively licensed) are embedded, as @font-face
6
+ // data in each SVG and as subsets in the PDF. No system font is read and nothing is fetched at export time.
6
7
  //
7
- // const result = await exportDeck(editor.presentation, { format: "pdf", fonts, renderOptions, signal, onProgress });
8
- // // result.download is { name, type, bytes }: one PDF, one PNG or SVG, or a ZIP of the slides.
8
+ // const { files, findings } = await convert(editor.presentation, { format: "pdf", fonts, renderOptions, signal, onProgress });
9
+ // // files: one PDF; one PNG or SVG per slide; or, with `zip: true`, one archive of them.
9
10
 
10
- import { renderSvg } from "@openpresentation/opf-render/svg";
11
+ import { parseSlideSelection } from "@openpresentation/opf";
12
+ import { pointerFromPath } from "@openpresentation/opf/patch";
13
+ import { toSvg } from "@openpresentation/opf-render/svg";
11
14
  import { mergeCatalogs, prepareSave } from "./catalogs.js";
12
15
  import { fontGate } from "./font-gate.js";
13
16
  import { createZip } from "./zip.js";
@@ -17,14 +20,16 @@ export const EXPORT_FORMATS = Object.freeze({
17
20
  png: Object.freeze({ extension: "png", type: "image/png", label: "PNG" }),
18
21
  svg: Object.freeze({ extension: "svg", type: "image/svg+xml", label: "SVG" }),
19
22
  });
23
+ const ZIP_TYPE = "application/zip";
20
24
 
21
25
  /** Largest PNG scale offered; a 1280 x 720 slide at 4x is 5120 x 2880 pixels. */
22
26
  export const MAX_PNG_SCALE = 4;
23
27
 
24
28
  // Licenses a face may be embedded under (the repository's font policy: OFL-1.1, Apache-2.0, MIT, UFL-1.0). The registry's faces carry the
25
- // face's own license text; a text that names none of these keeps the face out of the download.
29
+ // face's own license text; a text that names none of these keeps the face out of the file.
26
30
  const PERMISSIVE_LICENSE = /SIL OPEN FONT LICENSE|OPEN FONT LICENSE|\bOFL\b|APACHE LICENSE|\bMIT LICENSE\b|PERMISSION IS HEREBY GRANTED|UBUNTU FONT LICEN[SC]E|\bUFL\b/i;
27
- const FONT_WARNINGS = new Set([
31
+ // PDF converter codes that are warnings; every other `pdf-` code (embedded faces, page notes) is information. Renderer codes are warnings.
32
+ const PDF_WARNINGS = new Set([
28
33
  "pdf-font-substituted", "pdf-font-fallback", "pdf-glyph-missing", "pdf-raster-fallback", "pdf-image-skipped", "pdf-font-unreadable",
29
34
  "pdf-font-embedding-restricted", "pdf-font-unsupported-format", "pdf-expansion-limit", "pdf-font-unavailable",
30
35
  ]);
@@ -32,7 +37,7 @@ const FONT_WARNINGS = new Set([
32
37
  const yieldToHost = () => new Promise((resolve) => setTimeout(resolve, 0));
33
38
 
34
39
  /**
35
- * The download file name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg is dropped), else the slugified `name`, else
40
+ * The file name: the deck's `filename` (a trailing .pptx, .pdf, .png or .svg is dropped), else the slugified `name`, else
36
41
  * "presentation", plus `suffix` and `.extension`. Characters a file system rejects are replaced, so the name is safe to save.
37
42
  */
38
43
  export function exportFileName(deck, extension, suffix = "") {
@@ -43,12 +48,17 @@ export function exportFileName(deck, extension, suffix = "") {
43
48
  return `${base}${suffix}.${extension}`;
44
49
  }
45
50
 
46
- /** The slide numbers (0-based) an export covers. Hidden slides are skipped in an "all" export unless `includeHidden`; one chosen slide is always exported. */
47
- export function slidesToExport(deck, { slides = "all", slideIndex = 0, includeHidden = false } = {}) {
48
- const count = deck?.slides?.length ?? 0;
49
- if (Array.isArray(slides)) return slides.filter((index) => Number.isInteger(index) && index >= 0 && index < count);
50
- if (slides === "current") return slideIndex >= 0 && slideIndex < count ? [slideIndex] : [];
51
- return deck.slides.map((slide, index) => (includeHidden || slide?.hidden !== true ? index : -1)).filter((index) => index >= 0);
51
+ /**
52
+ * The slide numbers (one-based, ascending) a conversion covers. `slides` is core's slide selection (`3`, `"1-3"`, `"1,3-5"`, `[1, 3]`;
53
+ * `parseSlideSelection`): exactly those slides, hidden or not. Without it every slide that is not hidden, or every slide with
54
+ * `includeHidden`. A deck without slides gives `[]`; a malformed selection or a slide past the end throws core's `OPFApiError`
55
+ * (`invalid-option`).
56
+ */
57
+ export function slidesToConvert(deck, { slides, includeHidden = false } = {}) {
58
+ const list = Array.isArray(deck?.slides) ? deck.slides : [];
59
+ if (!list.length) return [];
60
+ if (slides !== undefined) return parseSlideSelection(slides, list.length);
61
+ return list.map((slide, index) => (includeHidden || slide?.hidden !== true ? index + 1 : 0)).filter(Boolean);
52
62
  }
53
63
 
54
64
  /**
@@ -72,7 +82,7 @@ function exportError(code, message, cause) {
72
82
  }
73
83
 
74
84
  // The faces a PDF may embed: the same permissive-license list as the SVG (`embeddableFonts`), as the bytes the converter reads. The fonts
75
- // handle itself is never given to `svgToPdf`: it would embed every face the registry holds, whatever its license.
85
+ // handle itself is never given to `toPdf`: it would embed every face the registry holds, whatever its license.
76
86
  function pdfFontData(faces) {
77
87
  return faces.map((face) => {
78
88
  const text = String(face.dataUrl ?? "");
@@ -85,48 +95,84 @@ function pdfFontData(faces) {
85
95
  }
86
96
 
87
97
  const fontLabel = (face) => `${face.family} ${face.weight}${face.italic ? " italic" : ""}`;
98
+ const scalar = (value) => value === null || ["string", "number", "boolean"].includes(typeof value);
88
99
 
89
- function slideOf(path) {
90
- const match = /^slides\.(\d+)(?:\.|$)/.exec(String(path ?? ""));
91
- return match ? Number(match[1]) : undefined;
92
- }
93
-
94
- /** A renderer or converter diagnostic as `{ code, severity, message, slide?, path? }` (`slide` is 0-based). */
95
- export function describeDiagnostic(diagnostic, source = "render") {
96
- const code = diagnostic.code ?? `${source}-note`;
97
- let message = diagnostic.message ?? code;
100
+ // A renderer, PDF converter or editor note as core's Finding (finding.schema.json): `ruleId` is `<source>/<code>` and `category` the
101
+ // source, as core's conversion reports them (`render/`, `pdf/`, `fonts/`); `path` is a JSON Pointer (core's `pointerFromPath`); `slide`
102
+ // (zero-based, the Finding schema's convention) and `slideId` say which slide it belongs to; the note's own facts (font family, weight,
103
+ // glyph count, ...) are `measured`.
104
+ function findingOf(source, diagnostic, deck) {
105
+ const { code: rawCode, message: rawMessage, path: where, severity: _severity, ...details } = diagnostic ?? {};
106
+ const code = String(rawCode ?? "note").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "") || "note";
107
+ let message = rawMessage ?? code;
98
108
  let severity = "warning";
99
109
  if (code === "pdf-font-embedded") {
100
110
  severity = "info";
101
- message = `Embedded ${diagnostic.family} ${diagnostic.weight}${diagnostic.italic ? " italic" : ""} (${diagnostic.embedding}, ${diagnostic.glyphs} glyphs).`;
111
+ message = `Embedded ${details.family} ${details.weight}${details.italic ? " italic" : ""} (${details.embedding}, ${details.glyphs} glyphs).`;
102
112
  } else if (code === "pdf-font-substituted" || code === "pdf-font-fallback") {
103
- message = diagnostic.message ?? "A font was substituted in the PDF.";
104
- } else if (source === "pdf" && !FONT_WARNINGS.has(code) && !/^pdf-unsupported-/.test(code)) {
113
+ message = rawMessage ?? "A font was substituted in the PDF.";
114
+ } else if (source === "pdf" && !PDF_WARNINGS.has(code) && !/^pdf-unsupported-/.test(code)) {
105
115
  severity = "info";
106
116
  }
107
- const slide = slideOf(diagnostic.path);
108
- return { code, severity, message, ...(slide === undefined ? {} : { slide }), ...(diagnostic.path ? { path: diagnostic.path } : {}), ...(diagnostic.family ? { family: diagnostic.family } : {}) };
117
+ const path = typeof where === "string" && where ? pointerFromPath(where) : "";
118
+ const slideMatch = /^\/slides\/(\d+)(?:\/|$)/.exec(path);
119
+ const slide = slideMatch ? Number(slideMatch[1]) : undefined;
120
+ const slideId = slide === undefined ? undefined : deck?.slides?.[slide]?.id;
121
+ const measured = {};
122
+ // A long text (an image's data URI) is cut: the finding names the fact, the deck holds the value.
123
+ const short = (value) => (typeof value === "string" && value.length > 200 ? `${value.slice(0, 199)}…` : value);
124
+ for (const [key, value] of Object.entries(details)) {
125
+ if (scalar(value)) measured[key] = short(value);
126
+ else if (Array.isArray(value) && value.length && value.every(scalar)) measured[key] = short(value.join(", "));
127
+ }
128
+ return {
129
+ ruleId: `${source}/${code}`,
130
+ severity,
131
+ category: source,
132
+ path,
133
+ scope: "document",
134
+ message: String(message),
135
+ ...(slide === undefined ? {} : { slide }),
136
+ ...(typeof slideId === "string" ? { slideId } : {}),
137
+ ...(Object.keys(measured).length ? { measured } : {}),
138
+ };
109
139
  }
110
140
 
141
+ // The picture size in pixels: the SVG's width and height attributes, a PNG's IHDR.
142
+ const svgSize = (svg) => {
143
+ const tag = /<svg\b[^>]*>/.exec(svg)?.[0] ?? "";
144
+ const width = Number(/\bwidth="([\d.]+)"/.exec(tag)?.[1]);
145
+ const height = Number(/\bheight="([\d.]+)"/.exec(tag)?.[1]);
146
+ return Number.isFinite(width) && Number.isFinite(height) ? { width, height } : {};
147
+ };
148
+ const pngSize = (bytes) => {
149
+ if (!(bytes instanceof Uint8Array) || bytes.length < 24) return {};
150
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
151
+ return { width: view.getUint32(16), height: view.getUint32(20) };
152
+ };
153
+
111
154
  async function loadConverters(options) {
112
- if (options.convert) return options.convert;
155
+ if (options.converters) return options.converters;
113
156
  try {
114
157
  return await import("@openpresentation/opf-render/export-browser");
115
158
  } catch (cause) {
116
- throw exportError("export-unavailable", "PDF and PNG export need @openpresentation/opf-render with its export-browser entry. SVG export works without it.", cause);
159
+ throw exportError("export-unavailable", "PDF and PNG output need @openpresentation/opf-render with its export-browser entry. SVG works without it.", cause);
117
160
  }
118
161
  }
119
162
 
120
163
  /**
121
- * Convert the deck's slides to a download.
164
+ * Convert the deck's slides to PDF, PNG or SVG files, in memory. The name and the result are core's in-memory `convert`.
122
165
  *
123
166
  * `options`:
124
167
  * - `format`: "pdf", "png" or "svg" (required).
125
- * - `slides`: "current" (with `slideIndex`), "all" (default; hidden slides only with `includeHidden`) or an array of slide numbers.
126
- * - `pdfMode`: "vector" (default: selectable text, vector shapes) or "raster" (each slide an image).
127
- * - `pdfLib`: for `pdfMode: "raster"` only, the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry imports no PDF library (RR-63, opf-render 0.16),
128
- * so a host that offers the raster PDF imports pdf-lib itself; without it a raster PDF rejects with the renderer's `converter-missing` (its `details.install` names the package).
129
- * Vector PDF, PNG and SVG need no option.
168
+ * - `slides`: core's slide selection, counted from 1 (`3`, `"1-3"`, `"1,3-5"`, `[1, 3]`): exactly those slides, hidden or not. Omitted:
169
+ * every slide that is not hidden, or every slide with `includeHidden`.
170
+ * - `raster`: PDF only; each page an image of its slide instead of selectable text and vector shapes.
171
+ * - `pdfLib`: for `raster: true` only, the pdf-lib module (`import * as pdfLib from "pdf-lib"`). The renderer's `export-browser` entry
172
+ * imports no PDF library (RR-63, opf-render 0.16), so a host that offers the raster PDF imports pdf-lib itself; without it a raster
173
+ * PDF rejects with the renderer's `converter-missing` (its `details.install` names the package). Vector PDF, PNG and SVG need no option.
174
+ * - `zip`: PNG and SVG only; one ZIP archive of the slides instead of one file per slide (even for one slide).
175
+ * - `name`: the base name of the files (`name.pdf`, `name-001.png`, `name.zip`). Omitted: the deck's `filename`, else its slugified `name`.
130
176
  * - `scale`: PNG pixel density, 1 to 4 (default 2); also the raster PDF's density.
131
177
  * - `renderOptions`: the render options the host draws with (`catalogs`, `date`, ...), the same as its preview.
132
178
  * - `catalogs`: the host's registered catalogs (`Catalog[]`, merged with `renderOptions.catalogs`). The deck is embedded first (core's
@@ -135,74 +181,100 @@ async function loadConverters(options) {
135
181
  * `textMeasurement` lays the slides out, the faces the deck needs load through its `ensure` before anything is drawn, and its registry's
136
182
  * faces are what gets embedded: the SVG carries the faces its slides draw, and the PDF embeds the registry's faces (script faces included).
137
183
  * 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
138
- * and the PDF and reported once as `export-font-license`. Text that needed it is drawn with another face the registry holds and
139
- * reported by the converter (`pdf-font-substituted`, `pdf-glyph-missing`); when no face is left at all the PDF rejects with
184
+ * and the PDF and reported once as `fonts/export-font-license`. Text that needed it is drawn with another face the registry holds and
185
+ * reported by the converter (`pdf/pdf-font-substituted`, `pdf/pdf-glyph-missing`); when no face is left at all the PDF rejects with
140
186
  * `export-fonts-unlicensed` (the converter's error is its `cause`).
141
- * - `signal`, `onProgress({ stage, done, total, message })`, `onDiagnostic(diagnostic)`.
142
- * Resolves `{ download, files, diagnostics, slides }`. Rejects with an error whose `code` is `export-aborted`,
143
- * `export-fonts-unlicensed`, `export-no-slides`, `export-unavailable`, `fonts-unavailable` or a renderer code (`converter-missing` for a raster PDF without `pdfLib`).
187
+ * - `converters`: `{ toPdf, toPng }` in place of the renderer's `export-browser` entry (tests, or a host with its own converter).
188
+ * - `signal`, `onProgress({ stage, done, total, message })`, `onFinding(finding)`.
189
+ * Resolves `{ files, findings }`. Rejects with an error whose `code` is `export-aborted`, `export-fonts-unlicensed`, `export-format`,
190
+ * `export-no-slides`, `export-unavailable`, `fonts-unavailable`, `invalid-option` (core's, for a malformed slide selection or a slide past
191
+ * the end; the editor's, for `zip` or `raster` with a format they do not apply to) or a renderer code (`converter-missing` for a raster
192
+ * PDF without `pdfLib`).
144
193
  */
145
- export async function exportDeck(input, options = {}) {
146
- const format = EXPORT_FORMATS[options.format];
194
+ export async function convert(input, options = {}) {
195
+ const format = EXPORT_FORMATS[options?.format];
147
196
  if (!format) throw exportError("export-format", `Choose a format: ${Object.keys(EXPORT_FORMATS).join(", ")}.`);
197
+ if (options.zip && format.extension === "pdf") throw exportError("invalid-option", "zip applies to format png and svg.");
198
+ if (options.raster && format.extension !== "pdf") throw exportError("invalid-option", "raster applies to format pdf.");
148
199
  const { signal } = options;
149
- const diagnostics = [];
150
- const note = (diagnostic) => { diagnostics.push(diagnostic); options.onDiagnostic?.(diagnostic); };
200
+ const findings = [];
201
+ const seen = new Set();
202
+ // One finding per rule, place and message: a deck of 200 slides reports one substituted font once, not 200 times.
203
+ const note = (finding) => {
204
+ const key = JSON.stringify([finding.ruleId, finding.path, finding.message]);
205
+ if (seen.has(key)) return;
206
+ seen.add(key);
207
+ findings.push(finding);
208
+ options.onFinding?.(finding);
209
+ };
151
210
  const progress = (stage, done, total, message) => options.onProgress?.({ stage, done, total, message });
152
- const check = () => { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); };
153
- const indexes = slidesToExport(input, options);
154
- if (!indexes.length) throw exportError("export-no-slides", "There are no slides to export.");
155
- const selected = new Set(indexes);
156
- const keep = (diagnostic) => diagnostic.slide === undefined || selected.has(diagnostic.slide);
211
+ const aborted = () => exportError("export-aborted", "The export was cancelled.");
212
+ const check = () => { if (signal?.aborted) throw aborted(); };
213
+ const numbers = slidesToConvert(input, options);
214
+ if (!numbers.length) throw exportError("export-no-slides", "There are no slides to export.");
215
+ // A note about a slide that is not converted is left out (finding slides are zero-based).
216
+ const selected = new Set(numbers.map((number) => number - 1));
157
217
  const scale = Math.min(MAX_PNG_SCALE, Math.max(1, Number(options.scale) || 2));
158
218
  const catalogs = mergeCatalogs(options.catalogs, options.renderOptions?.catalogs);
159
219
  const renderOptions = { ...(options.renderOptions ?? {}), catalogs };
160
220
  const deck = prepareSave(input, { catalogs }).document;
221
+ const record = (source, diagnostic) => {
222
+ const finding = findingOf(source, diagnostic, deck);
223
+ if (finding.slide === undefined || selected.has(finding.slide)) note(finding);
224
+ };
161
225
 
162
226
  check();
163
227
  const gate = fontGate(options.fonts);
164
228
  if (gate?.pending(deck, renderOptions).length) {
165
229
  progress("fonts", 0, 1, "Loading fonts…");
166
230
  try { await gate.ensure(deck, { signal, renderOptions }); }
167
- catch (error) { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); throw error; }
231
+ catch (error) { if (signal?.aborted) throw aborted(); throw error; }
168
232
  }
169
233
  check();
170
234
  const converters = format.extension === "svg" ? null : await loadConverters(options);
171
235
 
172
236
  const dropped = [];
173
- 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 }); });
174
- progress("render", 0, indexes.length, "Drawing slides…");
237
+ const embeddedFonts = options.embeddedFonts ?? embeddableFonts(options.fonts?.registry, (face) => {
238
+ dropped.push(face);
239
+ const detail = { family: face.family, weight: face.weight, ...(face.italic ? { italic: true } : {}) };
240
+ note(findingOf("fonts", { code: "export-font-license", message: `${fontLabel(face)} is not embedded: its license is not one of OFL-1.1, Apache-2.0, MIT or UFL-1.0.`, ...detail }, deck));
241
+ });
242
+ progress("render", 0, numbers.length, "Drawing slides…");
175
243
  await yieldToHost();
176
- const rendered = renderSvg(deck, {
244
+ const svgs = toSvg(deck, numbers, {
177
245
  ...renderOptions,
178
246
  fonts: { textMeasurement: options.fonts?.textMeasurement, embeddedFonts },
179
247
  // The PDF reports problems by element path; the SVG and PNG are the plain drawing.
180
248
  trace: format.extension === "pdf",
181
249
  onDiagnostic: (diagnostic) => {
182
- const described = describeDiagnostic(diagnostic, "render");
183
- if (keep(described)) note(described);
250
+ record("render", diagnostic);
184
251
  renderOptions.onDiagnostic?.(diagnostic);
185
252
  },
186
253
  });
187
254
  check();
188
- const svgs = indexes.map((index) => rendered[index]);
189
- progress("render", indexes.length, indexes.length, "Slides drawn");
255
+ progress("render", numbers.length, numbers.length, "Slides drawn");
190
256
 
191
- const digits = Math.max(2, String(deck.slides.length).length);
192
- const numbered = (index, extension) => exportFileName(deck, extension, `-${String(index + 1).padStart(digits, "0")}`);
257
+ const named = (extension, suffix = "") => exportFileName(options.name === undefined ? deck : { filename: String(options.name) }, extension, suffix);
258
+ // Slide files are numbered as core numbers them: three digits at least (`-001`), more for a longer deck.
259
+ const digits = Math.max(3, String(deck.slides.length).length);
260
+ const numbered = (number, extension) => named(extension, `-${String(number).padStart(digits, "0")}`);
261
+ const facts = (number) => {
262
+ const id = deck.slides[number - 1]?.id;
263
+ return { slide: number, ...(typeof id === "string" ? { id } : {}) };
264
+ };
193
265
  let files;
194
266
  if (format.extension === "svg") {
195
267
  const header = '<?xml version="1.0" encoding="UTF-8"?>\n';
196
- files = svgs.map((svg, position) => ({ name: numbered(indexes[position], "svg"), type: format.type, bytes: new TextEncoder().encode(header + svg) }));
268
+ files = svgs.map((svg, position) => ({ name: numbered(numbers[position], "svg"), type: format.type, bytes: new TextEncoder().encode(header + svg), ...facts(numbers[position]), ...svgSize(svg) }));
197
269
  } else if (format.extension === "png") {
198
270
  files = [];
199
271
  for (const [position, svg] of svgs.entries()) {
200
272
  check();
201
- progress("convert", position, svgs.length, `Slide ${indexes[position] + 1} of ${deck.slides.length}`);
273
+ progress("convert", position, svgs.length, `Slide ${numbers[position]} of ${deck.slides.length}`);
202
274
  let bytes;
203
- try { bytes = await converters.svgToPng(svg, { scale, signal }); }
204
- catch (error) { if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled."); throw error; }
205
- files.push({ name: numbered(indexes[position], "png"), type: format.type, bytes });
275
+ try { bytes = await converters.toPng(svg, { scale, signal }); }
276
+ catch (error) { if (signal?.aborted) throw aborted(); throw error; }
277
+ files.push({ name: numbered(numbers[position], "png"), type: format.type, bytes, ...facts(numbers[position]), ...pngSize(bytes) });
206
278
  await yieldToHost();
207
279
  }
208
280
  progress("convert", svgs.length, svgs.length, "Images ready");
@@ -210,43 +282,35 @@ export async function exportDeck(input, options = {}) {
210
282
  const metadata = { ...(deck.name ? { title: String(deck.name) } : {}), ...(deck.description ? { subject: String(deck.description) } : {}), ...(options.metadata ?? {}) };
211
283
  let bytes;
212
284
  try {
213
- bytes = await converters.svgToPdf(svgs, {
214
- mode: options.pdfMode === "raster" ? "raster" : "vector",
285
+ bytes = await converters.toPdf(svgs, {
286
+ ...(options.raster ? { raster: true } : {}),
215
287
  scale,
216
288
  metadata,
217
289
  signal,
218
290
  fontData: pdfFontData(embeddedFonts),
219
- // RR-63: the renderer's browser entry imports no pdf-lib; raster mode takes the module the host imported.
220
- ...(options.pdfMode === "raster" && options.pdfLib ? { pdfLib: options.pdfLib } : {}),
291
+ // RR-63: the renderer's browser entry imports no pdf-lib; a raster PDF takes the module the host imported.
292
+ ...(options.raster && options.pdfLib ? { pdfLib: options.pdfLib } : {}),
221
293
  ...(options.fallbackFamily ? { defaultFontFamily: options.fallbackFamily } : {}),
222
- onDiagnostic: (diagnostic) => { const described = describeDiagnostic(diagnostic, "pdf"); if (keep(described)) note(described); },
294
+ onDiagnostic: (diagnostic) => record("pdf", diagnostic),
223
295
  onProgress: ({ page, pages }) => progress("convert", page, pages, `Page ${page} of ${pages}`),
224
296
  });
225
297
  } catch (error) {
226
- if (signal?.aborted) throw exportError("export-aborted", "The export was cancelled.");
298
+ if (signal?.aborted) throw aborted();
227
299
  // 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.
228
300
  if (dropped.length && /font face/i.test(String(error?.message))) {
229
301
  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);
230
302
  }
231
303
  throw error;
232
304
  }
233
- files = [{ name: exportFileName(deck, "pdf"), type: format.type, bytes }];
305
+ files = [{ name: named("pdf"), type: format.type, bytes, pages: numbers.length, slides: numbers }];
234
306
  }
235
307
 
236
308
  check();
237
- let download = files[0];
238
- if (files.length > 1) {
309
+ if (options.zip) {
239
310
  progress("archive", 0, files.length, "Packing files…");
240
311
  const bytes = await createZip(files.map(({ name, bytes: data }) => ({ name, bytes: data })), { signal });
241
- download = { name: exportFileName(deck, "zip", `-${format.extension}`), type: "application/zip", bytes };
312
+ files = [{ name: named("zip"), type: ZIP_TYPE, bytes, entries: files.map((file) => file.name) }];
242
313
  }
243
314
  progress("done", 1, 1, "Ready");
244
- // One note per kind: a deck of 200 slides reports one substituted font once, not 200 times.
245
- const unique = [];
246
- const seen = new Set();
247
- for (const diagnostic of diagnostics) {
248
- const key = `${diagnostic.code}|${diagnostic.message}|${diagnostic.path ?? ""}`;
249
- if (!seen.has(key)) { seen.add(key); unique.push(diagnostic); }
250
- }
251
- return { download, files, diagnostics: unique, slides: indexes };
315
+ return { files, findings };
252
316
  }
package/dist/font-gate.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // fails with "Intos Display cannot display U+65E5"), so nothing may render or measure a new document before `ensure`
5
5
  // resolves. The renderer's fonts handle (`loadFonts()` from `@openpresentation/opf-render/fonts-browser`) is the source of
6
6
  // truth: its `pending(presentation, renderOptions)` and `ensure(presentation, options)` know what is missing and load it. The
7
- // canvas and `exportDeck` take that handle as `fonts` and gate every document-replacing path through this module.
7
+ // canvas and `convert` (`/export`) take that handle as `fonts` and gate every document-replacing path through this module.
8
8
 
9
9
  /** Error code of a document whose required faces are still loading. */
10
10
  export const FONTS_PENDING = "fonts-pending";
package/dist/review.d.ts CHANGED
@@ -61,8 +61,13 @@ export declare function groupFindings(findings: readonly ReviewFinding[]): { cat
61
61
  export declare function findingTarget(presentation: unknown, finding: Pick<ReviewFinding, "path">): ReviewTarget;
62
62
  /** Apply a `patch` fix as one undoable, validated session change; refuses a stale or unsafe patch. */
63
63
  export declare function applyReviewFix(editor: EditorSession, finding: Pick<ReviewFinding, "ruleId"> | undefined, fix: ReviewFix, meta?: Record<string, unknown>): EditorChange;
64
- /** The patch that sets (or, for `""`, marks decorative) the alt text of the picture at `pointer`; null when nothing changes. */
64
+ /**
65
+ * The patch that sets (or, for `""`, marks decorative) the alt text of the picture at `pointer`; null when nothing changes. A
66
+ * logo reference (`var:organization.logo.icon`) stays a whole-field string: its alt is written on the organization logo
67
+ * asset(s) it draws, and a reference that names no logo is refused (`unresolved-logo-alt`).
68
+ */
65
69
  export declare function altTextPatch(presentation: unknown, pointer: string, text: string): JsonPatchOperation[] | null;
66
70
  export declare function setReviewAltText(editor: EditorSession, pointer: string, text: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
67
71
  export declare function markDecorative(editor: EditorSession, pointer: string, meta?: Record<string, unknown>): { changed: boolean } & Partial<EditorChange>;
72
+ /** The alt text a picture has now; for a logo reference, the alt of the organization asset it draws. */
68
73
  export declare function currentAltText(presentation: unknown, pointer: string): string;
package/dist/review.js CHANGED
@@ -7,7 +7,9 @@
7
7
  // module needs no DOM.
8
8
  import { validationCategories } from "@openpresentation/opf";
9
9
  import { OPFEditorError, getValueAtPath, hasValueAtPath, jsonPointerToOpfPath, opfPathToJsonPointer, splitOpfPath } from "./index.js";
10
+ import { resolveLogo } from "@openpresentation/opf/composition";
10
11
  import { assetIdOf, prepareAssetAlt } from "./assets.js";
12
+ import { parseLogoReference } from "./design-options.js";
11
13
 
12
14
  function fail(code, message, details) {
13
15
  return new OPFEditorError(code, message, details);
@@ -158,10 +160,36 @@ export function applyReviewFix(editor, finding, fix, meta = {}) {
158
160
 
159
161
  const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
160
162
 
161
- /** The patch operations that give the asset value at `pointer` alt text (`""` marks it decorative), or `null` when it already has exactly this alt. */
163
+ // RR-71: a logo reference (`var:organization.logo.icon`) is a whole-field string, so its alt text cannot be written beside
164
+ // it ({ src: "var:…", alt } is an ordinary image whose source is no longer the logo). The logo's alt lives on the
165
+ // organization's asset (Organization.logo, each shape and onLight/onDark value a path or Asset): these are the OPF paths of
166
+ // the assets the reference draws, on a light and on a dark background (one path when both resolve to the same asset).
167
+ function logoAltPaths(presentation, value) {
168
+ if (typeof value !== "string" || !parseLogoReference(value)) return null;
169
+ const paths = new Set();
170
+ for (const onDark of [false, true]) {
171
+ const logo = resolveLogo(presentation, undefined, { reference: value, onDark });
172
+ if (logo) paths.add(logo.path);
173
+ }
174
+ if (!paths.size) {
175
+ throw fail("unresolved-logo-alt", `${value} names no logo yet. Add the organization's logo first; its alt text is written on the logo in the organization.`, { reference: value });
176
+ }
177
+ return [...paths];
178
+ }
179
+
180
+ /**
181
+ * The patch operations that give the asset value at `pointer` alt text (`""` marks it decorative), or `null` when it already
182
+ * has exactly this alt. For a logo reference the alt is written on the organization logo asset(s) it draws.
183
+ */
162
184
  export function altTextPatch(presentation, pointer, text) {
163
185
  const value = getValueAtPath(presentation, pointer);
164
186
  if (value === undefined) throw fail("stale-finding", "The picture this finding is about no longer exists.", { pointer });
187
+ const logoPaths = logoAltPaths(presentation, value);
188
+ if (logoPaths) {
189
+ // Both backgrounds may name one registry asset (asset:<id>): write it once.
190
+ const operations = [...new Map(logoPaths.flatMap((path) => altTextPatch(presentation, opfPathToJsonPointer(splitOpfPath(path)), text) ?? []).map((operation) => [JSON.stringify(operation), operation])).values()];
191
+ return operations.length ? operations : null;
192
+ }
165
193
  const alt = String(text ?? "");
166
194
  const id = assetIdOf(value);
167
195
  const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
@@ -202,6 +230,14 @@ export function markDecorative(editor, pointer, meta = {}) {
202
230
  /** The alt text a picture has now (own or through the assets registry), for pre-filling the field. */
203
231
  export function currentAltText(presentation, pointer) {
204
232
  const value = getValueAtPath(presentation, pointer);
233
+ // A logo reference shows the alt of the organization asset it draws (on a light background first).
234
+ if (typeof value === "string" && parseLogoReference(value)) {
235
+ for (const onDark of [false, true]) {
236
+ const logo = resolveLogo(presentation, undefined, { reference: value, onDark });
237
+ if (logo) return currentAltText(presentation, opfPathToJsonPointer(splitOpfPath(logo.path)));
238
+ }
239
+ return "";
240
+ }
205
241
  if (isObject(value) && typeof value.alt === "string") return value.alt;
206
242
  const id = assetIdOf(value);
207
243
  const entry = id === undefined ? undefined : getValueAtPath(presentation, ["assets", id]);
@@ -5,7 +5,7 @@ export interface TemplatePanelOptions {
5
5
  editor: EditorSession;
6
6
  /**
7
7
  * Draw the live preview: return the SVG of one slide of `presentation` with `variables` (the values typed so far)
8
- * as an SVG string, or a promise of one. Typically `renderSlideSvg(presentation, slideIndex, { variables, ... })` from
8
+ * as an SVG string, or a promise of one. Typically `toSvg(presentation, slideIndex + 1, { variables, ... })` from
9
9
  * `@openpresentation/opf-render` with the host's fonts. Omit for no preview.
10
10
  */
11
11
  renderPreview?: (input: { presentation: unknown; variables: Record<string, unknown>; slideIndex: number }) => string | Promise<string>;
@@ -67,7 +67,7 @@ export function createTemplatePanel(container, options) {
67
67
  const builtinList = h(doc, "ul", { class: "opf-template-builtin-list" });
68
68
  const builtinSection = h(doc, "details", { class: "opf-template-builtins", "data-opf-component": "template-builtins" },
69
69
  h(doc, "summary", { text: "Built-in variables" }),
70
- h(doc, "p", { class: "opf-template-help", text: "Read from the presentation's own speaker, organization and deck fields; edit those fields to change them. Use them like any variable: {{speaker.name}} inside text, or var:speaker.photo as a whole image field. Slide number, section and slide count vary per slide: write them as {{slide.number}}, {{slide.section}} and {{deck.slideCount}} inside any text, not as a whole var: field." }),
70
+ h(doc, "p", { class: "opf-template-help", text: "Read from the presentation's own speaker, organization and deck fields; edit those fields to change them. Use them like any variable: {{speaker.name}} inside text, or var:speaker.photo as a whole image field. Slide number, section and slide count vary per slide: write them as {{slide.number}}, {{slide.section}} and {{deck.slideCount}} inside any text, not as a whole var: field. The organization logos are images that follow each slide's background (light or dark artwork): place one as a whole image field, for example var:organization.logo.icon in a header or footer zone's image, or choose it with Insert logo." }),
71
71
  builtinList);
72
72
  const live = h(doc, "p", { class: "opf-template-live", role: "status", "aria-live": "polite" });
73
73
  const applyButton = h(doc, "button", { type: "button", class: "opf-template-apply primary", text: "Fill the presentation" });
@@ -230,16 +230,19 @@ export function createTemplatePanel(container, options) {
230
230
  const builtins = listBuiltins(editor.presentation);
231
231
  insertSelect.replaceChildren(
232
232
  ...fields.map((field) => h(doc, "option", { value: field.id, text: `${field.label} (${KIND_LABELS[field.kind]})` })),
233
- ...(builtins.length ? [h(doc, "optgroup", { label: "Built-in variables" }, ...builtins.map((entry) => h(doc, "option", { value: entry.name, text: `${entry.label} (${entry.name})` })))] : []),
233
+ // A slide-scoped image (an organization logo) is a whole-field var: reference, not a {{ }} token for text.
234
+ ...(builtins.length ? [h(doc, "optgroup", { label: "Built-in variables" }, ...builtins.filter((entry) => !(entry.scope === "slide" && entry.kind === "image")).map((entry) => h(doc, "option", { value: entry.name, text: `${entry.label} (${entry.name})` })))] : []),
234
235
  );
235
236
  builtinSection.hidden = !builtins.length;
236
237
  builtinList.replaceChildren(...builtins.map((entry) => {
237
238
  const uses = entry.uses.length;
238
239
  // A slide-scoped built-in has no single value: each slide gets its own when it is drawn or exported (FA-31).
239
240
  const slideScoped = entry.scope === "slide";
240
- const shown = slideScoped ? h(doc, "em", { text: "varies per slide" }) : entry.available ? builtinValueText(entry) : h(doc, "em", { text: "not set" });
241
+ // RR-71: an organization logo is an image placed by var:, and the artwork follows each slide's background.
242
+ const logoImage = slideScoped && entry.kind === "image";
243
+ const shown = logoImage ? h(doc, "em", { text: "image; light or dark artwork per slide" }) : slideScoped ? h(doc, "em", { text: "varies per slide" }) : entry.available ? builtinValueText(entry) : h(doc, "em", { text: "not set" });
241
244
  return h(doc, "li", { "data-builtin": entry.name, "data-kind": entry.kind, "data-scope": entry.scope, "data-available": entry.available ? "true" : "false" },
242
- h(doc, "code", { text: `{{${entry.name}}}` }), ` ${entry.label}: `, shown, slideScoped && !entry.available ? " (no slide has one yet)" : "", uses ? ` (used ${uses} time${uses === 1 ? "" : "s"})` : "");
245
+ h(doc, "code", { text: logoImage ? `var:${entry.name}` : `{{${entry.name}}}` }), ` ${entry.label}: `, shown, slideScoped && !logoImage && !entry.available ? " (no slide has one yet)" : "", logoImage && !entry.available ? " (no logo yet)" : "", uses ? ` (used ${uses} time${uses === 1 ? "" : "s"})` : "");
243
246
  }));
244
247
  slideSelect.replaceChildren(...(editor.presentation.slides ?? []).map((slide, index) => h(doc, "option", { value: String(index), text: `${index + 1}. ${slideTitle(slide) || "Untitled"}` })));
245
248
  previewSlide = Math.min(previewSlide, Math.max(0, (editor.presentation.slides?.length ?? 1) - 1));
package/dist/templates.js CHANGED
@@ -8,7 +8,7 @@ import { OPFEditorError, getValueAtPath, opfPathToJsonPointer } from "./index.js
8
8
 
9
9
  const VARIABLE_ID = /^[a-z][a-z0-9-]*$/;
10
10
  // A built-in variable name (core FA-04): `speakers`, or deck/speaker/organization plus one or two dotted segments.
11
- const BUILTIN_NAME = /^(?:speakers|(?:deck|slide|speaker|organization)(?:\.[A-Za-z0-9_-]+){1,2})$/;
11
+ const BUILTIN_NAME = /^(?:speakers|(?:deck|slide|speaker|organization)(?:\.[A-Za-z0-9_-]+){1,3})$/;
12
12
  const KINDS = ["color", "text", "number", "date", "image", "url", "list"];
13
13
 
14
14
  /** The form control each variable kind uses. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-editor",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Embeddable local OPF editor primitives built on the deterministic renderer.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -258,11 +258,11 @@
258
258
  "@codemirror/state": "6.7.6",
259
259
  "@codemirror/view": "6.43.13",
260
260
  "@lezer/highlight": "1.2.4",
261
- "@openpresentation/opf": "^0.17.0",
261
+ "@openpresentation/opf": "^0.18.1",
262
262
  "jsonc-parser": "3.3.1"
263
263
  },
264
264
  "peerDependencies": {
265
- "@openpresentation/opf-render": "^0.17.0"
265
+ "@openpresentation/opf-render": "^0.18.0"
266
266
  },
267
267
  "peerDependenciesMeta": {
268
268
  "@openpresentation/opf-render": {
@@ -314,8 +314,9 @@
314
314
  "@expo-google-fonts/roboto-mono": "0.4.2",
315
315
  "@expo-google-fonts/stix-two-math": "0.4.0",
316
316
  "@expo-google-fonts/tinos": "0.4.2",
317
- "@openpresentation/opf-pptx": "^0.17.0",
318
- "@openpresentation/opf-render": "^0.17.0",
317
+ "@openpresentation/opf-pptx": "^0.18.0",
318
+ "@openpresentation/opf-render": "^0.18.0",
319
+ "@resvg/resvg-js": "2.6.2",
319
320
  "axe-core": "4.13.0",
320
321
  "esbuild": "0.28.2",
321
322
  "jszip": "3.10.2",