@openpresentation/opf-render 0.27.0 → 0.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/fonts-browser.d.ts +6 -0
- package/dist/fonts-browser.js +11 -5
- package/dist/fonts-node.js +4 -2
- package/dist/index.d.ts +5 -1
- package/dist/lazy-fonts.js +204 -23
- package/dist/script-font-pack.js +50 -7
- package/dist/script-fonts.js +18 -6
- package/dist/svg-internals.js +3 -0
- package/dist/svg.d.ts +5 -1
- package/dist/svg.js +51 -11
- package/package.json +7 -7
package/dist/fonts-browser.d.ts
CHANGED
|
@@ -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 & {
|
package/dist/fonts-browser.js
CHANGED
|
@@ -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
|
}
|
package/dist/fonts-node.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/lazy-fonts.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fontPolicyFor } from "@openpresentation/opf/font-policy";
|
|
2
2
|
import { pickFace } from "./font-registry.js";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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}`; }
|
package/dist/script-font-pack.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
190
|
-
for (const
|
|
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
|
package/dist/script-fonts.js
CHANGED
|
@@ -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"]
|
|
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
|
-
: (
|
|
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
|
|
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" :
|
|
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;
|
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
|
-
|
|
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
|
@@ -13,19 +13,21 @@ import {
|
|
|
13
13
|
import {
|
|
14
14
|
hasContentVariables,
|
|
15
15
|
isTemplate,
|
|
16
|
+
resolveDrawnFontFamilies,
|
|
16
17
|
resolveSlideContext,
|
|
17
18
|
resolveVariables,
|
|
18
19
|
validate
|
|
19
20
|
} from "@openpresentation/opf";
|
|
20
21
|
import { adjustedFontSize, baselineShift, createScriptFonts } from "./script-fonts.js";
|
|
21
|
-
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
22
22
|
import { isSymbolEncodedFamily } from "./symbol-fonts.js";
|
|
23
23
|
import { fontPolicyFor } from "@openpresentation/opf/font-policy";
|
|
24
|
+
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
24
25
|
import { isDatasetChart, isDatasetTable, renderCatalogChart } from "./charts.js";
|
|
25
26
|
import { renderCaption, renderFootnotes } from "./annotations.js";
|
|
26
27
|
import { drawnFaces } from "./drawn-faces.js";
|
|
27
28
|
import { dataUrlFsType, embeddingAllowed } from "./font-fstype.js";
|
|
28
29
|
import { rejectRenamedOptions, selectSlides } from "./slide-sources.js";
|
|
30
|
+
import { deckInternals } from "./svg-internals.js";
|
|
29
31
|
|
|
30
32
|
export const packageName = "@openpresentation/opf-render";
|
|
31
33
|
|
|
@@ -598,10 +600,9 @@ function bindSlide(presentation, index, context) {
|
|
|
598
600
|
// A symbol-encoded family (Symbol, Wingdings, Webdings; FF-45) keeps its name too: each run resolves it again and the script-font planner
|
|
599
601
|
// maps the run's private-use codes through the symbol table (symbol-fonts.js), as it does for a symbol run. Resolving the role to the open
|
|
600
602
|
// symbol face here would drop the encoding, and the codes would reach a face with no glyph for them (missing-glyph).
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
}
|
|
603
|
+
// The rule is core's resolveDrawnFontFamilies, which validate and paginate compose with too (WI-1, P24); the renderer decides
|
|
604
|
+
// the two exceptions from the font policy and symbol tables it already holds.
|
|
605
|
+
design.fonts = resolveDrawnFontFamilies(design.fonts, textMeasurement, (named, resolved) => isSymbolEncodedFamily(named) || fontPolicyFor(named)?.replacement?.weight !== undefined && resolved.toLowerCase() !== named.toLowerCase());
|
|
605
606
|
const geometry = composeSlide(slide, { ...slideContext.options, fontFamilies: design.fonts, textRasterPadding:context.options.textRasterPadding, textMeasurement });
|
|
606
607
|
return {
|
|
607
608
|
scriptFonts,
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 });
|
|
@@ -1001,7 +1009,8 @@ function listEntriesSvg(item, fit, bound, options) {
|
|
|
1001
1009
|
// design.listBullet=image: core attaches the icon logo as item.bulletImage and its box (entry.bulletBox: 0.65 em, as PowerPoint draws a:buBlip). An icon that cannot be drawn keeps the glyph marker.
|
|
1002
1010
|
const bullet=item.bulletImage?resolveBulletImage(item.bulletImage,bound,options):undefined;
|
|
1003
1011
|
for(const entry of fit.listEntries){
|
|
1004
|
-
if(
|
|
1012
|
+
if(entry.marker.none){/* design.listBullet: none draws no marker */}
|
|
1013
|
+
else if(bullet){const box=entry.bulletBox??{x:entry.marker.x,y:entry.marker.y-entry.marker.fontSize*.65,width:entry.marker.fontSize*.65,height:entry.marker.fontSize*.65};children.push(tag('image',{x:stableNumber(box.x),y:stableNumber(box.y),width:stableNumber(box.width),height:stableNumber(box.height),href:bullet,preserveAspectRatio:'xMidYMid meet','aria-hidden':'true',...(options.trace?{'data-opf-generated':'true'}:{})}));}
|
|
1005
1014
|
else children.push(tag('text',{x:stableNumber(entry.marker.x),y:stableNumber(entry.marker.y),'font-family':fontStack(entry.marker.style.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(entry.marker.fontSize),...numberMarkerStyle(entry.marker),fill:bound.design.colors.text,'aria-hidden':'true',...(entry.marker.anchor==='end'?{'text-anchor':'end'}:{})},escapeText(entry.marker.text)));
|
|
1006
1015
|
const config={path:entry.textPath,align:'left',fill:bound.design.colors.text,rich:Array.isArray(entry.value),options};
|
|
1007
1016
|
children.push(renderRichLines(typeof entry.value==='string'?[entry.value]:entry.value,entry.text,entry.textBox,bound,config));
|
|
@@ -2279,3 +2288,34 @@ function tag(name, attrs = {}, children = "") {
|
|
|
2279
2288
|
if (children === "") return `<${name}${serializedAttrs}/>`;
|
|
2280
2289
|
return `<${name}${serializedAttrs}>${children}</${name}>`;
|
|
2281
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.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "Deterministic local OPF renderer for SVG, PNG, and PDF output.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -92,9 +92,9 @@
|
|
|
92
92
|
"dependencies": {
|
|
93
93
|
"bidi-js": "1.1.0",
|
|
94
94
|
"fontkit": "^2.0.4",
|
|
95
|
-
"harfbuzzjs": "1.6.
|
|
95
|
+
"harfbuzzjs": "1.6.4",
|
|
96
96
|
"pako": "3.0.2",
|
|
97
|
-
"@openpresentation/opf": "^0.
|
|
97
|
+
"@openpresentation/opf": "^0.29.0"
|
|
98
98
|
},
|
|
99
99
|
"peerDependencies": {
|
|
100
100
|
"@expo-google-fonts/arimo": "0.4.3",
|
|
@@ -335,15 +335,15 @@
|
|
|
335
335
|
"@expo-google-fonts/tinos": "0.4.2",
|
|
336
336
|
"@hyzyla/pdfium": "2.1.13",
|
|
337
337
|
"@jspawn/qpdf-wasm": "0.0.2",
|
|
338
|
-
"@napi-rs/canvas": "1.0.
|
|
338
|
+
"@napi-rs/canvas": "1.0.13",
|
|
339
339
|
"@resvg/resvg-js": "2.6.2",
|
|
340
340
|
"axe-core": "4.14.0",
|
|
341
341
|
"esbuild": "0.28.2",
|
|
342
342
|
"pdf-lib": "1.17.1",
|
|
343
343
|
"pdfjs-dist": "6.4.299",
|
|
344
|
-
"playwright": "1.64.
|
|
344
|
+
"playwright": "1.64.1",
|
|
345
345
|
"sharp": "0.35.5",
|
|
346
|
-
"@openpresentation/gallery": "0.
|
|
346
|
+
"@openpresentation/gallery": "0.29.0"
|
|
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": "
|
|
402
|
+
"gitHead": "464e10d3c15647b0e43326ac7e6761e34789991a"
|
|
403
403
|
}
|