@openpresentation/opf-render 0.28.0 → 0.29.1

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.
@@ -73,6 +73,12 @@ export interface BrowserFontsHandle extends FontsHandle {
73
73
  ensure(presentation: unknown, options?: { signal?: AbortSignal } & Partial<ToSvgOptions>): Promise<EnsureResult>;
74
74
  /** Synchronous: the vendored files and script packages `ensure` would fetch. Empty means a render can start now. Throws what `toSvg` throws for a document that does not resolve. */
75
75
  pending(presentation: unknown, renderOptions?: Partial<ToSvgOptions>): string[];
76
+ /**
77
+ * Counters of the analysis behind `pending` and `ensure`, for tests and benchmarks: `decks` opened (resolved and checked),
78
+ * `slides` composed and drawn to find their faces, `designs` resolved for their families, and `strings` scanned for scripts.
79
+ * The analysis keeps what it found, so asking again about the same deck adds nothing and an edit adds only the changed slides.
80
+ */
81
+ readonly analysisStats: { decks: number; slides: number; designs: number; strings: number };
76
82
  }
77
83
  export declare function loadFonts(
78
84
  options?: FontRegistryOptions & {
@@ -1,11 +1,11 @@
1
1
  import { createFontRegistry, OPFFontError } from "./font-registry.js";
2
2
  import { lazyFontEntries, lazyFontList, normalizeExtraLazyFonts } from "./lazy-font-list.js";
3
- import { lazyFacesNeeded } from "./lazy-fonts.js";
3
+ import { createFontAnalysis, lazyFacesNeeded } from "./lazy-fonts.js";
4
4
  import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
5
5
  import { textOutlines } from "./text-paths.js";
6
6
  import { createSubsetter, fontSubsets } from "./font-subset.js";
7
7
  import { instantiateShaper } from "./hb-shape.js";
8
- import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
8
+ import { analyzePresentationScripts, createScriptAnalysis, nextFallbackPackage, scriptFontPackages, scriptPackageEntries, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
9
9
  export { autoScriptSelection, detectPresentationScripts, scriptFontEntries, scriptFontPackages } from "./script-font-pack.js";
10
10
  export { lazyFontEntries, lazyFontList, splitStartupFaces } from "./lazy-font-list.js";
11
11
  export { lazyFacesNeeded, lazyFontsFor, presentationFaces, presentationFamilies } from "./lazy-fonts.js";
@@ -102,6 +102,10 @@ async function loadRegistry(entries, options = {}, startup = {}) {
102
102
  };
103
103
  await registerBrowserFaces(faces, described);
104
104
 
105
+ // `pending` and `ensure` run before every render and edit, so the analyses keep what they found (the 0.28.0 startup regression):
106
+ // each slide's faces and families are analyzed again only when the slide changes, each drawn string is scanned once.
107
+ const fontAnalysis = createFontAnalysis(), scriptMemory = createScriptAnalysis({ fonts: fontAnalysis });
108
+ Object.defineProperty(registry, "analysisStats", { get: () => ({ ...fontAnalysis.stats, ...scriptMemory.stats }), enumerable: false });
105
109
  // Script faces (FF-19) are loaded lazily, once a document needs them, and only for the scripts it uses.
106
110
  const scriptPackages = new Set();
107
111
  let queue = Promise.resolve();
@@ -167,7 +171,7 @@ async function loadRegistry(entries, options = {}, startup = {}) {
167
171
  */
168
172
  async ensureScripts(presentation, callOptions) {
169
173
  if (disposed) throw gone();
170
- const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(callOptions)), selection = scriptSelectionOf(analysis);
174
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(callOptions), scriptMemory), selection = scriptSelectionOf(analysis);
171
175
  const loadedNow = [], failed = [];
172
176
  const attempt = async (item) => {
173
177
  try { loadedNow.push(...(await loadPackageList([item], callOptions))); }
@@ -186,7 +190,7 @@ async function loadRegistry(entries, options = {}, startup = {}) {
186
190
  },
187
191
  /** Synchronous: script-pack packages the presentation needs that are not loaded yet. Empty means `ensureScripts` would fetch nothing. `renderOptions` are the `toSvg` options the document resolves with (`catalogs`, ...). */
188
192
  pendingScripts(presentation, renderOptions) {
189
- const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(renderOptions));
193
+ const analysis = analyzePresentationScripts(presentation, undefined, renderOptionsFor(renderOptions), scriptMemory);
190
194
  const primary = scriptFontPackages(scriptSelectionOf(analysis).scripts).map((item) => item.name).filter((name) => !scriptPackages.has(name));
191
195
  if (primary.length) return primary;
192
196
  const next = nextFallbackPackage(analysis, { covers, loaded: scriptPackages });
@@ -203,7 +207,7 @@ async function loadRegistry(entries, options = {}, startup = {}) {
203
207
  const aliasTargets = new Map(Object.entries(options.aliases ?? {}).map(([from, to]) => [from.toLowerCase(), to]));
204
208
  // Face level (FF-41): the faces the document draws, resolved as the registry resolves them with every vendored face loaded.
205
209
  // A document that does not resolve throws what `toSvg` throws for it.
206
- const neededLazy = (presentation, callOptions) => lazyFacesNeeded(presentation, renderOptionsFor(callOptions), { lazy, held: registry.describeFaces(), loaded: lazyLoaded, policy, aliases: aliasTargets, fallbackFamily: options.fallbackFamily });
210
+ const neededLazy = (presentation, callOptions) => lazyFacesNeeded(presentation, renderOptionsFor(callOptions), { lazy, held: registry.describeFaces(), loaded: lazyLoaded, policy, aliases: aliasTargets, fallbackFamily: options.fallbackFamily, analysis: fontAnalysis });
207
211
  const lazyBaseUrl = () => {
208
212
  if (typeof options.lazyFontsBaseUrl !== "string" || !options.lazyFontsBaseUrl)
209
213
  throw new OPFFontError("invalid-font-source", "Loading vendored fonts requires lazyFontsBaseUrl, where the host serves the package's fonts directory.");
@@ -360,6 +364,8 @@ export async function loadFonts({ faces = [], subsetWasm, shapeWasm, ...options
360
364
  },
361
365
  /** Synchronous: the files and script packages `ensure` would fetch. Empty means a render can start now. Throws what `toSvg` throws for a document that does not resolve. */
362
366
  pending,
367
+ /** Counters of the font analysis behind `pending` and `ensure` (slides analyzed, strings scanned), for tests and benchmarks. */
368
+ get analysisStats() { return registry.analysisStats; },
363
369
  dispose: () => registry.dispose(),
364
370
  };
365
371
  }
@@ -9,7 +9,7 @@ import { createSubsetter, fontSubsets } from "./font-subset.js";
9
9
  import { createShaper } from "./hb-shape.js";
10
10
  import { packageRoot, resolveInstalled } from "./node-resolve.js";
11
11
  export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
12
- import { analyzePresentationScripts, autoScriptSelection, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
12
+ import { analyzePresentationScripts, autoScriptSelection, createScriptAnalysis, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
13
13
  export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
14
14
  // Script faces are embedded in a standalone SVG only when the slide's text uses their family.
15
15
  const embedUsed = entries => entries.map(entry => ({...entry, embed: "used"}));
@@ -286,6 +286,8 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
286
286
  // Calls mutate one registry: serialize their reads/additions, while returning each
287
287
  // call's own result or error. A rejection must not poison later preparation.
288
288
  let ensureQueue = Promise.resolve();
289
+ // `pending` is asked before every render: each string is scanned once and each slide's families resolved once (see createScriptAnalysis).
290
+ const scriptMemory = createScriptAnalysis();
289
291
  const refresh = () => { embeddedFonts = undefined; fontFiles = [...registry.fontFiles]; };
290
292
  const [subsetter, shaper] = await Promise.all([nodeHarfbuzz("subsets"), nodeHarfbuzz("shaper")]);
291
293
  for (const [kind, engine] of [["subsets", subsetter], ["shaper", shaper]]) if (!engine) {
@@ -318,7 +320,7 @@ export async function loadFonts({pack = "base", embedScriptFonts = false, ...opt
318
320
  /** Synchronous: the script packages the presentation needs that are not loaded yet. Empty means `ensure` loads nothing. */
319
321
  pending(presentation, renderOptions) {
320
322
  const held = new Set([...loaded.packages, ...(loaded.selection?.notInstalled ?? [])]);
321
- const analysis = analyzePresentationScripts(presentation, undefined, {...options.renderOptions, ...renderOptions});
323
+ const analysis = analyzePresentationScripts(presentation, undefined, {...options.renderOptions, ...renderOptions}, scriptMemory);
322
324
  const primary = scriptFontPackages(scriptSelectionOf(analysis).scripts).map(pkg => pkg.name).filter(name => !held.has(name));
323
325
  if (primary.length) return primary;
324
326
  const next = nextFallbackPackage(analysis, {covers: character => registry.scriptFacesCover(character), loaded: held});
package/dist/index.d.ts CHANGED
@@ -295,7 +295,11 @@ export declare class OPFRenderError extends Error {
295
295
  constructor(code: string, message: string, details?: Record<string, unknown>);
296
296
  }
297
297
 
298
- export declare function resolvePresentation(input: unknown, options?: ToSvgOptions): ResolvedPresentation;
298
+ /**
299
+ * Resolve a presentation for drawing. `select.slides` (0-based) binds only those slides (each slide is composed on its own); the other entries
300
+ * of `slides` are then undefined. Without it every slide is bound.
301
+ */
302
+ export declare function resolvePresentation(input: unknown, options?: ToSvgOptions, select?: { slides?: number[] }): ResolvedPresentation;
299
303
 
300
304
  /** An SVG slide the renderer drew: its text, or its bytes. */
301
305
  export type SvgSlide = string | Uint8Array;
@@ -1,6 +1,7 @@
1
1
  import { fontPolicyFor } from "@openpresentation/opf/font-policy";
2
2
  import { pickFace } from "./font-registry.js";
3
- import { resolvePresentation, toSvg } from "./svg.js";
3
+ import { toSvg } from "./svg.js";
4
+ import { deckInternals } from "./svg-internals.js";
4
5
  export { lazyFontEntries, lazyFontList } from "./lazy-font-list.js";
5
6
 
6
7
  /**
@@ -35,10 +36,7 @@ function resolveOptionsOf(options) {
35
36
  * `options.catalogs` needs those catalogs here too.
36
37
  */
37
38
  export function presentationFamilies(presentation, options = {}) {
38
- const families = new Set();
39
- const resolved = resolvePresentation(presentation, resolveOptionsOf(options));
40
- for (const slide of resolved.slides) for (const family of Object.values(slide.design?.fonts ?? {})) if (typeof family === "string" && family) families.add(family);
41
- return families;
39
+ return createFontAnalysis().families(presentation, options);
42
40
  }
43
41
 
44
42
  // A measurement that costs nothing to overflow: text is measured as very narrow, so layout never fails on text that
@@ -81,25 +79,206 @@ function registryView({ faces, policy = "none", aliases = new Map(), fallbackFam
81
79
  * @returns {{family: string, weight: number, italic: boolean}[]}
82
80
  */
83
81
  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 }; }
82
+ return createFontAnalysis().faces(presentation, options, registry);
83
+ }
84
+
85
+ const faceKey = (family, weight, italic) => `${lc(family)}\u0000${weight}\u0000${italic}`;
86
+ const byFace = (a, b) => a.family < b.family ? -1 : a.family > b.family ? 1 : Number(a.italic) - Number(b.italic) || a.weight - b.weight;
87
+
88
+ /**
89
+ * The measurement `presentationFaces` draws with: it records every style it is asked to measure (resolved through `settle`) into
90
+ * the map `record.into` holds (switch it to file what follows elsewhere).
91
+ */
92
+ function recordingMeasurement(settle, into) {
93
+ const record = { into };
94
+ record.textMeasurement = {
95
+ measure(text, size, style) {
96
+ if (text !== "") {
97
+ const settled = settle(style), family = settled?.fontFamily;
98
+ if (typeof family === "string" && family.trim() && !family.startsWith("+")) {
99
+ const weight = Number.isFinite(settled.fontWeight) ? settled.fontWeight : 400, italic = settled.italic === true;
100
+ const key = faceKey(family, weight, italic);
101
+ if (!record.into.has(key)) record.into.set(key, { family, weight, italic });
102
+ }
103
+ }
104
+ return String(text).length * size * RECORDED_EM;
105
+ },
106
+ resolveStyle: settle,
89
107
  };
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 });
108
+ return record;
109
+ }
110
+
111
+ // Stable short tokens for option values: objects by identity (a host passes the same catalog objects on every call; the editor
112
+ // freezes its catalog list), plain values and small plain objects by content.
113
+ const objectTokens = new WeakMap();
114
+ let nextObjectToken = 0;
115
+ function optionToken(value) {
116
+ if (value === null || typeof value !== "object" && typeof value !== "function") return JSON.stringify(value ?? null);
117
+ if (Array.isArray(value)) return `[${value.map(optionToken).join(",")}]`;
118
+ let token = objectTokens.get(value);
119
+ if (token === undefined) { token = `#${nextObjectToken++}`; objectTokens.set(value, token); }
120
+ return token;
121
+ }
122
+ // The options a deck resolves with, as a key. `variables` is left out where it cannot change the deck (the editor's views pass `variables:
123
+ // false` or nothing for the same plain deck, which must not analyze it twice).
124
+ function optionsKey(options, presentation) {
125
+ return Object.keys(options).sort().filter(key => key !== "variables" || !deckInternals.variablesIdle(presentation, options.variables))
126
+ .map(key => `${JSON.stringify(key)}:${optionToken(options[key])}`).join(",");
127
+ }
128
+
129
+ /**
130
+ * Content keys of a deck for the per-slide caches: `context` is everything a slide's drawing reads besides the slide itself (the deck
131
+ * without its slides, and every slide's section, which furniture can show on other slides; the rule the editor's thumbnail cache
132
+ * uses), `slides[i]` the slide with its number and the slide count. Keys are taken from the content on every call, never from object
133
+ * identity, so a host that changes a deck in place is analyzed right. Undefined for input that is not a plain deck object (JSON
134
+ * text, bytes, a deck without a slide list or one JSON cannot write): such input is analyzed whole, as `toSvg` draws it.
135
+ */
136
+ export function deckKeys(presentation) {
137
+ if (presentation === null || typeof presentation !== "object" || ArrayBuffer.isView(presentation) || !Array.isArray(presentation.slides)) return undefined;
138
+ try {
139
+ const { slides, ...metadata } = presentation;
140
+ return {
141
+ context: JSON.stringify([metadata, slides.map(slide => slide?.section ?? null)]),
142
+ slides: slides.map((slide, index) => `${index}/${slides.length}/${JSON.stringify(slide) ?? ""}`),
143
+ };
144
+ } catch { return undefined; }
145
+ }
146
+
147
+ /**
148
+ * Per-slide results kept between calls: a few contexts (least recently used first out) and, in each, the slides most recently asked
149
+ * for, up to twice the deck's slides (a host that alternates a document and a draft of it keeps both).
150
+ */
151
+ function slideMemory(contexts = 8) {
152
+ const byContext = new Map();
153
+ return (context, slides) => {
154
+ let entries = byContext.get(context);
155
+ if (entries) byContext.delete(context);
156
+ else entries = new Map();
157
+ byContext.set(context, entries);
158
+ while (byContext.size > contexts) byContext.delete(byContext.keys().next().value);
159
+ const limit = 2 * slides + 16;
160
+ return {
161
+ get(key) {
162
+ const value = entries.get(key);
163
+ if (value !== undefined) { entries.delete(key); entries.set(key, value); }
164
+ return value;
165
+ },
166
+ set(key, value) {
167
+ entries.set(key, value);
168
+ while (entries.size > limit) entries.delete(entries.keys().next().value);
169
+ },
170
+ };
96
171
  };
97
- const textMeasurement = {
98
- measure(text, size, style) { if (text !== "") record(settle(style)); return String(text).length * size * RECORDED_EM; },
99
- resolveStyle: settle,
172
+ }
173
+
174
+ /**
175
+ * `presentationFaces` and `presentationFamilies` with memory, for a fonts handle that is asked again and again (FF-41; the 0.28.0
176
+ * startup regression). Each slide's result is kept by the slide's content, and a call analyzes only the slides whose content,
177
+ * number or slide count changed: `faces` composes and draws just those slides with the recording measurement, `families` resolves
178
+ * just their designs. Everything else a slide's result depends on (the rest of the deck, every slide's section, the render
179
+ * options and, for faces, the registry view) is the cache's context: a change there analyzes every slide again. The results are
180
+ * the ones `presentationFaces` and `presentationFamilies` give, errors included: a deck not checked before is opened (resolved and
181
+ * checked) even when every slide is known, and a slide that fails throws what `toSvg` throws for it (only slides that succeed are
182
+ * kept).
183
+ *
184
+ * `stats` counts the work, for tests and benchmarks: `decks` (decks opened), `slides` (slides composed and drawn for faces) and
185
+ * `designs` (slide designs resolved for families).
186
+ */
187
+ export function createFontAnalysis() {
188
+ const memory = slideMemory(), stats = { decks: 0, slides: 0, designs: 0 }, checked = new Set();
189
+ // The deck behind `keys`, opened (resolved and checked as `toSvg` does) when work is needed or this exact deck was never checked.
190
+ const opener = (presentation, resolveOptions, keys, optionsToken) => {
191
+ const whole = `${optionsToken}\u0001${keys.context}\u0001${keys.slides.join("\u0001")}`;
192
+ let deck;
193
+ const open = () => {
194
+ if (!deck) {
195
+ deck = deckInternals.openDeck(presentation, resolveOptions);
196
+ stats.decks++;
197
+ checked.delete(whole);
198
+ checked.add(whole);
199
+ while (checked.size > 8) checked.delete(checked.keys().next().value);
200
+ }
201
+ return deck;
202
+ };
203
+ return { open, check: () => { if (!checked.has(whole)) open(); } };
100
204
  };
101
- toSvg(presentation, { ...resolveOptionsOf(options), fonts: { 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);
205
+
206
+ function faces(presentation, options = {}, registry) {
207
+ const resolveOptions = resolveOptionsOf(options);
208
+ if (registry) registry = { ...registry, faces: [...registry.faces] };
209
+ const view = registry ? registryView(registry) : undefined;
210
+ const settle = style => {
211
+ if (!view) return { ...style };
212
+ try { return view(style); } catch { return { ...style }; }
213
+ };
214
+ const keys = deckKeys(presentation);
215
+ if (!keys) {
216
+ // Not a deck object: one whole-deck pass, kept nowhere.
217
+ const seen = new Map();
218
+ toSvg(presentation, { ...resolveOptions, fonts: { textMeasurement: recordingMeasurement(settle, seen).textMeasurement } });
219
+ stats.decks++;
220
+ return [...seen.values()].sort(byFace);
221
+ }
222
+ const optionsToken = optionsKey(resolveOptions, presentation);
223
+ const slides = memory(`faces\u0001${optionsToken}\u0001${registry ? registryKey(registry) : ""}\u0001${keys.context}`, keys.slides.length);
224
+ const deck = opener(presentation, resolveOptions, keys, optionsToken);
225
+ const entries = keys.slides.map(slides.get);
226
+ const missing = entries.flatMap((entry, index) => entry === undefined ? [index] : []);
227
+ if (missing.length) {
228
+ // Composition of the slides, then their drawing, as `toSvg` runs them.
229
+ const opened = deck.open();
230
+ const made = new Map(missing.map(index => [index, { composed: new Map(), drawn: new Map() }]));
231
+ const recorders = new Map(missing.map(index => [index, recordingMeasurement(settle, made.get(index).composed)]));
232
+ for (const index of missing) opened.bind(index, recorders.get(index).textMeasurement);
233
+ // A slide is drawn with the measurement it was composed with; what it measures from here on is its drawing. `skipHidden`
234
+ // leaves hidden slides undrawn (they are still composed), as in `toSvg`.
235
+ for (const index of missing) {
236
+ if (resolveOptions.skipHidden === true && opened.presentation.slides[index]?.hidden === true) continue;
237
+ recorders.get(index).into = made.get(index).drawn;
238
+ opened.render(index);
239
+ }
240
+ for (const [index, entry] of made) { slides.set(keys.slides[index], entry); entries[index] = entry; }
241
+ stats.slides += missing.length;
242
+ } else deck.check();
243
+ // In the order one `toSvg` records them: every slide's composition, then every slide's drawing.
244
+ const seen = new Map();
245
+ for (const phase of ["composed", "drawn"]) for (const entry of entries) for (const [key, face] of entry[phase]) if (!seen.has(key)) seen.set(key, face);
246
+ return [...seen.values()].sort(byFace).map(face => ({ ...face }));
247
+ }
248
+
249
+ function families(presentation, options = {}) {
250
+ const resolveOptions = resolveOptionsOf(options);
251
+ const familiesOf = design => Object.values(design?.fonts ?? {}).filter(family => typeof family === "string" && family);
252
+ const keys = deckKeys(presentation);
253
+ let entries;
254
+ if (!keys) {
255
+ const opened = deckInternals.openDeck(presentation, resolveOptions);
256
+ stats.decks++;
257
+ entries = opened.presentation.slides.map((_, index) => familiesOf(opened.design(index)));
258
+ stats.designs += entries.length;
259
+ } else {
260
+ const optionsToken = optionsKey(resolveOptions, presentation);
261
+ const slides = memory(`families\u0001${optionsToken}\u0001${keys.context}`, keys.slides.length);
262
+ const deck = opener(presentation, resolveOptions, keys, optionsToken);
263
+ entries = keys.slides.map(slides.get);
264
+ const missing = entries.flatMap((entry, index) => entry === undefined ? [index] : []);
265
+ if (missing.length) {
266
+ const opened = deck.open();
267
+ // The design as drawing binds it, without composing the slide (composing every slide with the default measurement made
268
+ // this the slowest step of a browser font check in 0.28.0).
269
+ for (const index of missing) { entries[index] = familiesOf(opened.design(index)); slides.set(keys.slides[index], entries[index]); }
270
+ stats.designs += missing.length;
271
+ } else deck.check();
272
+ }
273
+ return new Set(entries.flat());
274
+ }
275
+
276
+ return { faces, families, stats };
277
+ }
278
+
279
+ function registryKey({ faces, policy = "none", aliases = new Map(), fallbackFamily }) {
280
+ // Every field `pickFace` reads, as it reads them (`italic` is compared with ===, `fallbackOnly` and `scripts` by truth).
281
+ return JSON.stringify([faces.map(face => [face.family, face.familyGroup ?? face.family, face.weight ?? null, face.italic ?? null, !!face.fallbackOnly, !!face.scripts]), policy, [...aliases], fallbackFamily ?? null]);
103
282
  }
104
283
 
105
284
  /**
@@ -108,11 +287,13 @@ export function presentationFaces(presentation, options = {}, registry) {
108
287
  * (`registry.describeFaces()`), `loaded` the lazy files already loaded; `policy`, `aliases` and `fallbackFamily` are the
109
288
  * registry's. `renderOptions` are the `toSvg` options the document resolves with (`catalogs`, ...), and a document that
110
289
  * does not resolve throws what `toSvg` throws for it. Face level: a bold or italic run added by an edit adds just that face.
290
+ * Pass `analysis` (`createFontAnalysis()`) to keep each slide's faces between calls: then only slides that changed are analyzed.
111
291
  */
112
- export function lazyFacesNeeded(presentation, renderOptions, { lazy, held, loaded = new Set(), policy = "none", aliases = new Map(), fallbackFamily }) {
292
+ export function lazyFacesNeeded(presentation, renderOptions, { lazy, held, loaded = new Set(), policy = "none", aliases = new Map(), fallbackFamily, analysis }) {
113
293
  const heldFaces = [...held], heldKeys = new Set(heldFaces.map(faceKeyOf));
114
294
  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));
295
+ // `analysis` (from `createFontAnalysis`) keeps each slide's faces between calls, so a fonts handle redoes only changed slides.
296
+ const drawn = new Set((analysis ?? createFontAnalysis()).faces(presentation, renderOptions, { faces: [...heldFaces, ...missing], policy, aliases, fallbackFamily }).map(faceKeyOf));
116
297
  return missing.filter(face => drawn.has(faceKeyOf(face)));
117
298
  }
118
299
  function faceKeyOf(face) { return `${lc(face.family)}\u0000${face.weight}\u0000${!!face.italic}`; }
@@ -5,7 +5,7 @@ import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
5
5
  import { OPFFontError } from "./font-registry.js";
6
6
  import { SCRIPT_FONT_REPLACEMENTS, glyphFallbackFamilies, scriptOfCharacter, scriptsOfText } from "./script-fonts.js";
7
7
  import { resolveScriptFonts } from "@openpresentation/opf/composition";
8
- import { presentationFamilies } from "./lazy-fonts.js";
8
+ import { createFontAnalysis } from "./lazy-fonts.js";
9
9
  import { SYMBOL_SCRIPT, isSymbolEncodedFamily } from "./symbol-fonts.js";
10
10
 
11
11
  const SCRIPT_ALIASES = Object.freeze({Hira: "Jpan", Kana: "Jpan", Hrkt: "Jpan", Hang: "Kore", Hani: "Hans", Zyyy: "Latn"});
@@ -134,7 +134,7 @@ function scriptOfFamily(family) {
134
134
  * unchanged, and the families the renderer resolves per slide count too, so a scheme that exists only in the host's catalogs
135
135
  * and names a script font still loads that font.
136
136
  */
137
- function designScripts(presentation, profile, renderOptions) {
137
+ function designScripts(presentation, profile, renderOptions, memory) {
138
138
  const scripts = new Set();
139
139
  let han;
140
140
  const note = resolved => {
@@ -153,7 +153,7 @@ function designScripts(presentation, profile, renderOptions) {
153
153
  });
154
154
  }
155
155
  if (renderOptions?.catalogs) {
156
- for (const family of presentationFamilies(presentation, renderOptions)) note({ heading: { latin: family }, body: { latin: family } });
156
+ for (const family of memory.fonts.families(presentation, renderOptions)) note({ heading: { latin: family }, body: { latin: family } });
157
157
  }
158
158
  // FF-45: a run or design that names a symbol-encoded family (a Wingdings bullet run, a Symbol heading) needs the symbol faces.
159
159
  for (const family of namedFontFamilies(presentation)) if (isSymbolEncodedFamily(family)) scripts.add(SYMBOL_SCRIPT);
@@ -179,19 +179,62 @@ function* namedFontFamilies(value, key) {
179
179
  * `toSvg` options) matter only for font schemes the host supplies in `renderOptions.catalogs`, which then resolve
180
180
  * like the renderer resolves them; a document that then does not resolve throws what `toSvg` throws for it.
181
181
  */
182
- export function analyzePresentationScripts(presentation, profile, renderOptions) {
182
+ export function analyzePresentationScripts(presentation, profile, renderOptions, memory = createScriptAnalysis()) {
183
183
  profile ??= presentationScriptProfile(presentation, renderOptions);
184
184
  const scripts = new Set(), cjk = new Set();
185
- const design = designScripts(presentation, profile, renderOptions);
185
+ const design = designScripts(presentation, profile, renderOptions, memory);
186
186
  // Han-only text in a deck whose language is not CJK draws with the scheme's own CJK face when the scheme names one.
187
187
  const itemizing = design.han && !cjkKeys.includes(profile?.script) ? { ...profile, script: design.han } : profile;
188
+ const scan = memory.scanner(itemizing);
188
189
  for (const text of drawnStrings(presentation)) {
189
- for (const script of scriptsOfText(text, itemizing)) scripts.add(script);
190
- for (const character of text) if (character.codePointAt(0) > 0x2E7F && cjkCharacters.has(scriptOfCharacter(character))) cjk.add(character);
190
+ const found = scan(text);
191
+ for (const script of found.scripts) scripts.add(script);
192
+ for (const character of found.cjk) cjk.add(character);
191
193
  }
192
194
  return { profile, detected: [...scripts].sort(), design: design.scripts, cjk };
193
195
  }
194
196
 
197
+ /** One string's scripts (`scriptsOfText`) and the Han, kana and Hangul characters it draws, in order. One pass over the characters. */
198
+ function scanText(text, itemizing) {
199
+ const cjk = [];
200
+ for (const character of text) if (character.codePointAt(0) > 0x2E7F && cjkCharacters.has(scriptOfCharacter(character))) cjk.push(character);
201
+ return { scripts: scriptsOfText(text, itemizing), cjk };
202
+ }
203
+
204
+ const STRING_MEMORY_LIMIT = 65536;
205
+
206
+ /**
207
+ * Memory for `analyzePresentationScripts`, for a fonts handle that analyzes the same deck again and again (the 0.28.0 startup
208
+ * regression): each drawn string is scanned once per itemizing profile (the document language decides how Han text and East
209
+ * Asian punctuation itemize), and the font families of the deck's slides come from a `createFontAnalysis` that redoes only
210
+ * changed slides. Pass the handle's own `fonts` analysis to share its slide memory. `stats.strings` counts the strings scanned.
211
+ */
212
+ export function createScriptAnalysis({ fonts = createFontAnalysis() } = {}) {
213
+ const byProfile = new Map(), stats = { strings: 0 };
214
+ return {
215
+ fonts,
216
+ stats,
217
+ scanner(itemizing) {
218
+ const key = JSON.stringify(itemizing ?? null) ?? "null";
219
+ let seen = byProfile.get(key);
220
+ if (!seen) {
221
+ if (byProfile.size >= 4) byProfile.delete(byProfile.keys().next().value);
222
+ byProfile.set(key, seen = new Map());
223
+ }
224
+ return text => {
225
+ let found = seen.get(text);
226
+ if (found === undefined) {
227
+ found = scanText(text, itemizing);
228
+ stats.strings++;
229
+ if (seen.size >= STRING_MEMORY_LIMIT) seen.clear();
230
+ seen.set(text, found);
231
+ }
232
+ return found;
233
+ };
234
+ },
235
+ };
236
+ }
237
+
195
238
  /**
196
239
  * Script keys (ISO 15924: `Jpan`, `Hans`, `Arab`, ...) whose faces a preview of this presentation needs.
197
240
  * Text decides, itemized exactly as drawing does: the document language only tells Han text apart
@@ -181,9 +181,18 @@ const latinGroup = new Set(["Latn", "Cyrl", "Grek", "Zyyy", "Zinh", "Zzzz"]);
181
181
 
182
182
  const detected = ["Latn", "Cyrl", "Grek", "Hani", "Hira", "Kana", "Hang", "Bopo", "Yiii", "Arab", "Hebr", "Syrc", "Thaa", "Nkoo", "Adlm", "Rohg", "Mand", "Samr",
183
183
  "Deva", "Beng", "Guru", "Gujr", "Orya", "Taml", "Telu", "Knda", "Mlym", "Sinh", "Thai", "Laoo", "Tibt", "Mymr", "Khmr", "Mong", "Bali", "Java", "Lana",
184
- "Tale", "Talu", "Cakm", "Olck", "Armn", "Geor", "Ethi", "Cher", "Tfng", "Vaii", "Zinh", "Zyyy"].map(code => [code, new RegExp(String.raw`^\p{Script=${code}}$`, "u")]);
184
+ "Tale", "Talu", "Cakm", "Olck", "Armn", "Geor", "Ethi", "Cher", "Tfng", "Vaii", "Zinh", "Zyyy"];
185
+ // One precompiled pattern for every script, a capture group each, tried in the list's order (the first match wins, as with one
186
+ // pattern per script): a character is classified by one match instead of up to 48 tests.
187
+ const detectedPattern = new RegExp(`^(?:${detected.map(code => String.raw`(\p{Script=${code}})`).join("|")})$`, "u");
185
188
  const ignorable = /^\p{Default_Ignorable_Code_Point}$/u;
186
189
  const scriptCache = new Map();
190
+ function classify(character) {
191
+ const match = detectedPattern.exec(character);
192
+ if (!match) return "Zzzz";
193
+ for (let group = 1; group < match.length; group++) if (match[group] !== undefined) return detected[group - 1];
194
+ return "Zzzz";
195
+ }
187
196
 
188
197
  /** ISO 15924 script of one character: `Zyyy` common, `Zinh` inherited, `Zzzz` otherwise unknown. */
189
198
  export function scriptOfCharacter(character) {
@@ -192,7 +201,7 @@ export function scriptOfCharacter(character) {
192
201
  const code = character.codePointAt(0);
193
202
  script = code < 0x80 ? (/[A-Za-z]/.test(character) ? "Latn" : "Zyyy")
194
203
  : ignorable.test(character) ? "Zinh"
195
- : (detected.find(([, pattern]) => pattern.test(character))?.[0] ?? "Zzzz");
204
+ : classify(character);
196
205
  if (scriptCache.size < 65536) scriptCache.set(character, script);
197
206
  }
198
207
  return script;
@@ -214,10 +223,10 @@ function hanScript(text, profile) {
214
223
  return "Hans";
215
224
  }
216
225
 
217
- function fontKey(script, text, profile) {
226
+ function fontKey(script, text, profile, han = () => hanScript(text, profile)) {
218
227
  if (script === "Hira" || script === "Kana") return "Jpan";
219
228
  if (script === "Hang") return "Kore";
220
- if (script === "Hani" || script === "Bopo") return hanScript(text, profile);
229
+ if (script === "Hani" || script === "Bopo") return han();
221
230
  return latinGroup.has(script) ? "Latn" : script;
222
231
  }
223
232
 
@@ -251,6 +260,9 @@ export function itemizeScripts(text, profile) {
251
260
  const runs = [];
252
261
  let pending = "";
253
262
  const eastAsian = eastAsianLanguage(profile);
263
+ // The Han key depends on the whole text and the profile only: work it out once per text, not once per Han character.
264
+ let han;
265
+ const hanOfText = () => han ??= hanScript(source, profile);
254
266
  const push = (character, key) => {
255
267
  const last = runs.at(-1);
256
268
  if (last && last.script === key) last.text += character;
@@ -268,7 +280,7 @@ export function itemizeScripts(text, profile) {
268
280
  for (const character of source) {
269
281
  const script = scriptOfCharacter(character);
270
282
  if (eastAsianCommon.test(character) || (eastAsian && eastAsianAmbiguous.test(character))) {
271
- strong(character, script === "Hira" || script === "Kana" ? "Jpan" : hanScript(source, profile));
283
+ strong(character, script === "Hira" || script === "Kana" ? "Jpan" : hanOfText());
272
284
  } else if (script === "Zinh") {
273
285
  if (runs.length) runs.at(-1).text += character; else pending += character;
274
286
  } else if (script === "Zyyy") {
@@ -276,7 +288,7 @@ export function itemizeScripts(text, profile) {
276
288
  if (!last) pending += character;
277
289
  else if (character.codePointAt(0) < 0x80 && last.role === "eastAsian") push(character, "Latn");
278
290
  else last.text += character;
279
- } else strong(character, fontKey(script, source, profile));
291
+ } else strong(character, fontKey(script, source, profile, hanOfText));
280
292
  }
281
293
  if (!runs.length) return [{text: pending, script: "Latn", role: "latin"}];
282
294
  return runs;
@@ -0,0 +1,3 @@
1
+ // Hooks svg.js fills in for the renderer's own modules (the font analysis in lazy-fonts.js). They are not part of the package API:
2
+ // index.js re-exports svg.js, so internals live here instead of as exports there.
3
+ export const deckInternals = {};
package/dist/svg.d.ts CHANGED
@@ -190,7 +190,11 @@ export declare class OPFRenderError extends Error {
190
190
  constructor(code: string, message: string, details?: Record<string, unknown>);
191
191
  }
192
192
 
193
- export declare function resolvePresentation(input: unknown, options?: ToSvgOptions): ResolvedPresentation;
193
+ /**
194
+ * Resolve a presentation for drawing. `select.slides` (0-based) binds only those slides (each slide is composed on its own); the other entries
195
+ * of `slides` are then undefined. Without it every slide is bound.
196
+ */
197
+ export declare function resolvePresentation(input: unknown, options?: ToSvgOptions, select?: { slides?: number[] }): ResolvedPresentation;
194
198
 
195
199
  /** An SVG slide the renderer drew: its text, or its bytes. */
196
200
  export type SvgSlide = string | Uint8Array;
package/dist/svg.js CHANGED
@@ -27,6 +27,7 @@ import { renderCaption, renderFootnotes } from "./annotations.js";
27
27
  import { drawnFaces } from "./drawn-faces.js";
28
28
  import { dataUrlFsType, embeddingAllowed } from "./font-fstype.js";
29
29
  import { rejectRenamedOptions, selectSlides } from "./slide-sources.js";
30
+ import { deckInternals } from "./svg-internals.js";
30
31
 
31
32
  export const packageName = "@openpresentation/opf-render";
32
33
 
@@ -641,18 +642,21 @@ function normalizeOptions(options) {
641
642
  * Resolve a presentation for drawing: template variables filled, the boundary check passed, and every slide bound to its
642
643
  * layout, design, script fonts and composed geometry. `toSvg` draws from this.
643
644
  */
644
- export function resolvePresentation(input, options = {}) {
645
- return resolveDeck(input, normalizeOptions(options));
645
+ export function resolvePresentation(input, options = {}, { slides } = {}) {
646
+ return resolveDeck(input, normalizeOptions(options), Array.isArray(slides) ? () => new Set(slides) : undefined);
646
647
  }
647
648
 
648
- function resolveDeck(input, options) {
649
+ // `wanted(count)` names the 0-based slides to bind (CE-20): a slide is composed on its own, so drawing one slide of a long deck binds that slide
650
+ // only, and the others are left undefined. Without it every slide is bound.
651
+ function resolveDeck(input, options, wanted) {
649
652
  const presentation = resolveTemplateInput(parseInput(input), options);
650
653
  if (options.validate !== false) {
651
654
  assertValidBoundary(presentation, options);
652
655
  }
653
656
 
654
657
  const context = { presentation, options };
655
- const slides = presentation.slides.map((_, index) => bindSlide(presentation, index, context));
658
+ const only = wanted?.(presentation.slides.length);
659
+ const slides = presentation.slides.map((_, index) => (only && !only.has(index) ? undefined : bindSlide(presentation, index, context)));
656
660
 
657
661
  return {
658
662
  presentation,
@@ -670,7 +674,11 @@ function resolveDeck(input, options) {
670
674
  export function toSvg(input, slides, options) {
671
675
  if (slides !== null && typeof slides === "object" && !Array.isArray(slides)) { options = slides; slides = undefined; }
672
676
  const drawn = normalizeOptions(options ?? {});
673
- const resolved = resolveDeck(input, drawn);
677
+ // Only the slides drawn are bound (the whole deck when every slide is drawn).
678
+ const resolved = resolveDeck(input, drawn, slides === undefined ? undefined : (count) => {
679
+ if (typeof slides === "number") return new Set([slides - 1]);
680
+ try { return new Set(selectSlides(slides, count).map((number) => number - 1)); } catch { return new Set(); }
681
+ });
674
682
  const count = resolved.slides.length;
675
683
  if (typeof slides === "number") {
676
684
  if (!Number.isInteger(slides) || slides < 1 || slides > count) throw new OPFRenderError("slide-out-of-range", `Slide ${slides} is not in the presentation, which has ${count} slide${count === 1 ? "" : "s"} (slides count from 1).`, { slide: slides, slideCount: count });
@@ -2280,3 +2288,34 @@ function tag(name, attrs = {}, children = "") {
2280
2288
  if (children === "") return `<${name}${serializedAttrs}/>`;
2281
2289
  return `<${name}${serializedAttrs}>${children}</${name}>`;
2282
2290
  }
2291
+
2292
+ // For the renderer's own font analysis (lazy-fonts.js), not part of the package API (index.js re-exports this module): `openDeck`
2293
+ // opens the deck once (the parsing, template variables and boundary check of `toSvg`) and works on single slides on request, so an
2294
+ // analysis can redo only the slides that changed. Binding and drawing a slide read the deck and that slide only (its number and
2295
+ // the slide count included), never another slide's binding.
2296
+ // - `design(index)` is the slide's design as `bindSlide` resolves it, without composing the slide. Its font families do not depend
2297
+ // on composition: without a measurement (the analysis passes none) the drawn families are the scheme's.
2298
+ // - `bind(index, textMeasurement)` composes the slide with that measurement, which `render(index)` then draws with too, exactly as
2299
+ // `toSvg` measures each slide.
2300
+ // - `variablesIdle(deck, variables)`: the `variables` option leaves this deck as it is (a deck that is no template and has no content
2301
+ // variables, with `variables` false, absent or empty; see `resolveTemplateInput`), so an analysis need not tell those options apart.
2302
+ deckInternals.variablesIdle = (input, variables) =>
2303
+ isPlainObject(input) && (variables === undefined || variables === false || (isPlainObject(variables) && !Object.keys(variables).length)) && !isTemplate(input) && !hasContentVariables(input);
2304
+ deckInternals.openDeck = (input, options = {}) => {
2305
+ const drawn = normalizeOptions(options);
2306
+ const presentation = resolveTemplateInput(parseInput(input), drawn);
2307
+ if (drawn.validate !== false) assertValidBoundary(presentation, drawn);
2308
+ const resolved = { presentation, engineDefaults, slides: [] }, measurements = [];
2309
+ return {
2310
+ presentation,
2311
+ design(index) {
2312
+ const slideContext = resolveContext(presentation, index, { presentation, options: drawn });
2313
+ return resolveDesign(presentation, slideContext.slide, slideContext);
2314
+ },
2315
+ bind(index, textMeasurement = drawn.textMeasurement) {
2316
+ measurements[index] = textMeasurement;
2317
+ resolved.slides[index] = bindSlide(presentation, index, { presentation, options: { ...drawn, textMeasurement } });
2318
+ },
2319
+ render(index) { return renderResolvedSlide(resolved, index, { ...drawn, textMeasurement: measurements[index] }); },
2320
+ };
2321
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-render",
3
- "version": "0.28.0",
3
+ "version": "0.29.1",
4
4
  "description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -94,7 +94,7 @@
94
94
  "fontkit": "^2.0.4",
95
95
  "harfbuzzjs": "1.6.4",
96
96
  "pako": "3.0.2",
97
- "@openpresentation/opf": "^0.28.0"
97
+ "@openpresentation/opf": "^0.29.1"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "@expo-google-fonts/arimo": "0.4.3",
@@ -343,7 +343,7 @@
343
343
  "pdfjs-dist": "6.4.299",
344
344
  "playwright": "1.64.1",
345
345
  "sharp": "0.35.5",
346
- "@openpresentation/gallery": "0.28.0"
346
+ "@openpresentation/gallery": "0.29.1"
347
347
  },
348
348
  "bin": {
349
349
  "opf-preview-fonts": "./dist/preview-fonts-cli.js"
@@ -399,5 +399,5 @@
399
399
  "test:preview-a11y-browser": "node test/preview-a11y-browser.mjs",
400
400
  "test:forced-colors-browser": "node test/forced-colors-browser.mjs artifacts/forced-colors"
401
401
  },
402
- "gitHead": "abc56634a584db38a305a27edc3bc76b4116637b"
402
+ "gitHead": "e9f46c24606ce0f474347b984eacea5dce7fd839"
403
403
  }