@openpresentation/opf-render 0.11.3 → 0.11.5

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.
@@ -4,6 +4,7 @@ import type {
4
4
  FontRegistryOptions,
5
5
  } from "./fonts.js";
6
6
  import type { AutoScriptSelection, BundledFontPackage, LazyFont, ScriptSelection } from "./fonts-node.js";
7
+ import type { RenderSvgOptions } from "./svg.js";
7
8
  export type { LazyFont } from "./fonts-node.js";
8
9
  export type { AutoScriptSelection } from "./fonts-node.js";
9
10
  export { detectPresentationScripts, autoScriptSelection } from "./fonts-node.js";
@@ -18,15 +19,22 @@ export interface BrowserFontRegistry extends FontRegistry {
18
19
  /** Every vendored face a host can load on demand: family, style, package-relative file and pinned sha256. */
19
20
  readonly lazyFonts: readonly LazyFont[];
20
21
  /**
21
- * Load the vendored faces (for example Intos for the default Aptos scheme) the presentation's font families resolve to
22
- * under the registry's `substitutionPolicy` (nothing is downloaded for a family the policy would not resolve), once
23
- * each, from `lazyFontsBaseUrl`. Fetches and verifies every file, then adds the faces to the document and the registry
24
- * together; on failure nothing changes and the call can be retried. Call it before measuring a document. After
25
- * `dispose()` it rejects with `font-registry-disposed`.
22
+ * Load the vendored faces (for example Intos Display 700 and Intos 400 for a plain deck in the default Aptos scheme) the
23
+ * presentation draws, resolved as the registry resolves them under its `substitutionPolicy` (nothing is downloaded for a
24
+ * family the policy would not resolve, nor for a weight or style nothing draws), once each, from `lazyFontsBaseUrl`.
25
+ * Fetches and verifies every file, then adds the faces to the document and the registry together; on failure nothing
26
+ * changes and the call can be retried. Call it before measuring a document, and again after edits: an edit that adds a
27
+ * bold or italic run loads just that face. Besides `signal`, the call takes the `renderSvg` options the document resolves
28
+ * with (`catalogs`, ...) over the loader's `renderOptions`. A document that does not resolve rejects with the error
29
+ * `renderSvg` throws for it. After `dispose()` it rejects with `font-registry-disposed`.
30
+ */
31
+ ensureLazyFonts(presentation: unknown, options?: { signal?: AbortSignal } & Partial<RenderSvgOptions>): Promise<LazyFont[]>;
32
+ /**
33
+ * Synchronous: the vendored faces the presentation draws under the registry's policy that are not loaded yet. Empty means
34
+ * `ensureLazyFonts` fetches nothing. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...)
35
+ * over the loader's `renderOptions`. Throws what `renderSvg` throws for a document that does not resolve.
26
36
  */
27
- ensureLazyFonts(presentation: unknown, options?: { signal?: AbortSignal }): Promise<LazyFont[]>;
28
- /** Synchronous: the vendored faces the presentation needs under the registry's policy that are not loaded yet. Empty means `ensureLazyFonts` fetches nothing. */
29
- pendingLazyFonts(presentation: unknown): LazyFont[];
37
+ pendingLazyFonts(presentation: unknown, renderOptions?: Partial<RenderSvgOptions>): LazyFont[];
30
38
 
31
39
  /** Load script faces for ISO 15924 codes (or "all") from `scriptBaseUrl`. Resolves with the newly loaded package names. */
32
40
  loadScripts(scripts: ScriptSelection, options?: { signal?: AbortSignal }): Promise<string[]>;
@@ -39,9 +47,9 @@ export interface BrowserFontRegistry extends FontRegistry {
39
47
  * OPFFontError whose `details` are `{ loaded: string[]; failed: {package, code, message}[] }`. A failed call (fetch, hash, FontFace load) leaves nothing loaded and can be retried; after
40
48
  * `dispose()` it rejects with `font-registry-disposed`.
41
49
  */
42
- ensureScripts(presentation: unknown, options?: { signal?: AbortSignal }): Promise<AutoScriptSelection & { loaded: string[]; uncovered: string[] }>;
43
- /** Synchronous: package names the presentation needs that are not loaded yet. Empty means `ensureScripts` fetches nothing, so a host can render immediately. */
44
- pendingScripts(presentation: unknown): string[];
50
+ ensureScripts(presentation: unknown, options?: { signal?: AbortSignal } & Partial<RenderSvgOptions>): Promise<AutoScriptSelection & { loaded: string[]; uncovered: string[] }>;
51
+ /** Synchronous: package names the presentation needs that are not loaded yet. Empty means `ensureScripts` fetches nothing, so a host can render immediately. Text decides, so a document is analyzed without resolving its layouts; `renderOptions.catalogs` matter for font schemes only the host's catalogs have (FF-41). */
52
+ pendingScripts(presentation: unknown, renderOptions?: Partial<RenderSvgOptions>): string[];
45
53
  /** Names of the script-pack packages loaded so far. */
46
54
  readonly loadedScriptPackages: string[];
47
55
  }
@@ -60,6 +68,12 @@ export declare function loadBrowserFontRegistry(
60
68
  scriptBaseUrl?: string;
61
69
  /** Where the host serves this package's `fonts` directory, so vendored faces (Intos, the open families) load on demand from `<base>/<package-relative file>` (fonts/intos/..., fonts/<family>/...). */
62
70
  lazyFontsBaseUrl?: string;
71
+ /**
72
+ * FF-41: the `renderSvg` options the documents resolve with (`catalogs` above all), the default for `pendingLazyFonts`,
73
+ * `ensureLazyFonts`, `pendingScripts` and `ensureScripts`. A layout or font scheme id that only the host's catalogs have
74
+ * resolves the same way here as it does when the host renders. A call's own options win.
75
+ */
76
+ renderOptions?: Partial<RenderSvgOptions>;
63
77
  },
64
78
  ): Promise<BrowserFontRegistry>;
65
79
  export declare function scriptFontPackages(scripts: ScriptSelection): BundledFontPackage[];
@@ -72,10 +86,37 @@ export declare function scriptFontEntries(
72
86
  export declare function lazyFontList(): readonly LazyFont[];
73
87
  /** Hash-pinned browser entries for the vendored faces, served by the host from `baseUrl` at their package-relative paths. */
74
88
  export declare function lazyFontEntries(options: { baseUrl: string }, list?: readonly LazyFont[]): (BrowserFontInput & { url: string; family: string; weight: number; italic: boolean; sha256: string; package: string })[];
75
- /** The font families a presentation's slides resolve (heading, body and code roles); empty for a document that does not resolve. */
76
- export declare function presentationFamilies(presentation: unknown): Set<string>;
77
89
  /**
78
- * The vendored faces a set of resolved families needs that the registry does not hold, following the registry's own order and
90
+ * The font families a presentation's slides resolve (heading, body and code roles of every slide). `options` are the
91
+ * `renderSvg` options the document resolves with (`catalogs`, ...). A document that does not resolve throws what `renderSvg`
92
+ * throws for it (FF-41: it no longer returns an empty set). This is the role families, not what is drawn: see `presentationFaces`.
93
+ */
94
+ export declare function presentationFamilies(presentation: unknown, options?: Partial<RenderSvgOptions>): Set<string>;
95
+ /** A face request or a drawn face: a family, a weight and a style. */
96
+ export interface FaceRequest { family: string; weight: number; italic: boolean }
97
+ /**
98
+ * The faces a preview of the presentation draws (FF-41), sorted by family, style and weight: the renderer's own layout and
99
+ * painting with a recording measurement, so heading and body roles, bold and italic runs, tables, furniture, chart labels and
100
+ * the code role (only where code is drawn), per-slide overrides and script slots are all followed. Without `registry` the
101
+ * faces are the styles as the document names them. With `registry` (`{faces, policy, aliases, fallbackFamily}`: the faces a
102
+ * registry holds or could hold and its substitution options) every style resolves as that registry resolves it, in the two
103
+ * steps drawing takes (role family, then weight within it), and the result is the faces the registry paints.
104
+ * `options` are the `renderSvg` options the document resolves with; a document that does not resolve throws what `renderSvg`
105
+ * throws for it.
106
+ */
107
+ export declare function presentationFaces(presentation: unknown, options?: Partial<RenderSvgOptions>, registry?: { faces: Iterable<FaceRequest>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string>; fallbackFamily?: string }): FaceRequest[];
108
+ /**
109
+ * The vendored faces (of `lazy`) a presentation draws that the registry does not hold. `held` is `registry.describeFaces()`,
110
+ * `loaded` the lazy files already loaded; `policy`, `aliases` and `fallbackFamily` are the registry's. Face level: only the
111
+ * drawn faces, so an edit adding a bold run adds one face.
112
+ */
113
+ export declare function lazyFacesNeeded(presentation: unknown, renderOptions: Partial<RenderSvgOptions> | undefined, context: { lazy: readonly LazyFont[]; held: Iterable<FaceRequest>; loaded?: ReadonlySet<string>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string>; fallbackFamily?: string }): LazyFont[];
114
+ /**
115
+ * The vendored faces a list of requests needs that the registry does not hold, following the registry's own order and
79
116
  * `policy` (default "visual"): the family itself, an alias target, the declared replacement, then its alternates.
117
+ * Entries are face requests (`presentationFaces`), resolved as the registry resolves them with every vendored face loaded
118
+ * over `held` (`registry.describeFaces()`), so only the faces asked for are returned and a partially loaded family still
119
+ * loads its missing faces; or family names, which mean every face of the family. Without `held` a face request falls back to
120
+ * family level with `hasFamily`.
80
121
  */
81
- export declare function lazyFontsFor(families: Iterable<string>, context: { lazy: readonly LazyFont[]; hasFamily(family: string): boolean; loaded?: ReadonlySet<string>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string> }): LazyFont[];
122
+ export declare function lazyFontsFor(entries: Iterable<string | { family: string; weight?: number; italic?: boolean }>, context: { lazy: readonly LazyFont[]; held?: Iterable<FaceRequest>; hasFamily?: (family: string) => boolean; loaded?: ReadonlySet<string>; policy?: "none" | "metric" | "visual"; aliases?: ReadonlyMap<string, string>; fallbackFamily?: string }): LazyFont[];
@@ -1,10 +1,10 @@
1
1
  import { createFontRegistry, OPFFontError } from "./fonts.js";
2
2
  import { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
3
- import { lazyFontsFor, presentationFamilies } from "./lazy-fonts.js";
3
+ import { lazyFacesNeeded } from "./lazy-fonts.js";
4
4
  import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
5
5
  export { autoScriptSelection, detectPresentationScripts, scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
6
6
  export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
7
- export { lazyFontsFor, presentationFamilies } from "./lazy-fonts.js";
7
+ export { lazyFacesNeeded, lazyFontsFor, presentationFaces, presentationFamilies } from "./lazy-fonts.js";
8
8
 
9
9
  async function verifyDigest(entry, data, subtle) {
10
10
  if (entry.sha256 === undefined) return;
@@ -91,6 +91,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
91
91
  // Script faces (FF-19) are loaded lazily, once a document needs them, and only for the scripts it uses.
92
92
  const scriptPackages = new Set();
93
93
  let queue = Promise.resolve(), disposed = false;
94
+ // FF-41: the `renderSvg` options a document resolves with (`catalogs`, ...). `options.renderOptions` are the defaults; a call's own
95
+ // options (everything but `signal`) are added over them, so a host that passes its canvas options gets the same resolution here.
96
+ const renderOptionsFor = (callOptions) => { const { signal: _signal, ...own } = callOptions ?? {}; return { ...options.renderOptions, ...own }; };
94
97
  const gone = () => new OPFFontError("font-registry-disposed", "The font registry was disposed.");
95
98
  const baseUrl = () => {
96
99
  if (typeof options.scriptBaseUrl !== "string" || !options.scriptBaseUrl)
@@ -142,14 +145,15 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
142
145
  * fallback needs for characters the loaded faces lack. Cheap when nothing new is needed; call it after edits and
143
146
  * render again afterwards. Resolves with the detected scripts, the packages loaded by this call, the scripts no
144
147
  * pinned font serves and `uncovered`, drawn CJK characters no loaded face covers (the renderer reports
145
- * `missing-glyph` for them). Each package loads all or nothing. If some package fails, the others still load: the
148
+ * `missing-glyph` for them). Besides `signal`, the call takes the `renderSvg` options the document resolves with
149
+ * (`catalogs`, ...) over the loader's `renderOptions`. Each package loads all or nothing. If some package fails, the others still load: the
146
150
  * call then rejects with an OPFFontError (the first failure's code) whose `details` hold `loaded` (packages this
147
151
  * call did load) and `failed` (`{package, code, message}` per failed package); nothing is lost and a later call
148
152
  * retries only the failures. `signal` aborts this call's fetches.
149
153
  */
150
154
  async ensureScripts(presentation, callOptions) {
151
155
  if (disposed) throw gone();
152
- const analysis = analyzePresentationScripts(presentation), selection = scriptSelectionOf(analysis);
156
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(callOptions)), selection = scriptSelectionOf(analysis);
153
157
  const loadedNow = [], failed = [];
154
158
  const attempt = async (item) => {
155
159
  try { loadedNow.push(...(await loadPackageList([item], callOptions))); }
@@ -166,9 +170,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
166
170
  }
167
171
  return { ...selection, loaded: loadedNow, uncovered: uncoveredCjkCharacters(analysis, { covers }) };
168
172
  },
169
- /** Synchronous: script-pack packages the presentation needs that are not loaded yet. Empty means `ensureScripts` would fetch nothing. */
170
- pendingScripts(presentation) {
171
- const analysis = analyzePresentationScripts(presentation);
173
+ /** Synchronous: script-pack packages the presentation needs that are not loaded yet. Empty means `ensureScripts` would fetch nothing. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...). */
174
+ pendingScripts(presentation, renderOptions) {
175
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(renderOptions));
172
176
  const primary = scriptFontPackages(scriptSelectionOf(analysis).scripts).map((item) => item.name).filter((name) => !scriptPackages.has(name));
173
177
  if (primary.length) return primary;
174
178
  const next = nextFallbackPackage(analysis, { covers, loaded: scriptPackages });
@@ -181,8 +185,9 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
181
185
  const lazy = lazyFontList(), lazyLoaded = new Set();
182
186
  const policy = options.substitutionPolicy ?? "none";
183
187
  const aliasTargets = new Map(Object.entries(options.aliases ?? {}).map(([from, to]) => [from.toLowerCase(), to]));
184
- const hasFamily = (family) => registry.describeFaces().some((face) => face.family.toLowerCase() === String(family).toLowerCase());
185
- const neededLazy = (presentation) => lazyFontsFor(presentationFamilies(presentation), { lazy, hasFamily, loaded: lazyLoaded, policy, aliases: aliasTargets });
188
+ // Face level (FF-41): the faces the document draws, resolved as the registry resolves them with every vendored face loaded.
189
+ // A document that does not resolve throws what `renderSvg` throws for it.
190
+ const neededLazy = (presentation, callOptions) => lazyFacesNeeded(presentation, renderOptionsFor(callOptions), { lazy, held: registry.describeFaces(), loaded: lazyLoaded, policy, aliases: aliasTargets, fallbackFamily: options.fallbackFamily });
186
191
  const lazyBaseUrl = () => {
187
192
  if (typeof options.lazyFontsBaseUrl !== "string" || !options.lazyFontsBaseUrl)
188
193
  throw new OPFFontError("invalid-font-source", "Loading vendored fonts requires lazyFontsBaseUrl, where the host serves the package's fonts directory.");
@@ -192,16 +197,19 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
192
197
  /** Every vendored face a host can load on demand: family, style, package-relative file and pinned sha256. */
193
198
  lazyFonts: lazy,
194
199
  /**
195
- * Load the vendored faces the presentation's font families resolve to under the registry's substitution policy (nothing
196
- * is downloaded for a family the policy would not resolve), once each. All or nothing: on any failure (fetch, hash,
197
- * FontFace.load, registry) the document and registry are unchanged and the call can be retried. Cheap when nothing is
198
- * needed. Call it before measuring a document, and again after edits that change fonts. Resolves with the faces added.
200
+ * Load the vendored faces the presentation draws (FF-41: only the faces, by family, weight and style, that the renderer
201
+ * draws; a bold or italic run added by an edit adds just that face) as the registry resolves them under its substitution
202
+ * policy (nothing is downloaded for a family the policy would not resolve), once each. All or nothing: on any failure
203
+ * (fetch, hash, FontFace.load, registry) the document and registry are unchanged and the call can be retried. Cheap when
204
+ * nothing is needed. Call it before measuring a document, and again after edits that change fonts. Resolves with the faces
205
+ * added. Besides `signal`, the call takes the `renderSvg` options the document resolves with (`catalogs`, ...) over the
206
+ * loader's `renderOptions`; a document that does not resolve rejects with what `renderSvg` throws for it.
199
207
  */
200
208
  ensureLazyFonts(presentation, callOptions = {}) {
201
209
  const result = queue.then(async () => {
202
210
  if (disposed) throw gone();
203
211
  callOptions.signal?.throwIfAborted?.();
204
- const pending = neededLazy(presentation);
212
+ const pending = neededLazy(presentation, callOptions);
205
213
  if (!pending.length) return [];
206
214
  const fetched = (await fetchFaces(lazyFontEntries({ baseUrl: lazyBaseUrl() }, pending), callOptions.signal)).map((entry) => ({ ...entry, embed: "used" }));
207
215
  let browserFaces;
@@ -229,8 +237,8 @@ export async function loadBrowserFontRegistry(entries, options = {}) {
229
237
  queue = result.catch(() => {});
230
238
  return result;
231
239
  },
232
- /** Synchronous: the vendored faces the presentation needs under the registry's policy that are not loaded yet. Empty means `ensureLazyFonts` fetches nothing. */
233
- pendingLazyFonts: (presentation) => neededLazy(presentation),
240
+ /** Synchronous: the vendored faces the presentation draws under the registry's policy that are not loaded yet. Empty means `ensureLazyFonts` fetches nothing. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...). */
241
+ pendingLazyFonts: (presentation, renderOptions) => neededLazy(presentation, renderOptions),
234
242
  });
235
243
  /** Names of the script-pack packages loaded so far. */
236
244
  Object.defineProperty(registry, "loadedScriptPackages", { get: () => [...scriptPackages], enumerable: true });
@@ -1,4 +1,5 @@
1
1
  import type {FontRegistry,FontRegistryOptions,EmbeddedFont,FontFaceInput,ScriptFontProfile} from "./fonts.js";
2
+ import type {RenderSvgOptions} from "./svg.js";
2
3
  /** A caller-supplied face (FF-31), for example a licensed copy of the real font. Node callers may pass a file path. */
3
4
  export type CallerFontFace = FontFaceInput | (Omit<FontFaceInput, "data"> & {path: string});
4
5
  /** "all" or ISO 15924 codes (`Jpan`, `Hans`, `Hant`, `Kore`, `Arab`, `Hebr`, `Deva`, `Thai`, ...). */
@@ -15,8 +16,9 @@ export interface AutoScriptSelection {
15
16
  /** Script-pack packages that `scripts: "auto"` loaded or found not installed. */
16
17
  export interface AppliedScriptSelection extends AutoScriptSelection { packages: string[]; notInstalled: string[]; /** Drawn CJK characters no loaded face covers (glyph fallback stopped at its cap). */ uncovered?: string[] }
17
18
  /** Script keys whose faces a preview of the presentation needs: text decides, the document language only tells Han scripts apart. */
18
- export declare function detectPresentationScripts(presentation: unknown, options?: { profile?: ScriptFontProfile }): string[];
19
- export declare function autoScriptSelection(presentation: unknown, options?: { profile?: ScriptFontProfile }): AutoScriptSelection;
19
+ /** `options` may also carry the `renderSvg` options the document resolves with (`catalogs`): a font scheme that exists only in the host's catalogs and names a script font selects that font's script (FF-41). */
20
+ export declare function detectPresentationScripts(presentation: unknown, options?: { profile?: ScriptFontProfile } & Partial<RenderSvgOptions>): string[];
21
+ export declare function autoScriptSelection(presentation: unknown, options?: { profile?: ScriptFontProfile } & Partial<RenderSvgOptions>): AutoScriptSelection;
20
22
  export interface ScriptPackOptions {
21
23
  /** Caller-supplied faces, loaded first so a licensed real font resolves as an exact face (FF-31). */
22
24
  faces?: readonly CallerFontFace[];
@@ -28,6 +30,8 @@ export interface ScriptPackOptions {
28
30
  scripts?: ScriptSelection | "auto";
29
31
  /** The presentation whose text decides `scripts: "auto"`. */
30
32
  presentation?: unknown;
33
+ /** The `renderSvg` options the presentation resolves with (`catalogs`, ...), for the font schemes `scripts: "auto"` reads (FF-41). */
34
+ renderOptions?: Partial<RenderSvgOptions>;
31
35
  /** Receives `script-font-unavailable` and `script-font-not-installed` for `scripts: "auto"`. */
32
36
  onDiagnostic?: (diagnostic: {code: string; message: string; script?: string; package?: string; scripts?: string[]}) => void;
33
37
  }
@@ -7,7 +7,7 @@ import { createFontRegistry, OPFFontError } from "./fonts.js";
7
7
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
8
8
  import { lazyFontList } from "./lazy-font-list.js";
9
9
  export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
10
- import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
10
+ import { analyzePresentationScripts, autoScriptSelection, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
11
11
  export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
12
12
  // Script faces are embedded in a standalone SVG only when the slide's text uses their family.
13
13
  const embedUsed = entries => entries.map(entry => ({...entry, embed: "used"}));
@@ -78,9 +78,10 @@ const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item
78
78
  /**
79
79
  * Adds the script pack. `scripts` is 'all', ISO 15924 codes, or 'auto' (FF-19): detect the scripts the
80
80
  * `presentation` draws and load only their faces. Auto never fails on a missing optional package or a
81
- * script no pinned font serves; it reports them through `onDiagnostic` and `selection`.
81
+ * script no pinned font serves; it reports them through `onDiagnostic` and `selection`. `renderOptions` are the host's `renderSvg`
82
+ * options (see `analyzePresentationScripts`).
82
83
  */
83
- async function withScripts(loaded, scripts, {presentation, onDiagnostic} = {}) {
84
+ async function withScripts(loaded, scripts, {presentation, onDiagnostic, renderOptions} = {}) {
84
85
  if (scripts === undefined || (Array.isArray(scripts) && !scripts.length)) return loaded;
85
86
  // A package the registry already loaded (the default Noto Sans fallback) is never loaded twice.
86
87
  const notLoaded = list => list.filter(pkg => !loaded.packages?.includes(pkg.name));
@@ -89,7 +90,7 @@ async function withScripts(loaded, scripts, {presentation, onDiagnostic} = {}) {
89
90
  return {...loaded, entries:[...loaded.entries, ...embedUsed(extra.entries)], fontFiles:[...loaded.fontFiles, ...extra.fontFiles], packages:[...(loaded.packages ?? []), ...extra.packages]};
90
91
  }
91
92
  if (presentation === null || typeof presentation !== "object") throw new OPFFontError("invalid-font-scripts", "scripts: 'auto' needs the presentation whose text decides the scripts.", {scripts});
92
- const selection = scriptSelectionOf(analyzePresentationScripts(presentation)), skipped = [];
93
+ const selection = scriptSelectionOf(analyzePresentationScripts(presentation, undefined, renderOptions)), skipped = [];
93
94
  const extra = await loadPackages(notLoaded(scriptFontPackages(selection.scripts)), skipped);
94
95
  for (const script of selection.unavailable) onDiagnostic?.({code: "script-font-unavailable", script, message: `No pinned open font serves script '${script}'; that text uses the design font.`});
95
96
  for (const item of skipped) onDiagnostic?.({code: "script-font-not-installed", package: item.package, scripts: item.scripts, message: `Install ${item.package}@${item.version} to preview ${item.scripts.join(", ")} text with its designated open font.`});
@@ -102,12 +103,12 @@ async function withScripts(loaded, scripts, {presentation, onDiagnostic} = {}) {
102
103
  * has (Japanese-only kanji in a Simplified Chinese run, hanja) needs the next CJK face along the fallback chain.
103
104
  * Adds those packages to the registry and its fontFiles until every such character is covered or the chain ends.
104
105
  */
105
- async function completeFallback(registry, presentation, loaded, onDiagnostic) {
106
+ async function completeFallback(registry, presentation, loaded, onDiagnostic, renderOptions) {
106
107
  const selection = loaded.selection;
107
108
  if (!selection) return;
108
109
  const seen = new Set([...selection.packages, ...selection.notInstalled]);
109
110
  for (;;) {
110
- const analysis = analyzePresentationScripts(presentation), covers = character => registry.scriptFacesCover(character);
111
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptions), covers = character => registry.scriptFacesCover(character);
111
112
  const next = nextFallbackPackage(analysis, {covers, loaded: seen});
112
113
  if (!next) {
113
114
  const uncovered = uncoveredCjkCharacters(analysis, {covers, limit: 8});
@@ -152,17 +153,17 @@ async function withFaces(loaded, faces) {
152
153
  }
153
154
 
154
155
  /** Bundled, openly licensed faces. No system font discovery or network requests. */
155
- export async function loadBundledFontRegistry({scripts, faces, presentation, onDiagnostic, ...options} = {}) {
156
- const {entries, fontFiles, selection} = await withFaces(await withScripts(await loadPack("base"), scripts, {presentation, onDiagnostic}), faces);
156
+ export async function loadBundledFontRegistry({scripts, faces, presentation, onDiagnostic, renderOptions, ...options} = {}) {
157
+ const {entries, fontFiles, selection} = await withFaces(await withScripts(await loadPack("base"), scripts, {presentation, onDiagnostic, renderOptions}), faces);
157
158
  const registry = Object.assign(createFontRegistry(entries,options),{fontFiles, ...(selection ? {scriptSelection: selection} : {})});
158
159
  registry.lazyFonts = lazyOf(registry);
159
- await completeFallback(registry, presentation, {fontFiles, selection}, onDiagnostic);
160
+ await completeFallback(registry, presentation, {fontFiles, selection}, onDiagnostic, renderOptions);
160
161
  return registry;
161
162
  }
162
163
 
163
164
  /** Office substitutes plus the open families that font schemes select (FF-31),
164
165
  * optionally alongside the base Roboto pack. `includeOpenFonts: false` leaves the open families out. */
165
- export async function loadOfficeFontRegistry({scripts, faces, presentation, onDiagnostic, ...options} = {}) {
166
+ export async function loadOfficeFontRegistry({scripts, faces, presentation, onDiagnostic, renderOptions, ...options} = {}) {
166
167
  const {entries, fontFiles, packages} = await loadPack("office");
167
168
  for (const [include, pack] of [[options.includeOpenFonts, "open"], [options.includeBaseFonts, "base"]]) {
168
169
  if (include === false) continue;
@@ -174,17 +175,20 @@ export async function loadOfficeFontRegistry({scripts, faces, presentation, onDi
174
175
  // Noto Sans (regular, bold, italic, bold italic) is the default Latin, Cyrillic and Greek fallback face
175
176
  // (glyph fallback), so Georgia with Russian text previews without the scripts option. It is embedded in an
176
177
  // SVG only when the slide's text draws it (embed "used"); raster output reads it from fontFiles. Unless the
177
- // caller asked for it with scripts it is fallback-only, so no other family's preview changes.
178
- const requested = scripts === undefined || scripts === "auto" || (Array.isArray(scripts) && !scripts.length) ? [] : scriptFontPackages(scripts).map(pkg => pkg.name);
178
+ // caller asked for it with scripts it is fallback-only, so no other family's preview changes. "auto" counts as asking for it when the
179
+ // presentation itself selects Latn (a Sylfaen scheme: Noto Sans is its designated replacement, which a fallback-only face never is), as
180
+ // an explicit list does; a presentation that does not select Latn keeps it fallback-only.
181
+ const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation, renderOptions).scripts).map(pkg => pkg.name)
182
+ : scripts === undefined || scripts === "auto" || (Array.isArray(scripts) && !scripts.length) ? [] : scriptFontPackages(scripts).map(pkg => pkg.name);
179
183
  const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true});
180
184
  fontFiles.push(...fallback.fontFiles);
181
185
  entries.push(...fallback.entries);
182
186
  packages.push(...fallback.packages);
183
- const loaded = await withFaces(await withScripts({entries, fontFiles, packages}, scripts, {presentation, onDiagnostic}), faces);
187
+ const loaded = await withFaces(await withScripts({entries, fontFiles, packages}, scripts, {presentation, onDiagnostic, renderOptions}), faces);
184
188
  const aliases = options.includeOpenFonts === false ? options.aliases : {...renamedAliases(), ...options.aliases};
185
189
  const registry = Object.assign(createFontRegistry(loaded.entries,{substitutionPolicy:"metric",...options,...(aliases?{aliases}:{})}),{fontFiles:loaded.fontFiles, ...(loaded.selection ? {scriptSelection: loaded.selection} : {})});
186
190
  registry.lazyFonts = lazyOf(registry);
187
- await completeFallback(registry, presentation, loaded, onDiagnostic);
191
+ await completeFallback(registry, presentation, loaded, onDiagnostic, renderOptions);
188
192
  return registry;
189
193
  }
190
194
 
package/dist/fonts.js CHANGED
@@ -45,6 +45,83 @@ function unavailableFontError(family, style, policy) {
45
45
  return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} None of its replacements (${candidates.join(", ")}) is loaded${packs.length ? `; load the ${packs.map(pack=>"'"+pack+"'").join(" or ")} font pack` : ""}, or ${hook}. The PPTX names '${family}' either way.`, details);
46
46
  }
47
47
  /** Local font files only. The caller explicitly chooses aliases and fallback. */
48
+ // fontkit 2.0.4 cannot decode every OpenType lookup: its GSUB type 8 (reverse chaining contextual single substitution) struct lacks the
49
+ // backtrackGlyphCount field, so the lookup fails with "Not a fixed size" when a feature that references it is applied, and it has no processor
50
+ // for the type anyway. Noto Sans Mongolian 3.x has such a lookup under calt and rclt in every script, so every shaping of the face threw (FF-44).
51
+ // A lookup that cannot be decoded is treated as one that applies nothing (no subtables), so the rest of the feature still runs: advances equal
52
+ // a browser's for the face's Latin and Mongolian samples (test/mongolian-shaping.mjs). Applied only after shaping failed, once per font.
53
+ const lookupsGuarded = new WeakSet();
54
+ function skipUndecodableLookups(font) {
55
+ if (lookupsGuarded.has(font)) return false;
56
+ lookupsGuarded.add(font);
57
+ let guarded = false;
58
+ for (const tag of ["GSUB", "GPOS"]) {
59
+ let list;
60
+ try { list = font[tag]?.lookupList; } catch { continue; }
61
+ if (typeof list?.get !== "function") continue;
62
+ const get = list.get.bind(list);
63
+ list.get = index => { try { return get(index); } catch { return { lookupType: 1, flags: {}, subTables: [] }; } };
64
+ guarded = true;
65
+ }
66
+ return guarded;
67
+ }
68
+
69
+ /**
70
+ * The face a registry draws for `family` (a validated concrete family: theme tokens are already resolved) in `style`, chosen
71
+ * from `faces` ({family, familyGroup, weight, italic, fallbackOnly?, scripts?}) under the substitution policy, aliases and
72
+ * fallback family, or an OPFFontError when none applies. Shared by the registry's resolution and by the lazy loader's face
73
+ * planning (lazy-fonts.js), so the browser loads exactly the faces Node would draw with everything loaded.
74
+ */
75
+ export function pickFace(faces, family, style, {policy = "none", aliases = new Map(), fallbackFamily} = {}) {
76
+ const findFamily = name => faces.filter(face=>face.family.toLowerCase()===name.toLowerCase() || face.familyGroup.toLowerCase()===name.toLowerCase());
77
+ const weight = style.fontWeight ?? 400;
78
+ let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family";
79
+ const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
80
+ if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
81
+ if (!matching.length && aliases.has(family.toLowerCase())) {
82
+ matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
83
+ }
84
+ if (!matching.length && policy!=="none") {
85
+ const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
86
+ const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
87
+ // A declared visual replacement is used in visual mode, and in metric mode only when the
88
+ // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
89
+ // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
90
+ // replacement at weights 400 and 700, normal or italic, with an exact face weight.
91
+ const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
92
+ const allowVisual = policy==="visual" || metricFallback;
93
+ if (candidate && (allowVisual || tier==="metric")) {
94
+ // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
95
+ // the replacement; its bold style link still selects bold (FF-31).
96
+ const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
97
+ for (const [index, substitute] of candidate.substitutes.entries()) {
98
+ // Only the declared replacement can carry the row's metric claim; an alternate is visual.
99
+ const substituteTier = index===0 ? tier : "visual";
100
+ if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
101
+ const exactWeight = substituteTier==="metric" || policy!=="visual";
102
+ const pool = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
103
+ let available = pool.filter(face=>face.italic===!!style.italic);
104
+ // Visual replacements without the requested style draw the other one and say so.
105
+ if (!available.length && substituteTier==="visual" && policy==="visual" && pool.length) { available = pool.filter(face=>!face.italic); styleFallback = available.length>0; }
106
+ if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
107
+ }
108
+ }
109
+ }
110
+ // Equations require an explicit math-aware choice; never fall through to body text.
111
+ if (!matching.length && /^(cambria math|stix two math|noto sans math)$/i.test(family)) throw new OPFFontError("math-font-required", `Supply '${family}' or an explicit math-font alias.`, {path:style.path});
112
+ if (!matching.length && fallbackFamily) { matching=findFamily(fallbackFamily); compatibility="generic"; via="fallback"; }
113
+ if (!matching.length) throw unavailableFontError(family, style, policy);
114
+ const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
115
+ // Script replacement faces have no italics; use upright glyphs and advances.
116
+ if (!styled.length && style.italic && matching.length && matching.every(face=>face.scripts)) { if (compatibility!=="generic") compatibility="visual"; }
117
+ else matching = styled;
118
+ if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
119
+ matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
120
+ const face = matching[0];
121
+ if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
122
+ return {face, compatibility, rule, styleFallback, via};
123
+ }
124
+
48
125
  export function createFontRegistry(entries, options = {}) {
49
126
  if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
50
127
  const makeFace = entry => {
@@ -106,7 +183,6 @@ export function createFontRegistry(entries, options = {}) {
106
183
  const policy = options.substitutionPolicy ?? "none";
107
184
  if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
108
185
  if (options.fallbackFamily) validFamily(options.fallbackFamily);
109
- const findFamily = family => faces.filter(face=>face.family.toLowerCase()===family.toLowerCase() || face.familyGroup.toLowerCase()===family.toLowerCase());
110
186
  const resolve = style => {
111
187
  const requested = validFamily(style?.fontFamily);
112
188
  const weight = style.fontWeight ?? 400;
@@ -117,50 +193,7 @@ export function createFontRegistry(entries, options = {}) {
117
193
  const family = themeKey ? options.themeFonts?.[themeKey] : requested;
118
194
  if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
119
195
  validFamily(family);
120
- let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family";
121
- const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
122
- if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
123
- if (!matching.length && aliases.has(family.toLowerCase())) {
124
- matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
125
- }
126
- if (!matching.length && policy!=="none") {
127
- const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
128
- const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
129
- // A declared visual replacement is used in visual mode, and in metric mode only when the
130
- // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
131
- // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
132
- // replacement at weights 400 and 700, normal or italic, with an exact face weight.
133
- const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
134
- const allowVisual = policy==="visual" || metricFallback;
135
- if (candidate && (allowVisual || tier==="metric")) {
136
- // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
137
- // the replacement; its bold style link still selects bold (FF-31).
138
- const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
139
- for (const [index, substitute] of candidate.substitutes.entries()) {
140
- // Only the declared replacement can carry the row's metric claim; an alternate is visual.
141
- const substituteTier = index===0 ? tier : "visual";
142
- if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
143
- const exactWeight = substituteTier==="metric" || policy!=="visual";
144
- const faces = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
145
- let available = faces.filter(face=>face.italic===!!style.italic);
146
- // Visual replacements without the requested style draw the other one and say so.
147
- if (!available.length && substituteTier==="visual" && policy==="visual" && faces.length) { available = faces.filter(face=>!face.italic); styleFallback = available.length>0; }
148
- if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
149
- }
150
- }
151
- }
152
- // Equations require an explicit math-aware choice; never fall through to body text.
153
- if (!matching.length && /^(cambria math|stix two math|noto sans math)$/i.test(family)) throw new OPFFontError("math-font-required", `Supply '${family}' or an explicit math-font alias.`, {path:style.path});
154
- if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; via="fallback"; }
155
- if (!matching.length) throw unavailableFontError(family, style, policy);
156
- const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
157
- // Script replacement faces have no italics; use upright glyphs and advances.
158
- if (!styled.length && style.italic && matching.length && matching.every(face=>face.scripts)) { if (compatibility!=="generic") compatibility="visual"; }
159
- else matching = styled;
160
- if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
161
- matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
162
- const face = matching[0];
163
- if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
196
+ const {face, compatibility, rule, styleFallback, via} = pickFace(faces, family, style, {policy, aliases, fallbackFamily:options.fallbackFamily});
164
197
  // FF-31: `substitute` is true whenever the face is not the chosen family itself (policy
165
198
  // replacement, alias, script replacement or fallback). Exporters keep writing sourceFamily.
166
199
  const substitute = via!=="family", policyRow = substitute ? fontPolicyFor(family) : undefined;
@@ -203,11 +236,15 @@ export function createFontRegistry(entries, options = {}) {
203
236
  let run;
204
237
  try { run=shape(); }
205
238
  catch (error) {
239
+ // A lookup fontkit cannot decode (GSUB type 8 in Noto Sans Mongolian) applies nothing; shape again with the rest (FF-44).
240
+ if (skipUndecodableLookups(face.font)) { try { run=shape(); } catch { /* the mark retry below */ } }
206
241
  // fontkit rejects some mark-attachment lookups (null anchors, for example
207
242
  // Gurmukhi tippi in Noto Sans Gurmukhi). Mark positioning moves marks
208
243
  // without changing advances, so measure again without it (FF-19).
209
- try { run=shape({abvm:false,blwm:false,mark:false,mkmk:false}); }
210
- catch { throw new OPFFontError("font-shaping-failed", `Font '${face.family}' cannot shape this text.`, {path:style.path,fontFamily:face.family,cause:error?.message}); }
244
+ if (!run) {
245
+ try { run=shape({abvm:false,blwm:false,mark:false,mkmk:false}); }
246
+ catch { throw new OPFFontError("font-shaping-failed", `Font '${face.family}' cannot shape this text.`, {path:style.path,fontFamily:face.family,cause:error?.message}); }
247
+ }
211
248
  }
212
249
  value={width:run.positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm,...value};
213
250
  if(includeOutline) {