@openpresentation/opf-render 0.14.0 → 0.16.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/element.js CHANGED
@@ -168,19 +168,22 @@ function createElementClass() {
168
168
  /** Open the full-screen slideshow (needs a user gesture to enter full screen). Loads the player on first use. */
169
169
  async present(options = {}) {
170
170
  if (this.#session && !this.#session.closed) return this.#session;
171
+ // Capture before loading the player: activeElement in the document is the shadow host, not its invoking button.
172
+ let returnFocus = options.returnFocus ?? this.ownerDocument.activeElement;
173
+ if (!options.returnFocus) while (returnFocus?.shadowRoot?.activeElement) returnFocus = returnFocus.shadowRoot.activeElement;
171
174
  await this.#readyPromise;
172
175
  if (!this.#store || !this.#cursor) throw new DeckError("not-ready", "The deck is not loaded yet.");
173
176
  const { present } = await import("./player.js");
174
177
  const session = await present(this.#deck, {
175
178
  store: this.#store, fonts: this.#fonts ?? this.#resolvedFonts, renderOptions: this.#renderOptions,
176
- includeHidden: this.includeHidden, startSlide: this.#cursor.number, returnFocus: this.#parts.viewport, ...options,
179
+ includeHidden: this.includeHidden, startSlide: this.#cursor.number, ...options, returnFocus,
177
180
  });
178
181
  this.#session = session;
179
182
  session.addEventListener("slidechange", (event) => { if (this.#cursor && event.detail?.index !== undefined && this.#cursor.gotoIndex(event.detail.index)) { this.#show({ announce: false }); this.#emitChange(); } });
180
183
  session.addEventListener("close", () => {
181
184
  if (this.#session === session) this.#session = undefined;
182
185
  this.#show({ announce: false });
183
- this.#parts.viewport?.focus({ preventScroll: true });
186
+ if (!returnFocus?.isConnected) this.#parts.viewport?.focus({ preventScroll: true });
184
187
  this.dispatchEvent(new CustomEvent("presentend", { detail: { slide: this.#cursor?.number } }));
185
188
  });
186
189
  this.dispatchEvent(new CustomEvent("presentstart", { detail: { slide: this.#cursor?.number } }));
@@ -253,9 +256,15 @@ function createElementClass() {
253
256
  if (this.#documentValue !== undefined) return parseDeckDocument(this.#documentValue);
254
257
  const src = this.getAttribute("src");
255
258
  if (src) {
256
- const response = await fetch(new URL(src, this.ownerDocument.baseURI), { signal, credentials: "same-origin" });
257
- if (!response.ok) throw new DeckError("fetch-failed", `The deck could not be loaded (${response.status}).`);
258
- return parseDeckDocument(await response.text());
259
+ const source = new URL(src, this.ownerDocument.baseURI).href;
260
+ const response = await fetch(source, { signal, credentials: "same-origin" });
261
+ const details = {source, status: response.status, contentType: response.headers.get("content-type") ?? ""};
262
+ if (!response.ok) throw new DeckError("fetch-failed", `The deck could not be loaded (${response.status}) from ${source}.`, details);
263
+ const text = await response.text();
264
+ try { return parseDeckDocument(text); } catch (cause) {
265
+ const hint = /^\s*(?:<!doctype\s+html|<html\b|<head\b|<body\b)/i.test(text) ? " The response is HTML; check the deck URL and the host's SPA fallback route." : "";
266
+ throw new DeckError("invalid-document", `Invalid OPF from ${source} (${details.contentType || "unknown content type"}): ${cause.message}${hint}`, {...details, cause});
267
+ }
259
268
  }
260
269
  const inline = this.querySelector(':scope > script[type="application/opf+json"], :scope > script[type="application/json"]');
261
270
  if (inline) return parseDeckDocument(inline.textContent ?? "");
@@ -263,7 +272,8 @@ function createElementClass() {
263
272
  }
264
273
 
265
274
  #report(error, fatal) {
266
- this.dispatchEvent(new CustomEvent("error", { detail: { code: error.code, message: error.message, fatal } }));
275
+ const {source, status, contentType} = error.details ?? {};
276
+ this.dispatchEvent(new CustomEvent("error", { detail: { code: error.code, message: error.message, fatal, ...(source ? {source, status, contentType} : {}) } }));
267
277
  }
268
278
 
269
279
  #prefetch() {
@@ -504,6 +514,8 @@ const escapeHtml = (value) => String(value).replace(/&/g, "&amp;").replace(/</g,
504
514
  *
505
515
  * `slides`: `"first"` (default: the first slide plus a list of titles), `"all"` or a list of 1-based slide numbers.
506
516
  * `embed` puts the document in an `application/opf+json` script child, so the element upgrades without a request.
517
+ * `fontMode`: `"standalone"` (default, embeds per slide), `"shared"` (one set of font rules in the returned tag), or
518
+ * `"external"` (no font rules; the host must supply CSS with the exact pinned faces the handle measures).
507
519
  */
508
520
  export function renderDeckHtml(input, options = {}) {
509
521
  const deck = parseDeckDocument(input);
@@ -512,13 +524,23 @@ export function renderDeckHtml(input, options = {}) {
512
524
  const wanted = options.slides ?? "first";
513
525
  const shown = wanted === "all" ? sequence.map((_, position) => position) : wanted === "first" ? [0] : wanted.map((number) => number - 1).filter((position) => position >= 0 && position < sequence.length);
514
526
  const handle = options.fonts !== null && typeof options.fonts === "object" ? options.fonts : undefined;
527
+ const fontMode = options.fontMode ?? "standalone";
528
+ if (!["standalone", "shared", "external"].includes(fontMode)) throw new DeckError("invalid-font-mode", "Choose standalone, shared or external SSR fonts.");
529
+ // Every mode measures with the same handle; external writes no @font-face data (RR-61 `embedFonts: false`).
530
+ const renderFonts = handle ?? options.renderOptions?.fonts;
531
+ const sharedRules = new Set();
515
532
  const attributes = { src: options.src, slide: options.slide, fonts: handle ? undefined : options.fonts, label: options.label, ...options.attributes };
516
533
  const attributeText = Object.entries(attributes).filter(([, value]) => value !== undefined && value !== false).map(([name, value]) => (value === true ? ` ${name}` : ` ${name}="${escapeHtml(value)}"`)).join("")
517
534
  + (options.thumbnails ? " thumbnails" : "") + (options.present ? " present" : "") + (options.includeHidden ? " include-hidden" : "");
518
535
  const date = options.renderOptions?.date ?? options.date;
519
536
  const figures = shown.map((position) => {
520
537
  const index = sequence[position];
521
- const svg = renderSlideSvg(deck, index, { ...options.renderOptions, ...(handle ? { fonts: handle } : {}), ...(date ? { date } : {}) });
538
+ let svg = renderSlideSvg(deck, index, { ...options.renderOptions, fonts: renderFonts, ...(fontMode === "external" ? { embedFonts: false } : {}), ...(date ? { date } : {}) });
539
+ if (fontMode === "shared") svg = svg.replace(/<style>(@font-face[\s\S]*?)<\/style>/g, (_, css) => {
540
+ // These rules are produced by renderSlideSvg after its used-face selection and validation.
541
+ for (const rule of css.split("\n")) sharedRules.add(rule);
542
+ return "";
543
+ });
522
544
  const { width, height } = svgDimensions(svg);
523
545
  const prepared = prepareSlideSvg(svg).replace(/^<svg\b/, `<svg style="display:block;width:100%;height:auto;aspect-ratio:${width} / ${height}"`);
524
546
  const label = slideLabel(slideInfo(deck, index), position + 1, sequence.length);
@@ -527,5 +549,6 @@ export function renderDeckHtml(input, options = {}) {
527
549
  const titles = wanted === "first" && sequence.length > 1
528
550
  ? `<nav aria-label="Slides"><ol>${sequence.map((index) => `<li>${escapeHtml(slideInfo(deck, index).title)}</li>`).join("")}</ol></nav>` : "";
529
551
  const embedded = options.embed ? `<script type="application/opf+json">${JSON.stringify(deck).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}</script>` : "";
530
- return `<${tag}${attributeText}>${figures.join("")}${titles}${embedded}</${tag}>`;
552
+ const shared = sharedRules.size ? `<style>${[...sharedRules].join("\n")}</style>` : "";
553
+ return `<${tag}${attributeText}>${shared}${figures.join("")}${titles}${embedded}</${tag}>`;
531
554
  }
@@ -13,6 +13,12 @@ export interface BrowserSvgToPngOptions {
13
13
  export interface BrowserSvgToPdfOptions extends Pick<SvgToPdfOptions, "mode" | "background" | "serifFamily" | "metadata" | "tagged" | "compress" | "strict" | "rasterFallbackScale" | "defaultFontFamily" | "sansSerifFamily" | "monospaceFamily" | "onDiagnostic"> {
14
14
  /** Raster mode: pixel density of each page image (default 2). */
15
15
  scale?: number;
16
+ /**
17
+ * Raster mode only: the pdf-lib module (`import * as pdfLib from "pdf-lib"`), an optional peer of the renderer that this entry never imports itself, so a
18
+ * bundle of it holds no PDF library. Without it raster mode tries to import "pdf-lib" by name and rejects with `OPFRenderError` `converter-missing`
19
+ * when the page cannot. The default vector mode needs no option.
20
+ */
21
+ pdfLib?: { PDFDocument: { create(options?: { updateMetadata?: boolean }): Promise<any> } };
16
22
  /**
17
23
  * The browser fonts handle (`loadFonts` from `/fonts-browser`): every face it holds can be embedded in the PDF, including script faces
18
24
  * a standalone SVG does not carry. Faces an SVG embeds as `@font-face` data (the `embeddedFonts` of the handle `renderSvg` was given) need no entry.
@@ -8,6 +8,9 @@ import { OPFRenderError, packageName } from "./svg.js";
8
8
  // to hand one over) and nothing is fetched: faces come from the SVG's own @font-face data (the `embeddedFonts` of the fonts handle
9
9
  // `renderSvg` was given), from the faces the `fonts` handle holds (`loadFonts` from `/fonts-browser`, so script faces the SVG does not
10
10
  // embed reach the PDF too), or from `fontData`.
11
+ //
12
+ // RR-63: this entry imports no converter. The one optional piece is pdf-lib, for `mode: "raster"` only (the default vector PDF
13
+ // needs nothing): the host imports it and passes it as `options.pdfLib`, so a bundler never has to resolve it.
11
14
 
12
15
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
13
16
  const MAX_PIXELS = 40_000_000;
@@ -32,7 +35,7 @@ export async function svgToPng(svg, options = {}) {
32
35
  * Convert SVG slides to a PDF, one slide per page (1 SVG pixel is 1 PDF point, as in Node). `mode: "vector"` (default) writes
33
36
  * real text, paths, gradients and images; `"raster"` draws each slide as an image (`scale`, default 2). Takes the options of
34
37
  * the Node `svgToPdf` that make sense here (`metadata`, `tagged`, `strict`, `compress`, `onDiagnostic`, the generic family
35
- * names, `rasterFallbackScale`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
38
+ * names, `rasterFallbackScale`), `pdfLib` (raster mode only: `import * as pdfLib from "pdf-lib"`), `fonts` (the handle `loadFonts()` returns: every face it holds can be embedded), `fontData`
36
39
  * (`[{ data, family? }]`, extra face bytes without a handle), `signal` (an AbortSignal, checked between pages) and
37
40
  * `onProgress({ page, pages })`.
38
41
  */
@@ -70,7 +73,7 @@ export async function svgToPdf(svgs, options = {}) {
70
73
  }
71
74
 
72
75
  async function rasterPdf(inputs, options) {
73
- const { PDFDocument } = await import("pdf-lib");
76
+ const { PDFDocument } = options.pdfLib ?? await importPdfLib();
74
77
  const pdf = await PDFDocument.create({ updateMetadata: false });
75
78
  pdf.setCreator(packageName);
76
79
  pdf.setProducer(packageName);
@@ -87,6 +90,17 @@ async function rasterPdf(inputs, options) {
87
90
  return pdf.save({ addDefaultPage: false, useObjectStreams: false });
88
91
  }
89
92
 
93
+ // pdf-lib for raster mode when the host did not pass it. The specifier is not a literal, so a browser bundler leaves it alone (and
94
+ // never fails for a host that does not install pdf-lib); it resolves where the page can import it by name, for example with an import map.
95
+ async function importPdfLib() {
96
+ const name = "pdf-lib";
97
+ try {
98
+ return await import(/* webpackIgnore: true */ /* @vite-ignore */ name);
99
+ } catch (error) {
100
+ throw new OPFRenderError("converter-missing", 'Raster-mode PDF output needs pdf-lib, an optional peer dependency of @openpresentation/opf-render: run `npm install pdf-lib@^1.17.1` and pass it as the pdfLib option (import * as pdfLib from "pdf-lib"). The default vector mode needs nothing.', { package: "pdf-lib", range: "^1.17.1", install: "npm install pdf-lib@^1.17.1", cause: error instanceof Error ? error.message : String(error) });
101
+ }
102
+ }
103
+
90
104
  // ---------------------------------------------------------------------------------------------------------------------------
91
105
  // Canvas
92
106
 
@@ -295,8 +295,8 @@ export function createFontRegistry(entries, options = {}) {
295
295
  resolveFont(style) { return resolve(style).resolution; },
296
296
  clearSubstitutions() { substitutions.clear(); },
297
297
  get substitutions() { return [...substitutions.values()]; },
298
- // Faces flagged embed:"used" (the open pack, FF-31) are not in this eager list: supplied with selectEmbeddedFonts (or
299
- // the `loadFonts` handle's embeddedFonts) they are embedded only in SVGs whose text names their family.
298
+ // Faces flagged embed:"used" (the open pack, FF-31) are not in this eager list: they come with selectEmbeddedFonts (or the
299
+ // `loadFonts` handle's embeddedFonts). Whichever list it is given, the SVG writer embeds a face only where its text draws it (RR-61).
300
300
  get embeddedFonts() { return embedded(face=>face.embed!=="used"); },
301
301
  /** Parsed face metadata in entry order, without encoding font bytes. */
302
302
  describeFaces: ()=>faces.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{}),...(face.fallbackOnly?{fallbackOnly:true}:{})})),
@@ -320,5 +320,5 @@ export function createFontRegistry(entries, options = {}) {
320
320
  return made.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{})}));
321
321
  },
322
322
  };
323
- function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts,embed:face.embed,fallbackOnly:face.fallbackOnly})).map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),...(face.embed==="used" ? {embed:"used"} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); }
323
+ function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts,embed:face.embed,fallbackOnly:face.fallbackOnly})).map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),...(face.embed ? {embed:face.embed} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); }
324
324
  }
@@ -25,10 +25,24 @@ async function verifiedFile(file, expected, details) {
25
25
  // The default Latin, Cyrillic and Greek glyph-fallback face (the office pack always loads it, fallback-only).
26
26
  const FALLBACK_PACKAGE = "@expo-google-fonts/noto-sans";
27
27
 
28
+ // RR-63: every `@expo-google-fonts/*` package is an optional peer dependency, so a host installs only the faces it uses. A request that
29
+ // needs packages that are not installed fails once, naming all of them and the install command, instead of one package at a time.
30
+ function requireInstalled(packages, what) {
31
+ const missing = packages.filter(pkg => {
32
+ if (pkg.vendored) return false;
33
+ try { require.resolve(`${pkg.name}/package.json`); return false; } catch { return true; }
34
+ });
35
+ if (!missing.length) return;
36
+ const install = `npm install ${missing.map(pkg => `${pkg.name}@${pkg.version}`).join(" ")}`;
37
+ throw new OPFFontError("font-resource-unavailable", `${what} needs font packages that are not installed (optional peer dependencies of @openpresentation/opf-render): run \`${install}\`.`, {packages: missing.map(pkg => pkg.name), install});
38
+ }
39
+
28
40
  // `skipped`: an optional npm package that is not installed is recorded there instead of failing (scripts: 'auto').
29
41
  // `fallbackOnly`: the faces serve glyph fallback and requests by their own name, never a replacement for another family.
30
- async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
42
+ // `what`: how the request is named in the error for a package that is not installed, for example "The 'office' font pack".
43
+ async function loadPackages(packages, skipped, {fallbackOnly = false, what = "This font request"} = {}) {
31
44
  const entries = [], fontFiles = [];
45
+ if (!skipped) requireInstalled(packages, what);
32
46
  for (const pkg of packages) {
33
47
  if (pkg.vendored) {
34
48
  // FF-31: vendored faces ship inside this package (pkg.vendored, for example fonts/carlito), hash-pinned like the npm packs. The open pack is
@@ -53,7 +67,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
53
67
  installed = JSON.parse(await readFile(manifestPath, "utf8"));
54
68
  } catch (error) {
55
69
  if (skipped) { skipped.push({package: pkg.name, version: pkg.version, scripts: [...pkg.scripts]}); continue; }
56
- throw new OPFFontError("font-resource-unavailable", `Install ${pkg.name}@${pkg.version} to use this offline font pack.`, {package:pkg.name, cause:error.code});
70
+ throw new OPFFontError("font-resource-unavailable", `Install ${pkg.name}@${pkg.version} to use ${what}.`, {package:pkg.name, cause:error.code});
57
71
  }
58
72
  if (installed.version !== pkg.version) throw new OPFFontError("font-version-mismatch", `Expected ${pkg.name}@${pkg.version}; reinstall the pinned package.`, {package:pkg.name, expected:pkg.version, actual:installed.version});
59
73
  const directory = path.dirname(manifestPath);
@@ -73,7 +87,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
73
87
  const renamedAliases = () => Object.fromEntries(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "open" && item.renamedFrom).map(item => [item.renamedFrom, item.faces[0].family]));
74
88
  // The vendored faces this registry holds that are embed "used": a host that serves fonts itself lists them to copy their files.
75
89
  const lazyOf = registry => lazyFontList().filter(face => registry.describeFaces().some(held => held.family === face.family && held.weight === face.weight && held.italic === face.italic));
76
- const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack));
90
+ const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack), undefined, {what: `The '${pack}' font pack`});
77
91
 
78
92
  /**
79
93
  * Adds the script pack. `scripts` is 'all', ISO 15924 codes, or 'auto' (FF-19): detect the scripts the
@@ -86,7 +100,7 @@ async function withScripts(loaded, scripts, {presentation, onDiagnostic, renderO
86
100
  // A package the registry already loaded (the default Noto Sans fallback) is never loaded twice.
87
101
  const notLoaded = list => list.filter(pkg => !loaded.packages?.includes(pkg.name));
88
102
  if (scripts !== "auto") {
89
- const extra = await loadPackages(notLoaded(scriptFontPackages(scripts)));
103
+ const extra = await loadPackages(notLoaded(scriptFontPackages(scripts)), undefined, {what: `The requested script fonts (${Array.isArray(scripts) ? scripts.join(", ") : scripts})`});
90
104
  return {...loaded, entries:[...loaded.entries, ...embedUsed(extra.entries)], fontFiles:[...loaded.fontFiles, ...extra.fontFiles], packages:[...(loaded.packages ?? []), ...extra.packages]};
91
105
  }
92
106
  if (presentation === null || typeof presentation !== "object") throw new OPFFontError("invalid-font-scripts", "scripts: 'auto' needs the presentation whose text decides the scripts.", {scripts});
@@ -160,6 +174,11 @@ async function withFaces(loaded, faces) {
160
174
  */
161
175
  async function buildRegistry(pack, {scripts, faces, presentation, onDiagnostic, renderOptions, ...options}) {
162
176
  let loaded, registryOptions = options;
177
+ // RR-63: name every npm font package the pack needs at once, before loading any of them.
178
+ if (pack === "base" || pack === "office") {
179
+ const packs = pack === "base" ? ["base"] : ["office", ...(options.includeBaseFonts === false ? [] : ["base"])];
180
+ requireInstalled([...BUNDLED_FONT_MANIFEST.packages.filter(item => packs.includes(item.pack)), ...(pack === "office" ? scriptFontPackages(["Latn"]) : [])], `The '${pack}' font pack`);
181
+ }
163
182
  if (pack === "office") {
164
183
  const {entries, fontFiles, packages} = await loadPack("office");
165
184
  for (const [include, name] of [[options.includeOpenFonts, "open"], [options.includeBaseFonts, "base"]]) {
@@ -177,7 +196,7 @@ async function buildRegistry(pack, {scripts, faces, presentation, onDiagnostic,
177
196
  // an explicit list does; a presentation that does not select Latn keeps it fallback-only.
178
197
  const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation, renderOptions).scripts).map(pkg => pkg.name)
179
198
  : scripts === undefined || scripts === "auto" || (Array.isArray(scripts) && !scripts.length) ? [] : scriptFontPackages(scripts).map(pkg => pkg.name);
180
- const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true});
199
+ const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true, what: "The 'office' font pack (its default glyph-fallback face)"});
181
200
  fontFiles.push(...fallback.fontFiles);
182
201
  entries.push(...fallback.entries);
183
202
  packages.push(...fallback.packages);
package/dist/fonts.d.ts CHANGED
@@ -8,8 +8,8 @@ export type { FontPolicyEntry } from "@openpresentation/opf/font-policy";
8
8
  export { isSymbolEncodedFamily, mapSymbolText, symbolCodeOf } from "@openpresentation/opf/symbol-font-encodings";
9
9
  export { scriptFontRole } from "@openpresentation/opf/composition";
10
10
  export type { ScriptRole } from "@openpresentation/opf/composition";
11
- export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string; /** Serves glyph fallback and requests by its own family only; never a replacement for another family. */ fallbackOnly?: boolean; /** "used" embeds the face in an SVG only when the slide's text names its family (the open pack, FF-31). Default "always". */ embed?: "always" | "used"; /** ISO 15924 scripts a designated script replacement face serves (FF-19). */ scripts?: string[] }
12
- export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string; embed?: "used" }
11
+ export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string; /** Serves glyph fallback and requests by its own family only; never a replacement for another family. */ fallbackOnly?: boolean; /** Every face is embedded in an SVG only when the slide's text draws it (RR-61). "used" also leaves the face out of the eager `registry.embeddedFonts` list (the open pack, FF-31); "always" embeds it in every SVG. */ embed?: "always" | "used"; /** ISO 15924 scripts a designated script replacement face serves (FF-19). */ scripts?: string[] }
12
+ export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string; /** "always": written into every SVG; otherwise only into an SVG whose text draws the face (RR-61). */ embed?: "always" | "used" }
13
13
  export type FontCompatibility = "exact" | "metric" | "visual" | "generic";
14
14
  export interface FontReplacementMeasurement { replacement:string; meanAbsWidthDelta:number; meanWidthDelta:number; maxAbsWidthDelta:number; styles:number; reference:string }
15
15
  export interface FontResolution {
@@ -41,7 +41,7 @@ export declare const EXPERIMENTAL_FONT_CANDIDATES: readonly Readonly<{requestedF
41
41
  export interface RenderFonts {
42
42
  /** Measures text with the real faces. Without it layout uses core's portable estimate. */
43
43
  textMeasurement?: TextMeasurement;
44
- /** Faces `renderSvg` writes into each SVG as @font-face data; a face flagged `embed: "used"` only when a slide draws its family. */
44
+ /** Faces `renderSvg` may write into an SVG as @font-face data: each only into the SVGs whose text draws it (its family, weight and style), unless flagged `embed: "always"` (RR-61). */
45
45
  embeddedFonts?: readonly EmbeddedFont[];
46
46
  /** Font files the Node raster and PDF conversions draw with. */
47
47
  fontFiles?: readonly string[];
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { RenderFonts, ScriptFonts } from "./fonts.js";
2
- import type { Finding } from "@openpresentation/opf";
2
+ import type { Catalog, Finding, UnresolvedReferenceDiagnostic } from "@openpresentation/opf";
3
3
  import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
4
4
  export declare const packageName = "@openpresentation/opf-render";
5
5
 
@@ -17,19 +17,12 @@ export declare const runtimePolicy: Readonly<{
17
17
  deterministicLocalExecution: true;
18
18
  }>;
19
19
 
20
+ /** What draws when nothing resolves: core's engine defaults (OPF 0.15 ships no catalog records), and the chart type a chart without `type` previews as. */
20
21
  export declare const engineDefaults: Readonly<{
21
- catalogs: Readonly<Record<string, Readonly<{ source: string }>>>;
22
- theme: "minimal";
23
- colorScheme: "cool-horizon";
24
- language: "english";
25
- narrative: "classic-story";
26
- tone: "formal";
27
- audience: "executives";
28
- fontScheme: Readonly<{
29
- pptx: Readonly<{ latin: "aptos"; ea: "microsoft-yahei"; cs: "nirmala-ui" }>;
30
- google: Readonly<{ latin: "roboto"; ea: "noto-sans-sc"; cs: "noto-sans" }>;
31
- }>;
32
- chartTypes: readonly ["stacked-column", "stacked-area", "line-with-markers"];
22
+ theme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_THEME;
23
+ colorScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_COLOR_SCHEME;
24
+ fontScheme: typeof import("@openpresentation/opf/composition").ENGINE_DEFAULT_FONT_SCHEME;
25
+ chartType: string;
33
26
  }>;
34
27
 
35
28
  export type RenderDiagnostic = LayoutDiagnostic | {
@@ -45,20 +38,7 @@ export type RenderDiagnostic = LayoutDiagnostic | {
45
38
  code: "unsupported-pattern" | "variable-example-used" | "variable-builtin-missing" | "date-needs-value" | "language-preview-unresolved";
46
39
  path: string;
47
40
  message: string;
48
- } | {
49
- /** A font-scheme id matched no record; the default font scheme (`aptos`) was used as the base. */
50
- code: "unresolved-font-scheme";
51
- path: string;
52
- message: string;
53
- id: string;
54
- fallback: string;
55
- } | {
56
- /** A layout, theme or colour-scheme id matched no record: the slide composed with no layout record, the `minimal` theme or the `cool-horizon` colour scheme. Never an error. */
57
- code: "unresolved-layout" | "unresolved-theme" | "unresolved-color-scheme";
58
- path: string;
59
- message: string;
60
- id: string;
61
- } | {
41
+ } | UnresolvedReferenceDiagnostic | {
62
42
  code: "unresolved-asset";
63
43
  path: string;
64
44
  message: string;
@@ -74,10 +54,12 @@ export interface RenderSvgOptions {
74
54
  skipHidden?: boolean;
75
55
  /**
76
56
  * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
77
- * `/fonts-browser`), or any object with a `textMeasurement` and, to embed faces in each SVG, an `embeddedFonts` list. Without it layout uses
57
+ * `/fonts-browser`), or any object with a `textMeasurement` and, to embed faces, an `embeddedFonts` list (each SVG carries only the faces its text draws). Without it layout uses
78
58
  * core's portable width estimate and the SVG names the design fonts without embedding them.
79
59
  */
80
60
  fonts?: RenderFonts;
61
+ /** `false` writes no `@font-face` data into the SVG (RR-61): the fonts handle still measures and the SVG still names the families, for a host whose page already has the faces (a browser `loadFonts` handle adds them to the document). Default true: each SVG embeds the faces of `fonts.embeddedFonts` its own text draws. */
62
+ embedFonts?: boolean;
81
63
  /** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
82
64
  glyphFallback?: "chain" | "none";
83
65
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
@@ -100,13 +82,18 @@ export interface RenderSvgOptions {
100
82
  */
101
83
  date?: string;
102
84
  trace?: boolean;
103
- catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
104
85
  /**
105
- * Records for catalog sources the host has already fetched, keyed by the source string a document's
106
- * `catalogs.<kind>.source` names (a single source or an ordered array; first match wins, the bundled
107
- * default catalog is appended). The renderer never fetches a source itself.
86
+ * Catalogs the host registered (core `Catalog[]`), passed unchanged to core resolution: a reference the document does not
87
+ * embed resolves against the catalog whose `source` its group names, and the first entry is the default catalog for bare ids
88
+ * when the document omits `catalogs.default`. The renderer bundles and fetches no catalog; register the gallery snapshot with
89
+ * `import { defaultCatalog } from "@openpresentation/opf/catalog"` and `catalogs: [defaultCatalog]`. Omitted: only embedded
90
+ * records resolve, and anything else draws with core's engine defaults (reported as `unresolved-reference`).
91
+ * A malformed value throws core's `OPFCatalogsOptionError` (`code: "invalid-catalogs"`); a reference with an undeclared prefix
92
+ * (`foo:id` without `catalogs.foo`) fails the boundary check (`invalid-opf`, finding `opf/undeclared-catalog`).
108
93
  */
109
- catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
94
+ catalogs?: readonly Catalog[];
95
+ /** Fail with `unresolved-reference` (an OPFRenderError whose `details.diagnostics` list the references) instead of falling back when a reference resolves nowhere. Default false. */
96
+ strictReferences?: boolean;
110
97
  }
111
98
 
112
99
  export interface SvgToPngOptions {
@@ -233,7 +220,7 @@ export interface ResolvedSlide {
233
220
  index: number;
234
221
  path: string;
235
222
  slide: unknown;
236
- /** The layout record the slide names; absent when it names none or an id no catalog has (the slide then composes with no layout). */
223
+ /** The layout record the slide names; absent when it names none or a reference that resolves nowhere (the slide then composes automatically). */
237
224
  layout?: unknown;
238
225
  design: {
239
226
  theme: unknown;
@@ -724,6 +724,8 @@ class Converter {
724
724
  result = { resource, object, width: info.width, height: info.height };
725
725
  }
726
726
  } catch (error) {
727
+ // A missing converter is the host's install, not a bad picture: say so instead of writing a PDF without its pictures.
728
+ if (error?.code === "converter-missing") throw error;
727
729
  this.unsupported("pdf-image-skipped", `An embedded image could not be decoded and is not drawn: ${error instanceof Error ? error.message : String(error)}`, { path: tracePath });
728
730
  }
729
731
  this.images.set(digest, result);
package/dist/pdf.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { svgToPdf } from "./index.js";
2
+ export type { SvgToPdfOptions, PdfDiagnostic, PdfMetadata } from "./index.js";
package/dist/pdf.js ADDED
@@ -0,0 +1,4 @@
1
+ // RR-63: the PDF format entry (Node). The default vector `svgToPdf` needs no converter for text and shapes; it loads the optional peer
2
+ // `sharp` for pictures and `@resvg/resvg-js` for the rare element it rasterizes, and `mode: "raster"` also loads `pdf-lib`. A converter
3
+ // that is not installed rejects with `OPFRenderError` code `converter-missing`. For a page use `@openpresentation/opf-render/export-browser`.
4
+ export { svgToPdf } from "./raster.js";
package/dist/player.d.ts CHANGED
@@ -7,7 +7,8 @@ import type { RenderSvgOptions } from "./svg.js";
7
7
  */
8
8
  export declare class DeckError extends Error {
9
9
  readonly code: string;
10
- constructor(code: string, message: string, options?: { cause?: unknown });
10
+ readonly details?: {source: string; status?: number; contentType?: string};
11
+ constructor(code: string, message: string, options?: { cause?: unknown; source?: string; status?: number; contentType?: string });
11
12
  }
12
13
 
13
14
  /** The slide that is showing, in the sequence that plays (hidden slides are not counted). `notes` is plain text. */
package/dist/png.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { svgToPng } from "./index.js";
2
+ export type { SvgToPngOptions } from "./index.js";
package/dist/png.js ADDED
@@ -0,0 +1,4 @@
1
+ // RR-63: the PNG format entry (Node). `svgToPng` loads the optional peers `@resvg/resvg-js` (and `sharp` for WebP and rotated JPEG
2
+ // pictures) on first use and rejects with `OPFRenderError` code `converter-missing` when they are not installed. For a page use
3
+ // `@openpresentation/opf-render/export-browser`.
4
+ export { svgToPng } from "./raster.js";
@@ -1,7 +1,7 @@
1
1
  // RR-28: fill a self-hosted font root (the layout of `preview-fonts.js`) from the installed packages, for hosts that serve
2
2
  // `<opf-deck fonts="/opf-fonts/">`. Node only. Every file is copied unmodified and checked against its pinned SHA-256 and
3
3
  // every license notice travels with it (LICENSES.txt). Nothing is downloaded: the faces come from this package's `fonts`
4
- // directory and the installed `@expo-google-fonts/*` packages.
4
+ // directory and the installed `@expo-google-fonts/*` packages (optional peer dependencies of this package: install the ones you copy).
5
5
  import crypto from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
@@ -50,7 +50,7 @@ export function copyPreviewFonts({ outDir, scripts = false, cwd, manifest = BUND
50
50
  const sourceDirectory = pkg.vendored ? within(rendererRoot, pkg.vendored) : resolvePackageDirectory(pkg.name, cwd);
51
51
  if (!sourceDirectory || !fs.existsSync(sourceDirectory)) {
52
52
  if (pkg.pack === "scripts") { missing.push(pkg.name); continue; }
53
- throw new Error(`${pkg.name} is not installed: reinstall @openpresentation/opf-render.`);
53
+ throw new Error(`${pkg.name} is not installed: it is an optional peer dependency of @openpresentation/opf-render, so run "npm install ${pkg.name}@${pkg.version}".`);
54
54
  }
55
55
  if (!pkg.vendored) {
56
56
  const installed = JSON.parse(fs.readFileSync(path.join(sourceDirectory, "package.json"), "utf8"));
@@ -1,4 +1,5 @@
1
1
  import {OPFRenderError} from './svg.js';
2
+ import {isConverterMissing,loadConverter} from './converters.js';
2
3
 
3
4
  // Resvg does not decode WebP or apply JPEG EXIF orientation, and draws no text of an SVG used as an image (its nested
4
5
  // document has no fonts). Replace only affected embedded image hrefs in the private
@@ -27,6 +28,7 @@ export async function prepareRasterImages(svg, {nestedSvg} = {}) {
27
28
  const replacement = href[0].slice(0, href[0].indexOf(href[1]) + 1) + png + href[1];
28
29
  tag = tag.slice(0, href.index) + replacement + tag.slice(href.index + href[0].length);
29
30
  } catch (error) {
31
+ if (isConverterMissing(error)) throw error;
30
32
  throw new OPFRenderError('image-conversion-failed', 'Embedded raster image could not be prepared for PNG/PDF output.', {path, cause:error instanceof Error ? error.message : String(error)});
31
33
  }
32
34
  output += svg.slice(end, match.index) + tag;
@@ -74,7 +76,7 @@ async function prepareRasterImage(uri) {
74
76
  } else if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8 || bytes[2] !== 0xff) {
75
77
  throw new Error('Embedded data is not a JPEG image.');
76
78
  }
77
- const {default:sharp}=await import('sharp');
79
+ const {default:sharp}=await loadConverter('sharp');
78
80
  const decoder=sharp(bytes,{limitInputPixels:40_000_000,animated:false,failOn:'warning'});
79
81
  if (!webp) {
80
82
  const {orientation}=await decoder.metadata();
package/dist/raster.js CHANGED
@@ -3,6 +3,7 @@ import {OPFRenderError,packageName} from './svg.js';
3
3
  import {separateLigatures} from './font-compatibility.js';
4
4
  import {monochromeColorFonts,rasterFontFiles} from './color-fonts.js';
5
5
  import {pinScriptClusters} from './raster-text.js';
6
+ import {loadConverter} from './converters.js';
6
7
  const DEFAULT_DIMENSIONS = { width: 1280, height: 720 };
7
8
  const DEFAULT_RASTER_SCALE = 1;
8
9
  const DEFAULT_RASTER_BACKGROUND = "#FFFFFF";
@@ -100,7 +101,7 @@ async function vectorPdf(inputs, options) {
100
101
  // The picture decoder of the Node vector export (the browser entry passes a canvas one). sharp loads on first use.
101
102
  function sharpImageCodec() {
102
103
  let loaded;
103
- const sharp = () => (loaded ??= import("sharp").then((module) => module.default));
104
+ const sharp = () => (loaded ??= loadConverter("sharp").then((module) => module.default));
104
105
  return {
105
106
  metadata: async (bytes) => (await sharp())(bytes, { limitInputPixels: 40_000_000, animated: false }).metadata(),
106
107
  rgba: async (bytes, { orient }) => {
@@ -162,25 +163,8 @@ async function rasterizeSvg(svgInput, options) {
162
163
  }
163
164
  }
164
165
 
165
- async function loadResvg() {
166
- try {
167
- return await import("@resvg/resvg-js");
168
- } catch (error) {
169
- throw new OPFRenderError("png-renderer-unavailable", "PNG/PDF conversion requires the Node runtime dependency @resvg/resvg-js.", {
170
- cause: error instanceof Error ? error.message : String(error)
171
- });
172
- }
173
- }
174
-
175
- async function loadPdfLib() {
176
- try {
177
- return await import("pdf-lib");
178
- } catch (error) {
179
- throw new OPFRenderError("pdf-writer-unavailable", "PDF conversion requires the runtime dependency pdf-lib.", {
180
- cause: error instanceof Error ? error.message : String(error)
181
- });
182
- }
183
- }
166
+ const loadResvg = () => loadConverter("@resvg/resvg-js");
167
+ const loadPdfLib = () => loadConverter("pdf-lib");
184
168
 
185
169
  async function bundledFontFiles() {
186
170
  if (!bundledFontFilesCache) {
@@ -92,10 +92,10 @@ export function* drawnStrings(presentation) {
92
92
  /**
93
93
  * The script profile of a presentation: core `resolveScriptFonts` output, exactly what the renderer plans
94
94
  * with. When it throws, the renderer ignores the document language, so the profile is empty and Han text is
95
- * Simplified Chinese, as drawn.
95
+ * Simplified Chinese, as drawn. `renderOptions.catalogs` (the host's registered catalogs) resolve as they do for the renderer.
96
96
  */
97
- export function presentationScriptProfile(presentation) {
98
- try { return resolveScriptFonts(presentation); } catch { /* the renderer falls back the same way */ }
97
+ export function presentationScriptProfile(presentation, renderOptions) {
98
+ try { return resolveScriptFonts(presentation, renderOptions?.catalogs !== undefined ? { catalogs: renderOptions.catalogs } : {}); } catch { /* the renderer falls back the same way */ }
99
99
  return {};
100
100
  }
101
101
 
@@ -123,9 +123,9 @@ function scriptOfFamily(family) {
123
123
  * scheme (`slide.design`), so each such slide is resolved too. `han` is the CJK script the scheme names, which tells
124
124
  * Han-only text apart when the language does not (a Yu Gothic deck whose language is English).
125
125
  *
126
- * `renderOptions` are the host's `renderSvg` options. Core resolves a font scheme id from the document and its bundled catalogs
127
- * only, while the renderer also reads `renderOptions.catalogs`; when a host passes catalogs the families the renderer resolves
128
- * per slide count too, so a scheme that exists only in the host's catalogs and names a script font still loads that font.
126
+ * `renderOptions` are the host's `renderSvg` options: their `catalogs` (core `Catalog[]`) go to core's `resolveScriptFonts`
127
+ * unchanged, and the families the renderer resolves per slide count too, so a scheme that exists only in the host's catalogs
128
+ * and names a script font still loads that font.
129
129
  */
130
130
  function designScripts(presentation, profile, renderOptions) {
131
131
  const scripts = new Set();
@@ -142,7 +142,7 @@ function designScripts(presentation, profile, renderOptions) {
142
142
  if (Array.isArray(presentation?.slides)) {
143
143
  presentation.slides.forEach((slide, slideIndex) => {
144
144
  if (!slide || typeof slide !== "object" || !slide.design || typeof slide.design !== "object") return;
145
- try { note(resolveScriptFonts(presentation, { slideIndex })); } catch { /* the renderer reports it when it draws the slide */ }
145
+ try { note(resolveScriptFonts(presentation, { slideIndex, ...(renderOptions?.catalogs !== undefined ? { catalogs: renderOptions.catalogs } : {}) })); } catch { /* the renderer reports it when it draws the slide */ }
146
146
  });
147
147
  }
148
148
  if (renderOptions?.catalogs) {
@@ -172,7 +172,8 @@ function* namedFontFamilies(value, key) {
172
172
  * `renderSvg` options) matter only for font schemes the host supplies in `renderOptions.catalogs`, which then resolve
173
173
  * like the renderer resolves them; a document that then does not resolve throws what `renderSvg` throws for it.
174
174
  */
175
- export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation), renderOptions) {
175
+ export function analyzePresentationScripts(presentation, profile, renderOptions) {
176
+ profile ??= presentationScriptProfile(presentation, renderOptions);
176
177
  const scripts = new Set(), cjk = new Set();
177
178
  const design = designScripts(presentation, profile, renderOptions);
178
179
  // Han-only text in a deck whose language is not CJK draws with the scheme's own CJK face when the scheme names one.