@openpresentation/opf-render 0.11.4 → 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.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # OPF Render
2
2
 
3
- **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the files the document's font families resolve to (the default Aptos scheme: Intos and Intos Display, 8 files, 5.9 MB), then adds them to the document and the registry together so measurement and painting never disagree. Hosts copy the vendored directories next to the page.
3
+ **Aptos previews with Intos.** The Office pack includes Intos, Intos Display, Intos Narrow and Intos Serif (16 faces, about 12 MB, vendored under `fonts/intos/`, SIL OFL 1.1, pinned to upstream commit fef9315c14da9e4b23b4c3cac8e718998d4e4736 and per-file SHA-256, unmodified from the upstream files). They are the metric-compatible previews for Aptos, Aptos Display, Aptos Narrow and Aptos Serif, the default `aptos` font scheme: 0.000% mean and maximum width difference against Aptos 2.01 in all four styles, with equal vertical metrics. The letter shapes are Intos's own, not Aptos's. The exported PPTX still names Aptos; no Aptos file is bundled or embedded. A registry built without these faces (the base pack) has no Aptos substitute: visual policy falls back to Roboto or Carlito. Like the open families, Intos is `embed: "used"`: it is not in `registry.embeddedFonts` (the 33 eager npm faces), `prepareNodeFonts().options.embeddedFonts` supplies it, and an SVG embeds only the Intos faces its text draws. `registry.lazyFonts` lists the 51 vendored faces (35 open, 16 Intos) with package-relative file and sha256, and `loadBrowserFontRegistry(faces, {lazyFontsBaseUrl})` loads them on demand: `await registry.ensureLazyFonts(presentation)` fetches and verifies only the faces the document draws, by family, weight and style (a plain Aptos deck: Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic run adds Intos Italic, a bold run Intos Bold; the code face only when a slide has code), then adds them to the document and the registry together so measurement and painting never disagree. `registry.pendingLazyFonts(presentation)` is the synchronous check hosts gate rendering on, and after an edit it reports just the newly needed faces. The faces are those the renderer itself measures and draws (`presentationFaces(presentation, options, registry)`, one layout-and-paint run with a recording measurement), resolved by the registry's own face selection over the faces it holds plus the vendored ones, so the browser loads exactly what Node draws with every vendored face loaded. A document resolves with the `renderSvg` options the host renders with: pass them as `loadBrowserFontRegistry(entries, { renderOptions: { catalogs } })`, or per call (`pendingLazyFonts(presentation, { catalogs })`, `ensureLazyFonts(presentation, { signal, catalogs })`), so a layout or font scheme id that only the host's catalogs know resolves here too; a document that does not resolve throws (rejects) what `renderSvg` throws for it instead of reporting nothing. Node's `prepareNodeFonts` and `loadOfficeFontRegistry` keep loading the whole open pack and Intos eagerly, so nothing there is face level; with `scripts: 'auto'` they accept `renderOptions: { catalogs }` too. Hosts copy the vendored directories next to the page.
4
4
 
5
- Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
5
+ Version 0.11.5 loads vendored preview fonts by face, not by family, and lets the font loaders resolve documents with the host's render options (`presentationFaces`, `lazyFacesNeeded`; `pendingLazyFonts`, `ensureLazyFonts`, `pendingScripts` and `ensureScripts` take `catalogs` and throw or reject with the `renderSvg` error for a document that does not resolve; `registry.lazyFonts` still lists 94 faces). Version 0.11.4 previews the seven chartex chart types natively (the world map as a non-geographic tile grid), keeps the Latin Noto Sans replacement for script schemes under `scripts: 'auto'` in Node, shapes Noto Sans Mongolian again and bundles Raleway and Playfair Display (`registry.lazyFonts` lists 94 faces; no API change). Version 0.11.3 previews the classic chart types natively, draws deprecated chart ids as their replacement and scales value axes like Office (no API change; the preview of chart slides changes). Version 0.11.2 makes `scripts: 'auto'` load the script faces a font scheme names and adds `details.loadedFaceHasGlyph` to `missing-glyph` (no export change). Version 0.11.1 adds the open replacement font families the policy routes to and complete Red Hat styles (no API change). Version 0.11.0 requires `@openpresentation/opf` ^0.11.2 (cover centering, Intos Aptos previews, lazy font loading, automatic script fonts and glyph fallback); use it with PPTX 0.11.0 and editor 0.10.0 so preview and export resolve the same core. Version 0.10.0 required `@openpresentation/opf` ^0.11.1 (font policy table, script fonts and right-to-left paragraphs, socials, slide-image treatments, formatted slide-number and date furniture with the host `date` option, per-item alignment). See the changelog for the intentional preview-output changes.
6
6
 
7
7
  Version 0.9.0 required `@openpresentation/opf` ^0.11.0 and resolved content ColorRef / `variables` through core `resolveColorRef()`. Authored `#RRGGBB` paint stays authored. It retains the existing rendering APIs.
8
8
 
@@ -189,6 +189,8 @@ registry.scriptSelection; // { detected, scripts, unavailable, packages, notInst
189
189
  const fonts = await loadBrowserFontRegistry(baseEntries, { substitutionPolicy: 'visual', fallbackFamily: 'Roboto', scriptBaseUrl: '/script-fonts/' });
190
190
  if (fonts.pendingScripts(presentation).length) await fonts.ensureScripts(presentation); // resolves { detected, scripts, unavailable, loaded }
191
191
  // pendingScripts is synchronous: empty means nothing is missing, so render at once.
192
+ // Text decides, so the analysis does not resolve layouts or catalogs. Pass the render options (renderOptions: { catalogs } to
193
+ // loadBrowserFontRegistry, or per call) so a font scheme only the host's catalogs have and that names a script font (Yu Gothic) counts.
192
194
  ```
193
195
 
194
196
  Where the renderer has per-character glyph fallback (`glyphFallbackFamilies`), auto also loads Noto Sans when the text has Greek or Cyrillic (the chosen Latin face may lack it), and the next CJK face of the fallback chain when a drawn Han, kana or Hangul character is missing from the loaded ones (a Simplified-only hanzi in Japanese text, hanja). That is capped at one fallback package beyond the packages the text's own scripts need: a character no CJK face covers never pulls in all four (10 to 20 MiB each); it is listed in `ensureScripts().uncovered` (Node: `script-glyph-uncovered`, `registry.scriptSelection.uncovered`) and the renderer reports `missing-glyph`. Browser loads are all or nothing per package: a failed fetch, hash check or `FontFace.load()` leaves the registry, the document and `loadedScriptPackages` unchanged and the package pending. One failing package does not lose the others: `ensureScripts` loads every package it can, then rejects with an `OPFFontError` whose `details` are `{ loaded, failed: [{package, code, message}] }`, and the next call retries only the failures. `ensureScripts(presentation, { signal })` and `loadScripts(scripts, { signal })` take a per-call `AbortSignal` (the creation signal is not reused), and `dispose()` during a load leaves no face behind and rejects with `font-registry-disposed`. Script faces carry `embed: "used"`, like the open families: the eager `registry.embeddedFonts` leaves them out, and when you pass them explicitly (`registry.selectEmbeddedFonts(face => face.scripts)`) an SVG embeds one only when the slide's text uses its family, so a Latin slide never carries a CJK face.
@@ -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
  }
@@ -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;
@@ -177,17 +178,17 @@ export async function loadOfficeFontRegistry({scripts, faces, presentation, onDi
177
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
178
179
  // presentation itself selects Latn (a Sylfaen scheme: Noto Sans is its designated replacement, which a fallback-only face never is), as
179
180
  // an explicit list does; a presentation that does not select Latn keeps it fallback-only.
180
- const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation).scripts).map(pkg => pkg.name)
181
+ const requested = scripts === "auto" && presentation !== null && typeof presentation === "object" ? scriptFontPackages(autoScriptSelection(presentation, renderOptions).scripts).map(pkg => pkg.name)
181
182
  : scripts === undefined || scripts === "auto" || (Array.isArray(scripts) && !scripts.length) ? [] : scriptFontPackages(scripts).map(pkg => pkg.name);
182
183
  const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true});
183
184
  fontFiles.push(...fallback.fontFiles);
184
185
  entries.push(...fallback.entries);
185
186
  packages.push(...fallback.packages);
186
- 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);
187
188
  const aliases = options.includeOpenFonts === false ? options.aliases : {...renamedAliases(), ...options.aliases};
188
189
  const registry = Object.assign(createFontRegistry(loaded.entries,{substitutionPolicy:"metric",...options,...(aliases?{aliases}:{})}),{fontFiles:loaded.fontFiles, ...(loaded.selection ? {scriptSelection: loaded.selection} : {})});
189
190
  registry.lazyFonts = lazyOf(registry);
190
- await completeFallback(registry, presentation, loaded, onDiagnostic);
191
+ await completeFallback(registry, presentation, loaded, onDiagnostic, renderOptions);
191
192
  return registry;
192
193
  }
193
194
 
package/dist/fonts.js CHANGED
@@ -66,6 +66,62 @@ function skipUndecodableLookups(font) {
66
66
  return guarded;
67
67
  }
68
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
+
69
125
  export function createFontRegistry(entries, options = {}) {
70
126
  if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
71
127
  const makeFace = entry => {
@@ -127,7 +183,6 @@ export function createFontRegistry(entries, options = {}) {
127
183
  const policy = options.substitutionPolicy ?? "none";
128
184
  if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
129
185
  if (options.fallbackFamily) validFamily(options.fallbackFamily);
130
- const findFamily = family => faces.filter(face=>face.family.toLowerCase()===family.toLowerCase() || face.familyGroup.toLowerCase()===family.toLowerCase());
131
186
  const resolve = style => {
132
187
  const requested = validFamily(style?.fontFamily);
133
188
  const weight = style.fontWeight ?? 400;
@@ -138,50 +193,7 @@ export function createFontRegistry(entries, options = {}) {
138
193
  const family = themeKey ? options.themeFonts?.[themeKey] : requested;
139
194
  if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
140
195
  validFamily(family);
141
- let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family";
142
- const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
143
- if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
144
- if (!matching.length && aliases.has(family.toLowerCase())) {
145
- matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
146
- }
147
- if (!matching.length && policy!=="none") {
148
- const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
149
- const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
150
- // A declared visual replacement is used in visual mode, and in metric mode only when the
151
- // policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
152
- // In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
153
- // replacement at weights 400 and 700, normal or italic, with an exact face weight.
154
- const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
155
- const allowVisual = policy==="visual" || metricFallback;
156
- if (candidate && (allowVisual || tier==="metric")) {
157
- // A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
158
- // the replacement; its bold style link still selects bold (FF-31).
159
- const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
160
- for (const [index, substitute] of candidate.substitutes.entries()) {
161
- // Only the declared replacement can carry the row's metric claim; an alternate is visual.
162
- const substituteTier = index===0 ? tier : "visual";
163
- if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
164
- const exactWeight = substituteTier==="metric" || policy!=="visual";
165
- const faces = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
166
- let available = faces.filter(face=>face.italic===!!style.italic);
167
- // Visual replacements without the requested style draw the other one and say so.
168
- if (!available.length && substituteTier==="visual" && policy==="visual" && faces.length) { available = faces.filter(face=>!face.italic); styleFallback = available.length>0; }
169
- if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
170
- }
171
- }
172
- }
173
- // Equations require an explicit math-aware choice; never fall through to body text.
174
- 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});
175
- if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; via="fallback"; }
176
- if (!matching.length) throw unavailableFontError(family, style, policy);
177
- const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
178
- // Script replacement faces have no italics; use upright glyphs and advances.
179
- if (!styled.length && style.italic && matching.length && matching.every(face=>face.scripts)) { if (compatibility!=="generic") compatibility="visual"; }
180
- else matching = styled;
181
- if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
182
- matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
183
- const face = matching[0];
184
- 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});
185
197
  // FF-31: `substitute` is true whenever the face is not the chosen family itself (policy
186
198
  // replacement, alias, script replacement or fallback). Exporters keep writing sourceFamily.
187
199
  const substitute = via!=="family", policyRow = substitute ? fontPolicyFor(family) : undefined;
@@ -1,52 +1,180 @@
1
1
  import { fontPolicyFor } from "./font-policy.js";
2
- import { resolvePresentation } from "./svg.js";
2
+ import { pickFace } from "./fonts.js";
3
+ import { renderSvgDeck, resolvePresentation } from "./svg.js";
3
4
  export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
4
5
 
5
6
  /**
6
7
  * FF-31: the vendored faces (the open families and Intos) are not in a registry's eager list. Browser hosts load them
7
- * on demand, from separate hash-pinned files, when a document's font families resolve to them. This module holds the
8
- * pure parts (which faces exist, which a presentation needs); fonts-browser.js does the fetching.
8
+ * on demand, from separate hash-pinned files, when a document draws them. This module holds the pure parts (which faces
9
+ * exist, which a presentation needs); fonts-browser.js does the fetching.
10
+ *
11
+ * FF-41: the analysis is face level. `presentationFaces` lists the {family, weight, italic} the renderer draws (resolved as a
12
+ * registry resolves them, on request), `lazyFacesNeeded` picks the vendored faces among them a registry lacks, and
13
+ * `lazyFontsFor` selects the vendored faces a list of face requests resolves to; none of them loads whole families.
9
14
  */
10
15
 
11
16
  const lc = value => String(value).trim().toLowerCase();
12
17
 
13
- /** The font families a presentation's slides resolve (heading, body and code roles), or an empty set for a document that does not resolve. */
14
- export function presentationFamilies(presentation) {
18
+ /**
19
+ * Render options that decide how a document resolves (catalogs, validate, date, ...) are forwarded from the host's
20
+ * `renderSvg` options. The analysis supplies its own measurement and reports nothing: callbacks, the host's registry
21
+ * measurement, embedded fonts, asset resolution and tracing are dropped.
22
+ */
23
+ const NOT_RESOLVE_OPTIONS = new Set(["textMeasurement", "embeddedFonts", "fontFiles", "useBundledFonts", "loadSystemFonts", "onDiagnostic", "trace", "glyphFallback", "imageResolver", "strictAssets", "slideIndex", "signal"]);
24
+ function resolveOptionsOf(options) {
25
+ return Object.fromEntries(Object.entries(options ?? {}).filter(([key]) => !NOT_RESOLVE_OPTIONS.has(key)));
26
+ }
27
+
28
+ /**
29
+ * The font families a presentation's slides resolve (heading, body and code roles of every slide, including per-slide
30
+ * design overrides), as `renderSvg` resolves them with the same `options` (`catalogs`, ...). This is the role families,
31
+ * not what is drawn: use `presentationFaces` for that.
32
+ *
33
+ * A document that does not resolve throws the error `renderSvg` throws for it (an OPFRenderError such as
34
+ * `catalog-resolution-failed` for a layout id no catalog has, or `invalid-opf`), never an empty set: a document whose layout comes only from
35
+ * `options.catalogs` needs those catalogs here too.
36
+ */
37
+ export function presentationFamilies(presentation, options = {}) {
15
38
  const families = new Set();
16
- let resolved;
17
- try { resolved = resolvePresentation(presentation, {}); } catch { return families; }
39
+ const resolved = resolvePresentation(presentation, resolveOptionsOf(options));
18
40
  for (const slide of resolved.slides) for (const family of Object.values(slide.design?.fonts ?? {})) if (typeof family === "string" && family) families.add(family);
19
41
  return families;
20
42
  }
21
43
 
44
+ // A measurement that costs nothing to overflow: text is measured as very narrow, so layout never fails on text that
45
+ // only the real faces would fit. Only the styles it is asked about matter.
46
+ const RECORDED_EM = 0.01;
47
+
48
+ /** The registry's view with faces added: what a registry holding `faces` resolves families to (`pickFace`, its own selection). */
49
+ function registryView({ faces, policy = "none", aliases = new Map(), fallbackFamily }) {
50
+ const pool = [...faces].map(face => ({ ...face, familyGroup: face.familyGroup ?? face.family }));
51
+ return style => {
52
+ const family = style?.fontFamily;
53
+ const { face } = pickFace(pool, family, style, { policy, aliases, fallbackFamily });
54
+ return { ...style, fontFamily: face.family, fontWeight: face.weight, italic: face.italic };
55
+ };
56
+ }
57
+
58
+ /**
59
+ * The faces a preview of the presentation draws, as `[{family, weight, italic}]`, sorted by family, style and weight. It is
60
+ * the renderer's own layout and painting run with a recording measurement, so it follows exactly what `renderSvg` measures
61
+ * and draws: the heading and body roles of every slide that draws them, bold and italic runs of rich text, table and cell
62
+ * styles, header and footer furniture, chart labels, metrics, quotes and timelines, the code role only where code is
63
+ * drawn, per-slide design overrides, font scheme and theme resolution, and script slots (a script run is requested in its
64
+ * slot's family). A role the document does not draw is not requested.
65
+ *
66
+ * Without `registry` the faces are the styles as the document names them: the family a role resolves to, with the weight
67
+ * a use asks for (700 for headings and bold runs, 400 for body, 500 to 800 where a design asks).
68
+ *
69
+ * With `registry` (`{faces, policy, aliases, fallbackFamily}`: the faces a registry holds or could hold, `{family, weight,
70
+ * italic}`, and its substitution options) every style resolves as that registry resolves it (`pickFace`, the registry's own
71
+ * selection: the family or its alias or policy replacement, italic before weight, the nearest weight, ties to the
72
+ * lighter), in the two steps drawing takes: a role's family resolves first, then each use's weight resolves within the
73
+ * family found. The result is the faces the registry paints, for example `Intos 700` for a 600-weight label of an Aptos
74
+ * deck. A style the registry cannot resolve is left as named (drawing reports it).
75
+ *
76
+ * `options` are the `renderSvg` options the document resolves with (`catalogs`, `date`, `validate`, ...). A document that
77
+ * does not resolve throws what `renderSvg` throws for it.
78
+ * @param {unknown} presentation
79
+ * @param {object} [options]
80
+ * @param {{faces: Iterable<{family: string, weight: number, italic: boolean}>, policy?: "none"|"metric"|"visual", aliases?: ReadonlyMap<string, string>, fallbackFamily?: string}} [registry]
81
+ * @returns {{family: string, weight: number, italic: boolean}[]}
82
+ */
83
+ export function presentationFaces(presentation, options = {}, registry) {
84
+ const seen = new Map();
85
+ const resolve = registry ? registryView(registry) : undefined;
86
+ const settle = style => {
87
+ if (!resolve) return { ...style };
88
+ try { return resolve(style); } catch { return { ...style }; }
89
+ };
90
+ const record = style => {
91
+ const family = style?.fontFamily;
92
+ if (typeof family !== "string" || !family.trim() || family.startsWith("+")) return;
93
+ const weight = Number.isFinite(style.fontWeight) ? style.fontWeight : 400, italic = style.italic === true;
94
+ const key = `${lc(family)}\u0000${weight}\u0000${italic}`;
95
+ if (!seen.has(key)) seen.set(key, { family, weight, italic });
96
+ };
97
+ const textMeasurement = {
98
+ measure(text, size, style) { if (text !== "") record(settle(style)); return String(text).length * size * RECORDED_EM; },
99
+ resolveStyle: settle,
100
+ };
101
+ renderSvgDeck(presentation, { ...resolveOptionsOf(options), textMeasurement });
102
+ return [...seen.values()].sort((a, b) => a.family < b.family ? -1 : a.family > b.family ? 1 : Number(a.italic) - Number(b.italic) || a.weight - b.weight);
103
+ }
104
+
105
+ /**
106
+ * The vendored faces (of `lazy`) a presentation draws that the registry does not hold: what a registry loads before it can
107
+ * paint the document as Node paints it with every vendored face loaded. `held` lists the registry's faces
108
+ * (`registry.describeFaces()`), `loaded` the lazy files already loaded; `policy`, `aliases` and `fallbackFamily` are the
109
+ * registry's. `renderOptions` are the `renderSvg` options the document resolves with (`catalogs`, ...), and a document that
110
+ * does not resolve throws what `renderSvg` throws for it. Face level: a bold or italic run added by an edit adds just that face.
111
+ */
112
+ export function lazyFacesNeeded(presentation, renderOptions, { lazy, held, loaded = new Set(), policy = "none", aliases = new Map(), fallbackFamily }) {
113
+ const heldFaces = [...held], heldKeys = new Set(heldFaces.map(faceKeyOf));
114
+ const missing = lazy.filter(face => !heldKeys.has(faceKeyOf(face)) && !loaded.has(face.file));
115
+ const drawn = new Set(presentationFaces(presentation, renderOptions, { faces: [...heldFaces, ...missing], policy, aliases, fallbackFamily }).map(faceKeyOf));
116
+ return missing.filter(face => drawn.has(faceKeyOf(face)));
117
+ }
118
+ function faceKeyOf(face) { return `${lc(face.family)}\u0000${face.weight}\u0000${!!face.italic}`; }
119
+
22
120
  /**
23
121
  * The vendored faces a document needs that the registry does not hold, following the registry's own order and substitution
24
122
  * policy: the family itself, an alias target, then (unless the policy is "none") the declared replacement and, under
25
- * "visual", its alternates. The first candidate the registry already has ends the search (it will resolve to that); the
26
- * first vendored candidate is needed, whole family, so the browser resolves exactly what Node resolves with everything
27
- * loaded, and nothing is downloaded for a family the policy would not resolve.
28
- * @param {Iterable<string>} families families the document resolves
29
- * @param {{lazy: readonly object[], hasFamily: (family: string) => boolean, loaded?: ReadonlySet<string>, policy?: "none"|"metric"|"visual", aliases?: ReadonlyMap<string, string>}} context
123
+ * "visual", its alternates.
124
+ *
125
+ * Entries are face requests `{family, weight, italic}` (from `presentationFaces`) or family names. A face request is
126
+ * resolved exactly as the registry resolves it with every vendored face loaded (`pickFace`, the registry's own selection,
127
+ * over the faces it holds plus the vendored ones): the vendored face it lands on is needed if the registry does not hold
128
+ * it, so a partially loaded family still loads its missing faces, and a face is never fetched for a weight or style the
129
+ * document does not draw. A family name means every face of the family the route resolves to (the whole family, as
130
+ * before FF-41). Nothing is downloaded for a family the policy would not resolve.
131
+ * @param {Iterable<string | {family: string, weight?: number, italic?: boolean}>} entries
132
+ * @param {{lazy: readonly object[], held?: Iterable<{family: string, weight: number, italic: boolean}>, hasFamily?: (family: string) => boolean, loaded?: ReadonlySet<string>, policy?: "none"|"metric"|"visual", aliases?: ReadonlyMap<string, string>, fallbackFamily?: string}} context
133
+ * `held` lists the registry's faces (`registry.describeFaces()`); it is what makes the check face level. Without it a face
134
+ * request falls back to family level with `hasFamily` (a held family needs nothing).
30
135
  */
31
- export function lazyFontsFor(families, { lazy, hasFamily, loaded = new Set(), policy = "visual", aliases = new Map() }) {
136
+ export function lazyFontsFor(entries, { lazy, held, hasFamily, loaded = new Set(), policy = "visual", aliases = new Map(), fallbackFamily }) {
32
137
  const byFamily = new Map();
33
138
  for (const face of lazy) { const list = byFamily.get(lc(face.family)) ?? []; list.push(face); byFamily.set(lc(face.family), list); }
34
- const needed = new Set();
35
- for (const family of families) {
36
- if (hasFamily(family)) continue;
37
- if (byFamily.has(lc(family))) { needed.add(lc(family)); continue; }
139
+ const heldFaces = held === undefined ? undefined : [...held];
140
+ const faceKey = face => `${lc(face.family)}\u0000${face.weight}\u0000${!!face.italic}`;
141
+ const heldKeys = new Set((heldFaces ?? []).map(faceKey));
142
+ const has = hasFamily ?? (family => (heldFaces ?? []).some(face => lc(face.family) === lc(family)));
143
+ const needed = new Set(), neededFiles = new Set();
144
+ // Whole families (a family name, or a face request without a face-level registry view).
145
+ const addFamily = family => {
146
+ // A family the registry holds only in part still needs its other vendored faces (face level, when the registry's faces are known).
147
+ if (has(family)) { if (heldFaces && byFamily.has(lc(family))) needed.add(lc(family)); return; }
148
+ if (byFamily.has(lc(family))) { needed.add(lc(family)); return; }
38
149
  const target = aliases.get(lc(family));
39
- if (target && (hasFamily(target) || byFamily.has(lc(target)))) { if (!hasFamily(target)) needed.add(lc(target)); continue; }
40
- if (policy === "none") continue;
150
+ if (target && (has(target) || byFamily.has(lc(target)))) { if (byFamily.has(lc(target)) && (!has(target) || heldFaces)) needed.add(lc(target)); return; }
151
+ if (policy === "none") return;
41
152
  const row = fontPolicyFor(family), replacement = row?.replacement;
42
153
  const route = [];
43
154
  // Metric mode uses a metric replacement, or a visual one its policy row keeps as a metric-mode fallback; alternates are visual only.
44
155
  if (replacement && (policy === "visual" || replacement.compatibility === "metric" || replacement.metricModeFallback)) route.push(replacement.family);
45
156
  if (policy === "visual") route.push(...(row?.alternates ?? []));
46
157
  for (const candidate of route) {
47
- if (hasFamily(candidate)) break;
158
+ if (has(candidate)) { if (heldFaces && byFamily.has(lc(candidate))) needed.add(lc(candidate)); break; }
48
159
  if (byFamily.has(lc(candidate))) { needed.add(lc(candidate)); break; }
49
160
  }
161
+ };
162
+ // The registry's view with every vendored face added: what Node draws with everything loaded.
163
+ let pool;
164
+ const universe = () => pool ??= [
165
+ ...heldFaces.map(face => ({ ...face, familyGroup: face.family })),
166
+ ...lazy.filter(face => !heldKeys.has(faceKey(face)) && !loaded.has(face.file)).map(face => ({ family: face.family, familyGroup: face.family, weight: face.weight, italic: !!face.italic, lazy: face })),
167
+ ];
168
+ for (const entry of entries) {
169
+ if (typeof entry === "string") { addFamily(entry); continue; }
170
+ if (heldFaces === undefined) { addFamily(entry.family); continue; }
171
+ let picked;
172
+ try { picked = pickFace(universe(), entry.family, { fontFamily: entry.family, fontWeight: entry.weight ?? 400, italic: entry.italic === true }, { policy, aliases, fallbackFamily }); }
173
+ // A face the registry cannot resolve even with every vendored face (unknown family, symbol font, ...) needs nothing here: drawing reports it.
174
+ catch { continue; }
175
+ if (picked.face.lazy) neededFiles.add(picked.face.lazy.file);
50
176
  }
51
- return [...needed].flatMap(family => byFamily.get(family)).filter(face => !loaded.has(face.file));
177
+ const all = [...needed].flatMap(family => byFamily.get(family) ?? []);
178
+ for (const face of lazy) if (neededFiles.has(face.file)) all.push(face);
179
+ return [...new Set(all)].filter(face => !loaded.has(face.file) && !heldKeys.has(faceKey(face)));
52
180
  }
@@ -7,6 +7,7 @@ import * as scriptFontModule from "./script-fonts.js";
7
7
  import { scriptOfCharacter, scriptsOfText } from "./script-fonts.js";
8
8
  // Optional core exports are read from the namespace so an older published core still loads.
9
9
  import * as opfCore from "@openpresentation/opf";
10
+ import { presentationFamilies } from "./lazy-fonts.js";
10
11
 
11
12
  const SCRIPT_ALIASES = Object.freeze({Hira: "Jpan", Kana: "Jpan", Hrkt: "Jpan", Hang: "Kore", Hani: "Hans", Zyyy: "Latn"});
12
13
 
@@ -121,8 +122,12 @@ function scriptOfFamily(family) {
121
122
  * open replacement, so the face is needed even when no drawn character has the script. A slide may override the
122
123
  * scheme (`slide.design`), so each such slide is resolved too. `han` is the CJK script the scheme names, which tells
123
124
  * Han-only text apart when the language does not (a Yu Gothic deck whose language is English).
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.
124
129
  */
125
- function designScripts(presentation, profile) {
130
+ function designScripts(presentation, profile, renderOptions) {
126
131
  const scripts = new Set();
127
132
  let han;
128
133
  const note = resolved => {
@@ -140,6 +145,9 @@ function designScripts(presentation, profile) {
140
145
  try { note(opfCore.resolveScriptFonts(presentation, { slideIndex })); } catch { /* the renderer reports it when it draws the slide */ }
141
146
  });
142
147
  }
148
+ if (renderOptions?.catalogs) {
149
+ for (const family of presentationFamilies(presentation, renderOptions)) note({ heading: { latin: family }, body: { latin: family } });
150
+ }
143
151
  return { scripts: [...scripts].sort(), han };
144
152
  }
145
153
 
@@ -147,10 +155,13 @@ function designScripts(presentation, profile) {
147
155
  * What a preview of the presentation draws: its script profile, the scripts of its text (the renderer's own
148
156
  * itemization, so language-dependent punctuation counts), the scripts its font schemes need (`design`, for example
149
157
  * Jpan for a Yu Gothic scheme) and the CJK characters that a glyph fallback might have to draw with another CJK face.
158
+ * The text decides, so a document is analyzed without resolving its layouts and catalogs; `renderOptions` (the host's
159
+ * `renderSvg` options) matter only for font schemes the host supplies in `renderOptions.catalogs`, which then resolve
160
+ * like the renderer resolves them; a document that then does not resolve throws what `renderSvg` throws for it.
150
161
  */
151
- export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation)) {
162
+ export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation), renderOptions) {
152
163
  const scripts = new Set(), cjk = new Set();
153
- const design = designScripts(presentation, profile);
164
+ const design = designScripts(presentation, profile, renderOptions);
154
165
  // Han-only text in a deck whose language is not CJK draws with the scheme's own CJK face when the scheme names one.
155
166
  const itemizing = design.han && !cjkKeys.includes(profile?.script) ? { ...profile, script: design.han } : profile;
156
167
  for (const text of drawnStrings(presentation)) {
@@ -166,10 +177,10 @@ export function analyzePresentationScripts(presentation, profile = presentationS
166
177
  * (Japanese, Korean, Simplified or Traditional Chinese) and makes curly quotes, dashes and the ellipsis
167
178
  * East Asian. Text the preview never draws (ids, alt text, sources, assets, catalogs, notes) is ignored.
168
179
  * Greek and Cyrillic add nothing: the office registry always carries Noto Sans (the glyph-fallback face) and Roboto covers them.
169
- * Pass `profile` to reuse a resolved profile.
180
+ * Pass `profile` to reuse a resolved profile. Other options are the host's `renderSvg` options (`catalogs`, see `analyzePresentationScripts`).
170
181
  */
171
- export function detectPresentationScripts(presentation, { profile } = {}) {
172
- return analyzePresentationScripts(presentation, profile).detected;
182
+ export function detectPresentationScripts(presentation, { profile, ...renderOptions } = {}) {
183
+ return analyzePresentationScripts(presentation, profile, renderOptions).detected;
173
184
  }
174
185
 
175
186
  /** The scripts of an analysis that a pinned pack serves (`scripts`, the input for `scriptFontPackages`) and those it does not. */
@@ -186,7 +197,8 @@ export function scriptSelectionOf(analysis) {
186
197
  * Cherokee or Tifinagh, which draw with whatever the design font covers).
187
198
  */
188
199
  export function autoScriptSelection(presentation, options) {
189
- return scriptSelectionOf(analyzePresentationScripts(presentation, options?.profile));
200
+ const { profile, ...renderOptions } = options ?? {};
201
+ return scriptSelectionOf(analyzePresentationScripts(presentation, profile, renderOptions));
190
202
  }
191
203
 
192
204
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.11.4",
3
+ "version": "0.11.5",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,9 +56,9 @@
56
56
  "sideEffects": false,
57
57
  "scripts": {
58
58
  "build": "node scripts/build.mjs",
59
- "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/script-fonts.js && node --check src/script-font-pack.js && node --check test/script-fonts.mjs && node --check test/glyph-fallback.mjs && node --check test/script-fonts-browser.mjs && node --check test/script-fonts-auto.mjs && node --check test/script-fonts-auto-robust.mjs && node --check test/mongolian-shaping.mjs && node --check test/script-fonts-auto-browser.mjs && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check test/font-policy.mjs && node --check test/open-fonts.mjs && node --check test/aptos-preview.mjs && node --check test/aptos-preview-browser.mjs && node --check test/lazy-fonts.mjs && node --check test/lazy-fonts-browser.mjs && node --check test/lazy-face-fallback.mjs && node --check test/lazy-face-fallback-browser.mjs && node --check src/font-policy.js && node --check scripts/update-font-policy.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js && node --check test/rich-table.mjs && node --check test/rich-tab.mjs && node --check test/rich-tab-browser.mjs && node --check test/rich-tab-estimated-browser.mjs && node --check test/styled-table.mjs && node --check test/color-references.mjs && node --check test/default-font-scheme.mjs && node --check test/chart-colors.mjs && node --check test/chart-axis.mjs && node --check src/charts.js && node --check test/chart-types.mjs && node --check test/chart-scale.mjs && node --check test/chart-catalog.mjs && node --check test/image-placeholders.mjs && node --check test/image-placeholders-browser.mjs && node --check test/accepted-text.mjs && node --check test/accepted-text-fixtures.mjs && node --check test/accepted-text-browser.mjs && node --check test/rich-spacing-browser.mjs && node --check test/rich-flow-browser.mjs && node --check test/rich-flow-review-sheets.mjs && node --check test/furniture.mjs && node --check test/socials.mjs && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check test/item-alignment.mjs && node --check test/cover-centering.mjs && node --check scripts/font-license.mjs && node --check test/font-licenses.mjs && node --check scripts/check-font-hotlinks.mjs && node --check scripts/check-font-hotlinks.test.mjs",
59
+ "typecheck": "node --check src/index.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check test/golden.mjs && node --check src/fonts.js && node --check src/script-fonts.js && node --check src/script-font-pack.js && node --check test/script-fonts.mjs && node --check test/glyph-fallback.mjs && node --check test/script-fonts-browser.mjs && node --check test/script-fonts-auto.mjs && node --check test/script-fonts-auto-robust.mjs && node --check test/mongolian-shaping.mjs && node --check test/script-fonts-auto-browser.mjs && node --check src/fonts-node.js && node --check src/font-compatibility.js && node --check test/office-fonts.mjs && node --check test/font-policy.mjs && node --check test/open-fonts.mjs && node --check test/aptos-preview.mjs && node --check test/aptos-preview-browser.mjs && node --check test/lazy-fonts.mjs && node --check test/lazy-faces.mjs && node --check test/lazy-fonts-browser.mjs && node --check test/lazy-face-fallback.mjs && node --check test/lazy-face-fallback-browser.mjs && node --check src/font-policy.js && node --check scripts/update-font-policy.mjs && node --check src/fonts-browser.js && node --check test/fonts-browser.mjs && node --check src/svg.js && node --check src/raster.js && node --check src/raster-images.js && node --check test/webp.mjs && node --check test/jpeg-orientation.mjs && node --check scripts/build-browser-check.mjs && node --check test/jpeg-browser.js && node --check test/rich-table.mjs && node --check test/rich-tab.mjs && node --check test/rich-tab-browser.mjs && node --check test/rich-tab-estimated-browser.mjs && node --check test/styled-table.mjs && node --check test/color-references.mjs && node --check test/default-font-scheme.mjs && node --check test/chart-colors.mjs && node --check test/chart-axis.mjs && node --check src/charts.js && node --check test/chart-types.mjs && node --check test/chart-scale.mjs && node --check test/chart-catalog.mjs && node --check test/image-placeholders.mjs && node --check test/image-placeholders-browser.mjs && node --check test/accepted-text.mjs && node --check test/accepted-text-fixtures.mjs && node --check test/accepted-text-browser.mjs && node --check test/rich-spacing-browser.mjs && node --check test/rich-flow-browser.mjs && node --check test/rich-flow-review-sheets.mjs && node --check test/furniture.mjs && node --check test/socials.mjs && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check test/item-alignment.mjs && node --check test/cover-centering.mjs && node --check scripts/font-license.mjs && node --check test/font-licenses.mjs && node --check scripts/check-font-hotlinks.mjs && node --check scripts/check-font-hotlinks.test.mjs && node --check test/gallery-layout-alignment.mjs",
60
60
  "validate": "node scripts/validate-package.mjs",
61
- "test": "npm run build && npm run check:fonts && node test/furniture.mjs && node test/socials.mjs && node test/font-outlines.mjs && node test/accepted-text.mjs && node test/image-placeholders.mjs && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/chart-colors.mjs && node test/chart-axis.mjs && node test/chart-types.mjs && node test/chart-scale.mjs && node test/chart-catalog.mjs && node test/smoke.mjs && node test/webp.mjs && node test/jpeg-orientation.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/font-policy.mjs && node test/open-fonts.mjs && node test/aptos-preview.mjs && node test/lazy-fonts.mjs && node test/lazy-face-fallback.mjs && node test/timeline.mjs && node test/quote-footer.mjs && node test/shared-quote.mjs && node test/rich-trace.mjs && node test/rich-table.mjs && node test/rich-tab.mjs && node test/color-references.mjs && node test/default-font-scheme.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/script-fonts.mjs && node test/glyph-fallback.mjs && node test/script-fonts-auto.mjs && node test/script-fonts-auto-robust.mjs && node test/mongolian-shaping.mjs && node test/design-preview.mjs && node test/item-alignment.mjs && node test/cover-centering.mjs && npm run build:browser-check && npm run test:styled-table",
61
+ "test": "npm run build && npm run check:fonts && node test/furniture.mjs && node test/socials.mjs && node test/font-outlines.mjs && node test/accepted-text.mjs && node test/image-placeholders.mjs && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/chart-colors.mjs && node test/chart-axis.mjs && node test/chart-types.mjs && node test/chart-scale.mjs && node test/chart-catalog.mjs && node test/smoke.mjs && node test/webp.mjs && node test/jpeg-orientation.mjs && node test/fonts.mjs && node test/office-fonts.mjs && node test/font-policy.mjs && node test/open-fonts.mjs && node test/aptos-preview.mjs && node test/lazy-fonts.mjs && node test/lazy-faces.mjs && node test/lazy-face-fallback.mjs && node test/timeline.mjs && node test/quote-footer.mjs && node test/shared-quote.mjs && node test/rich-trace.mjs && node test/rich-table.mjs && node test/rich-tab.mjs && node test/color-references.mjs && node test/default-font-scheme.mjs && node test/golden-gate.mjs && node test/golden.mjs && node test/fonts-browser.mjs && node test/script-fonts.mjs && node test/glyph-fallback.mjs && node test/script-fonts-auto.mjs && node test/script-fonts-auto-robust.mjs && node test/mongolian-shaping.mjs && node test/design-preview.mjs && node test/item-alignment.mjs && node test/gallery-layout-alignment.mjs && node test/cover-centering.mjs && npm run build:browser-check && npm run test:styled-table",
62
62
  "check:fonts": "node test/font-licenses.mjs && npm run check:font-hotlinks",
63
63
  "check:font-hotlinks": "node --test scripts/check-font-hotlinks.test.mjs && node scripts/check-font-hotlinks.mjs",
64
64
  "test:golden": "npm run build && node test/golden.mjs",