@openpresentation/opf-render 0.15.0 → 0.17.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.
@@ -2,6 +2,7 @@ import { createFontRegistry, OPFFontError } from "./font-registry.js";
2
2
  import { lazyFontEntries, lazyFontList, normalizeExtraLazyFonts } from "./lazy-font-list.js";
3
3
  import { lazyFacesNeeded } from "./lazy-fonts.js";
4
4
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
5
+ import { textOutlines } from "./text-paths.js";
5
6
  import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
6
7
  export { autoScriptSelection, detectPresentationScripts, scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
7
8
  export { lazyFontEntries, lazyFontList, splitStartupFaces } from "./lazy-font-list.js";
@@ -293,6 +294,8 @@ export async function loadFonts({ faces = [], ...options } = {}) {
293
294
  textMeasurement: registry.textMeasurement,
294
295
  get embeddedFonts() { if (stale) { embedded = registry.embeddedFonts; stale = false; } return embedded; },
295
296
  registry,
297
+ // RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
298
+ outlines: textOutlines({ registry }),
296
299
  manifest: BUNDLED_FONT_MANIFEST,
297
300
  get substitutions() { return registry.substitutions; },
298
301
  /**
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { createFontRegistry, OPFFontError } from "./font-registry.js";
7
7
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
8
8
  import { lazyFontList } from "./lazy-font-list.js";
9
+ import { textOutlines } from "./text-paths.js";
9
10
  export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
10
11
  import { analyzePresentationScripts, autoScriptSelection, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
11
12
  export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
@@ -25,10 +26,24 @@ async function verifiedFile(file, expected, details) {
25
26
  // The default Latin, Cyrillic and Greek glyph-fallback face (the office pack always loads it, fallback-only).
26
27
  const FALLBACK_PACKAGE = "@expo-google-fonts/noto-sans";
27
28
 
29
+ // RR-63: every `@expo-google-fonts/*` package is an optional peer dependency, so a host installs only the faces it uses. A request that
30
+ // needs packages that are not installed fails once, naming all of them and the install command, instead of one package at a time.
31
+ function requireInstalled(packages, what) {
32
+ const missing = packages.filter(pkg => {
33
+ if (pkg.vendored) return false;
34
+ try { require.resolve(`${pkg.name}/package.json`); return false; } catch { return true; }
35
+ });
36
+ if (!missing.length) return;
37
+ const install = `npm install ${missing.map(pkg => `${pkg.name}@${pkg.version}`).join(" ")}`;
38
+ 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});
39
+ }
40
+
28
41
  // `skipped`: an optional npm package that is not installed is recorded there instead of failing (scripts: 'auto').
29
42
  // `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} = {}) {
43
+ // `what`: how the request is named in the error for a package that is not installed, for example "The 'office' font pack".
44
+ async function loadPackages(packages, skipped, {fallbackOnly = false, what = "This font request"} = {}) {
31
45
  const entries = [], fontFiles = [];
46
+ if (!skipped) requireInstalled(packages, what);
32
47
  for (const pkg of packages) {
33
48
  if (pkg.vendored) {
34
49
  // 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 +68,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
53
68
  installed = JSON.parse(await readFile(manifestPath, "utf8"));
54
69
  } catch (error) {
55
70
  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});
71
+ throw new OPFFontError("font-resource-unavailable", `Install ${pkg.name}@${pkg.version} to use ${what}.`, {package:pkg.name, cause:error.code});
57
72
  }
58
73
  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
74
  const directory = path.dirname(manifestPath);
@@ -73,7 +88,7 @@ async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
73
88
  const renamedAliases = () => Object.fromEntries(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "open" && item.renamedFrom).map(item => [item.renamedFrom, item.faces[0].family]));
74
89
  // The vendored faces this registry holds that are embed "used": a host that serves fonts itself lists them to copy their files.
75
90
  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));
91
+ const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack), undefined, {what: `The '${pack}' font pack`});
77
92
 
78
93
  /**
79
94
  * Adds the script pack. `scripts` is 'all', ISO 15924 codes, or 'auto' (FF-19): detect the scripts the
@@ -86,7 +101,7 @@ async function withScripts(loaded, scripts, {presentation, onDiagnostic, renderO
86
101
  // A package the registry already loaded (the default Noto Sans fallback) is never loaded twice.
87
102
  const notLoaded = list => list.filter(pkg => !loaded.packages?.includes(pkg.name));
88
103
  if (scripts !== "auto") {
89
- const extra = await loadPackages(notLoaded(scriptFontPackages(scripts)));
104
+ const extra = await loadPackages(notLoaded(scriptFontPackages(scripts)), undefined, {what: `The requested script fonts (${Array.isArray(scripts) ? scripts.join(", ") : scripts})`});
90
105
  return {...loaded, entries:[...loaded.entries, ...embedUsed(extra.entries)], fontFiles:[...loaded.fontFiles, ...extra.fontFiles], packages:[...(loaded.packages ?? []), ...extra.packages]};
91
106
  }
92
107
  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 +175,11 @@ async function withFaces(loaded, faces) {
160
175
  */
161
176
  async function buildRegistry(pack, {scripts, faces, presentation, onDiagnostic, renderOptions, ...options}) {
162
177
  let loaded, registryOptions = options;
178
+ // RR-63: name every npm font package the pack needs at once, before loading any of them.
179
+ if (pack === "base" || pack === "office") {
180
+ const packs = pack === "base" ? ["base"] : ["office", ...(options.includeBaseFonts === false ? [] : ["base"])];
181
+ requireInstalled([...BUNDLED_FONT_MANIFEST.packages.filter(item => packs.includes(item.pack)), ...(pack === "office" ? scriptFontPackages(["Latn"]) : [])], `The '${pack}' font pack`);
182
+ }
163
183
  if (pack === "office") {
164
184
  const {entries, fontFiles, packages} = await loadPack("office");
165
185
  for (const [include, name] of [[options.includeOpenFonts, "open"], [options.includeBaseFonts, "base"]]) {
@@ -177,7 +197,7 @@ async function buildRegistry(pack, {scripts, faces, presentation, onDiagnostic,
177
197
  // an explicit list does; a presentation that does not select Latn keeps it fallback-only.
178
198
  const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation, renderOptions).scripts).map(pkg => pkg.name)
179
199
  : 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});
200
+ 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
201
  fontFiles.push(...fallback.fontFiles);
182
202
  entries.push(...fallback.entries);
183
203
  packages.push(...fallback.packages);
@@ -253,6 +273,8 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
253
273
  useBundledFonts: false,
254
274
  loadSystemFonts: false,
255
275
  registry,
276
+ // RR-64: the outline engine `renderSvg(deck, { fonts, textAsPaths: true })` draws text with, over this registry's faces.
277
+ outlines: textOutlines({ registry }),
256
278
  manifest: BUNDLED_FONT_MANIFEST,
257
279
  get substitutions() { return registry.substitutions; },
258
280
  /** Load the script faces the presentation's text needs (see `ensureFonts`); `embeddedFonts` and `fontFiles` then include them. */
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[];
@@ -49,7 +49,11 @@ export interface RenderFonts {
49
49
  useBundledFonts?: boolean;
50
50
  /** Whether the raster conversion loads system fonts. Default false (the output never depends on the machine). */
51
51
  loadSystemFonts?: boolean;
52
+ /** The outline engine `textAsPaths: true` draws text with (RR-64); a `loadFonts` handle carries one over its registry's faces. */
53
+ outlines?: TextOutlines;
52
54
  }
55
+ /** Draws a slide's text as glyph outlines (RR-64). Read by `renderSvg`; hosts pass the handle and `textAsPaths: true`. */
56
+ export interface TextOutlines { outlineSlideText(content: string[], options: object): { content: string[]; defs: string } }
53
57
  /** What `fonts.ensure(presentation)` loaded: the script packages and vendored faces that were missing, and drawn CJK characters no face covers. */
54
58
  export interface EnsureResult { scripts: string[]; lazy: LazyFont[]; uncovered: string[] }
55
59
  /** The fonts handle `loadFonts()` returns from `/fonts-node` and `/fonts-browser`: pass it as `{ fonts }` to every deck-level function. */
@@ -58,6 +62,8 @@ export interface FontsHandle extends RenderFonts {
58
62
  readonly embeddedFonts: EmbeddedFont[];
59
63
  /** The face registry behind the handle (shaping, resolution, lazy and script loading). */
60
64
  readonly registry: FontRegistry;
65
+ /** The outline engine for `textAsPaths: true` (RR-64), over this registry's faces, including faces `ensure` loads later. */
66
+ readonly outlines: TextOutlines;
61
67
  /** The pinned manifest of the bundled font packages. */
62
68
  readonly manifest: { readonly version: number; readonly packages: readonly object[] };
63
69
  /** The substitutions made so far (a requested family drawn with another face), as `registry.substitutions`. */
package/dist/index.d.ts CHANGED
@@ -54,10 +54,22 @@ export interface RenderSvgOptions {
54
54
  skipHidden?: boolean;
55
55
  /**
56
56
  * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
57
- * `/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
58
58
  * core's portable width estimate and the SVG names the design fonts without embedding them.
59
59
  */
60
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;
63
+ /**
64
+ * `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
65
+ * no font and looks the same in every viewer (about 40 KB a slide over the core example decks, against 0.5 to 4 MB of embedded
66
+ * faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
67
+ * cannot be selected, searched or edited in place, and each outlined element is a `role="img"` group labelled with its text for
68
+ * assistive technology. Each element keeps its `data-opf-*` trace. Text in a colour or bitmap font stays text
69
+ * (`text-as-paths-kept-text`). Use it for thumbnails, previews and portable SVG files, not for an editing surface or the
70
+ * vector PDF (which keeps real text from `<text>` SVG). Default false.
71
+ */
72
+ textAsPaths?: boolean;
61
73
  /** `"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`. */
62
74
  glyphFallback?: "chain" | "none";
63
75
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
package/dist/pdf-text.js CHANGED
@@ -59,7 +59,7 @@ export function layoutText(textNode, env, parentStyle, link) {
59
59
  const preserve = own["xml:space"] === "preserve" || /^pre/.test(own["white-space"] ?? "");
60
60
  for (const child of node.children) {
61
61
  if (child.text !== undefined) {
62
- for (const character of child.text.replace(/[\r\n\t]/g, " ")) chars.push({ ch: character, style: own, scope: nextScope, decoration: nextDecoration, link: activeLink, preserve });
62
+ for (const character of child.text.replace(/[\r\n\t]/g, " ")) chars.push({ ch: character, style: own, scope: nextScope, decoration: nextDecoration, link: activeLink, preserve, owner: node });
63
63
  } else if (isElement(child)) {
64
64
  if (child.name === "tspan" || child.name === "textPath" || child.name === "tref") walk(child, own, nextScope, nextDecoration, activeLink);
65
65
  else if (child.name === "a") {
@@ -89,7 +89,8 @@ export function layoutText(textNode, env, parentStyle, link) {
89
89
  const styleIds = new Map();
90
90
  const faceCache = new Map();
91
91
  const linkIds = new Map();
92
- const runsByChunk = chunks.map((chunk) => buildRuns(chunk, env, styleIds, faceCache, linkIds));
92
+ const ownerIds = new Map();
93
+ const runsByChunk = chunks.map((chunk) => buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds));
93
94
 
94
95
  // textLength: the renderer gives lines and tabs an exact width; reproduce it per scope over all its runs.
95
96
  const scopeWidth = new Map();
@@ -155,7 +156,7 @@ function collapseWhitespace(chars) {
155
156
  for (const char of kept) chars.push(char);
156
157
  }
157
158
 
158
- function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
159
+ function buildRuns(chunk, env, styleIds, faceCache, linkIds, ownerIds) {
159
160
  const text = chunk.chars.map((char) => char.ch).join("");
160
161
  const baseDirection = chunk.chars[0].style.direction === "rtl" ? "rtl" : "ltr";
161
162
  // Levels per UTF-16 unit; characters take the level of their first unit.
@@ -174,9 +175,11 @@ function buildRuns(chunk, env, styleIds, faceCache, linkIds) {
174
175
  if (IGNORED.test(char.ch)) return;
175
176
  const level = levels[index];
176
177
  const resolved = pickFace(char, env, styleIds, faceCache);
177
- const key = `${resolved.styleId}|${resolved.face.id}|${level}|${char.scope?.id ?? 0}|${linkId(linkIds, char.link)}|${[...char.decoration].sort().join(",")}`;
178
+ // RR-64: outlined text keeps each element's trace, so with `env.splitByOwner` a run never spans two elements.
179
+ const owner = env.splitByOwner ? linkId(ownerIds, { node: char.owner }) : 0;
180
+ const key = `${resolved.styleId}|${resolved.face.id}|${level}|${char.scope?.id ?? 0}|${linkId(linkIds, char.link)}|${[...char.decoration].sort().join(",")}|${owner}`;
178
181
  if (!current || current.key !== key) {
179
- current = { chunk: chunkInfo, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
182
+ current = { chunk: chunkInfo, key, face: resolved.face, style: char.style, level, scope: char.scope, link: char.link, decoration: char.decoration, owner: char.owner, chars: [], text: "", logical: "", weight: resolved.weight, italic: resolved.italic };
180
183
  runs.push(current);
181
184
  }
182
185
  current.logical += char.ch;
@@ -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) {
@@ -54,28 +54,23 @@ export function scriptPackageEntries(packages, {baseUrl}) {
54
54
  // Everywhere: the schema URL, embedded assets, catalog records, speaker notes, ids, image alt text and sources,
55
55
  // links, metadata and typed `extensions` passthrough. On the presentation: name (aria-label fallback), description,
56
56
  // filename, author, audience, purpose, tone, takeaway, duration, tags, narrative, language and version.
57
- // On a slide: beat. The organization (name), the speaker (name and title) and a slide's section are drawn only as
58
- // generated header/footer furniture (`organization: true` / `speaker: true` / `section: true` in a design), so they count
59
- // only when a design asks for them; built-in variables copy their values into slide strings before this runs. Everything else is drawn text: titles, text blocks, list items, runs, table cells, chart labels,
60
- // categories and series names, code, quotes, metrics, timelines, furniture text.
57
+ // On a slide: beat. The organization (name), the speaker (name and title) and a slide's section are drawn only through the
58
+ // built-in variables that read them ({{organization.name}}, {{speaker.title}}, {{slide.section}}, FA-31), so they count
59
+ // only when a string the preview draws holds such a token. Everything else is drawn text: titles, text blocks, list items,
60
+ // runs, table cells, chart labels, categories and series names, code, quotes, metrics, timelines, header and footer text.
61
61
  const UNDRAWN_KEYS = Object.freeze(["$schema", "assets", "catalogs", "notes", "id", "alt", "altText", "src", "url", "href", "link", "metadata", "extensions"]);
62
62
  const UNDRAWN_ROOT_KEYS = Object.freeze(["name", "description", "filename", "author", "authors", "creator", "audience", "purpose", "tone", "takeaway", "duration", "tags", "narrative", "language", "license", "version", "keywords"]);
63
63
  const UNDRAWN_SLIDE_KEYS = Object.freeze(["beat"]);
64
64
  const UNDRAWN_VALUE = /^(?:https?|data|blob|pkg|file|mailto):\S*$/i;
65
65
 
66
- /** True when a design in the presentation turns on generated `field` furniture (`organization`, `speaker` or `section`). */
67
- function furnitureUses(presentation, field) {
68
- const designs = [presentation?.design, ...(Array.isArray(presentation?.slides) ? presentation.slides.map(slide => slide?.design) : [])];
69
- const pattern = new RegExp(`"${field}"\\s*:\\s*true`);
70
- return designs.some(design => design && pattern.test(JSON.stringify(design)));
71
- }
66
+ // The built-in variables that copy a document value into drawn text: the inline token and the whole-field reference
67
+ // (a logo or photo reference draws a picture, not text).
68
+ const ORGANIZATION_TOKEN = /\{\{\s*organization\.|var:organization\.(?!logo\b)/;
69
+ const SPEAKER_TOKEN = /\{\{\s*speaker\.|var:speaker\.(?!photo\b|image\b|avatar\b)/;
70
+ const SECTION_TOKEN = /\{\{\s*slide\.section\s*[|}]/;
72
71
 
73
- /** The strings a preview of this presentation draws. */
74
- export function* drawnStrings(presentation) {
75
- const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set(UNDRAWN_ROOT_KEYS), undrawnSlide = new Set(UNDRAWN_SLIDE_KEYS);
76
- if (!furnitureUses(presentation, "organization")) undrawnRoot.add("organization");
77
- if (!furnitureUses(presentation, "speaker")) undrawnRoot.add("speaker");
78
- if (!furnitureUses(presentation, "section")) undrawnSlide.add("section");
72
+ /** The strings under `presentation`, skipping the keys in the three sets (root keys, slide keys, and everywhere). */
73
+ function* walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide) {
79
74
  function* visit(item, level) {
80
75
  if (typeof item === "string") { if (!UNDRAWN_VALUE.test(item)) yield item; }
81
76
  else if (Array.isArray(item)) for (const child of item) yield* visit(child, level === "slides" ? "slide" : level === "root" ? "deep" : level);
@@ -89,6 +84,18 @@ export function* drawnStrings(presentation) {
89
84
  yield* visit(presentation, "root");
90
85
  }
91
86
 
87
+ /** The strings a preview of this presentation draws. */
88
+ export function* drawnStrings(presentation) {
89
+ const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set([...UNDRAWN_ROOT_KEYS, "organization", "speaker"]), undrawnSlide = new Set([...UNDRAWN_SLIDE_KEYS, "section"]);
90
+ // The organization, the speaker and a slide's section draw only where a drawn string reads them with a built-in variable.
91
+ for (const text of walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide)) {
92
+ if (ORGANIZATION_TOKEN.test(text)) undrawnRoot.delete("organization");
93
+ if (SPEAKER_TOKEN.test(text)) undrawnRoot.delete("speaker");
94
+ if (SECTION_TOKEN.test(text)) undrawnSlide.delete("section");
95
+ }
96
+ yield* walkStrings(presentation, undrawn, undrawnRoot, undrawnSlide);
97
+ }
98
+
92
99
  /**
93
100
  * The script profile of a presentation: core `resolveScriptFonts` output, exactly what the renderer plans
94
101
  * with. When it throws, the renderer ignores the document language, so the profile is empty and Han text is
package/dist/svg.d.ts CHANGED
@@ -54,10 +54,22 @@ export interface RenderSvgOptions {
54
54
  skipHidden?: boolean;
55
55
  /**
56
56
  * The fonts the deck is laid out and drawn with: the handle `loadFonts()` returns (from `@openpresentation/opf-render/fonts-node` or
57
- * `/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
58
58
  * core's portable width estimate and the SVG names the design fonts without embedding them.
59
59
  */
60
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;
63
+ /**
64
+ * `true` draws every `<text>` as glyph outlines (RR-64): `<use>` of outlines kept once per slide in `<defs>`, so the SVG needs
65
+ * no font and looks the same in every viewer (about 40 KB a slide over the core example decks, against 0.5 to 4 MB of embedded
66
+ * faces). Needs the fonts handle `loadFonts()` returns (`text-as-paths-needs-fonts` otherwise). The text is no longer text: it
67
+ * cannot be selected, searched or edited in place, and each outlined element is a `role="img"` group labelled with its text for
68
+ * assistive technology. Each element keeps its `data-opf-*` trace. Text in a colour or bitmap font stays text
69
+ * (`text-as-paths-kept-text`). Use it for thumbnails, previews and portable SVG files, not for an editing surface or the
70
+ * vector PDF (which keeps real text from `<text>` SVG). Default false.
71
+ */
72
+ textAsPaths?: boolean;
61
73
  /** `"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`. */
62
74
  glyphFallback?: "chain" | "none";
63
75
  /** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */