@openpresentation/opf-render 0.9.0 → 0.11.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/README.md +115 -13
- package/dist/font-compatibility.js +72 -3
- package/dist/font-manifest.js +1746 -12
- package/dist/font-policy.js +2518 -0
- package/dist/fonts-browser.d.ts +65 -1
- package/dist/fonts-browser.js +229 -35
- package/dist/fonts-node.d.ts +68 -10
- package/dist/fonts-node.js +152 -16
- package/dist/fonts.d.ts +87 -5
- package/dist/fonts.js +147 -37
- package/dist/index.d.ts +31 -2
- package/dist/lazy-font-list.js +23 -0
- package/dist/lazy-fonts.js +52 -0
- package/dist/raster.js +3 -1
- package/dist/script-font-pack.js +179 -0
- package/dist/script-fonts.js +563 -0
- package/dist/svg.d.ts +29 -2
- package/dist/svg.js +446 -41
- package/fonts/bebas-neue/BebasNeue_400Regular.ttf +0 -0
- package/fonts/bebas-neue/OFL.txt +93 -0
- package/fonts/bebas-neue/PROVENANCE.json +23 -0
- package/fonts/carlito/Carlito-Bold.ttf +0 -0
- package/fonts/carlito/Carlito-BoldItalic.ttf +0 -0
- package/fonts/carlito/Carlito-Italic.ttf +0 -0
- package/fonts/carlito/Carlito-Regular.ttf +0 -0
- package/fonts/carlito/OFL.txt +94 -0
- package/fonts/intos/Intos-Bold.ttf +0 -0
- package/fonts/intos/Intos-BoldItalic.ttf +0 -0
- package/fonts/intos/Intos-Italic.ttf +0 -0
- package/fonts/intos/Intos-Regular.ttf +0 -0
- package/fonts/intos/IntosDisplay-Bold.ttf +0 -0
- package/fonts/intos/IntosDisplay-BoldItalic.ttf +0 -0
- package/fonts/intos/IntosDisplay-Italic.ttf +0 -0
- package/fonts/intos/IntosDisplay-Regular.ttf +0 -0
- package/fonts/intos/IntosNarrow-Bold.ttf +0 -0
- package/fonts/intos/IntosNarrow-BoldItalic.ttf +0 -0
- package/fonts/intos/IntosNarrow-Italic.ttf +0 -0
- package/fonts/intos/IntosNarrow-Regular.ttf +0 -0
- package/fonts/intos/IntosSerif-Bold.ttf +0 -0
- package/fonts/intos/IntosSerif-BoldItalic.ttf +0 -0
- package/fonts/intos/IntosSerif-Italic.ttf +0 -0
- package/fonts/intos/IntosSerif-Regular.ttf +0 -0
- package/fonts/intos/NOTICE.md +18 -0
- package/fonts/intos/OFL.txt +94 -0
- package/fonts/intos/PROVENANCE.json +144 -0
- package/fonts/lora/Lora-Bold.ttf +0 -0
- package/fonts/lora/Lora-BoldItalic.ttf +0 -0
- package/fonts/lora/Lora-Italic.ttf +0 -0
- package/fonts/lora/Lora-Regular.ttf +0 -0
- package/fonts/lora/OFL.txt +93 -0
- package/fonts/lora/PROVENANCE.json +48 -0
- package/fonts/merriweather-sans/MerriweatherSans-Bold.ttf +0 -0
- package/fonts/merriweather-sans/MerriweatherSans-BoldItalic.ttf +0 -0
- package/fonts/merriweather-sans/MerriweatherSans-Italic.ttf +0 -0
- package/fonts/merriweather-sans/MerriweatherSans-Regular.ttf +0 -0
- package/fonts/merriweather-sans/OFL.txt +96 -0
- package/fonts/merriweather-sans/PROVENANCE.json +48 -0
- package/fonts/montserrat/Montserrat_400Regular.ttf +0 -0
- package/fonts/montserrat/Montserrat_400Regular_Italic.ttf +0 -0
- package/fonts/montserrat/Montserrat_700Bold.ttf +0 -0
- package/fonts/montserrat/Montserrat_700Bold_Italic.ttf +0 -0
- package/fonts/montserrat/Montserrat_900Black.ttf +0 -0
- package/fonts/montserrat/OFL.txt +93 -0
- package/fonts/montserrat/PROVENANCE.json +55 -0
- package/fonts/open-sans/OFL.txt +92 -0
- package/fonts/open-sans/OpenSans_400Regular.ttf +0 -0
- package/fonts/open-sans/OpenSans_400Regular_Italic.ttf +0 -0
- package/fonts/open-sans/OpenSans_700Bold.ttf +0 -0
- package/fonts/open-sans/OpenSans_700Bold_Italic.ttf +0 -0
- package/fonts/open-sans/PROVENANCE.json +47 -0
- package/fonts/poppins/OFL.txt +93 -0
- package/fonts/poppins/PROVENANCE.json +47 -0
- package/fonts/poppins/Poppins_400Regular.ttf +0 -0
- package/fonts/poppins/Poppins_400Regular_Italic.ttf +0 -0
- package/fonts/poppins/Poppins_700Bold.ttf +0 -0
- package/fonts/poppins/Poppins_700Bold_Italic.ttf +0 -0
- package/fonts/pt-serif/OFL.txt +93 -0
- package/fonts/pt-serif/PROVENANCE.json +50 -0
- package/fonts/pt-serif/PT_Serif-Web-Bold.ttf +0 -0
- package/fonts/pt-serif/PT_Serif-Web-BoldItalic.ttf +0 -0
- package/fonts/pt-serif/PT_Serif-Web-Italic.ttf +0 -0
- package/fonts/pt-serif/PT_Serif-Web-Regular.ttf +0 -0
- package/fonts/red-hat-display/OFL.txt +93 -0
- package/fonts/red-hat-display/PROVENANCE.json +38 -0
- package/fonts/red-hat-display/RedHatDisplay-Bold.ttf +0 -0
- package/fonts/red-hat-display/RedHatDisplay-Light.ttf +0 -0
- package/fonts/red-hat-display/RedHatDisplay-Regular.ttf +0 -0
- package/fonts/red-hat-text/OFL.txt +93 -0
- package/fonts/red-hat-text/PROVENANCE.json +30 -0
- package/fonts/red-hat-text/RedHatText-Bold.ttf +0 -0
- package/fonts/red-hat-text/RedHatText-Regular.ttf +0 -0
- package/fonts/source-sans-3/OFL.txt +93 -0
- package/fonts/source-sans-3/PROVENANCE.json +49 -0
- package/fonts/source-sans-3/SourceSans3-Bold.ttf +0 -0
- package/fonts/source-sans-3/SourceSans3-BoldIt.ttf +0 -0
- package/fonts/source-sans-3/SourceSans3-It.ttf +0 -0
- package/fonts/source-sans-3/SourceSans3-Regular.ttf +0 -0
- package/package.json +167 -6
package/dist/fonts-node.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { createHash } from "node:crypto";
|
|
5
6
|
import { createFontRegistry, OPFFontError } from "./fonts.js";
|
|
6
7
|
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
8
|
+
import { lazyFontList } from "./lazy-font-list.js";
|
|
7
9
|
export { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
10
|
+
import { analyzePresentationScripts, nextFallbackPackage, scriptFontPackages, scriptSelectionOf, uncoveredCjkCharacters } from "./script-font-pack.js";
|
|
11
|
+
export { autoScriptSelection, detectPresentationScripts, scriptFontPackages } from "./script-font-pack.js";
|
|
12
|
+
// Script faces are embedded in a standalone SVG only when the slide's text uses their family.
|
|
13
|
+
const embedUsed = entries => entries.map(entry => ({...entry, embed: "used"}));
|
|
8
14
|
const require = createRequire(import.meta.url);
|
|
9
15
|
|
|
10
16
|
async function verifiedFile(file, expected, details) {
|
|
@@ -16,14 +22,37 @@ async function verifiedFile(file, expected, details) {
|
|
|
16
22
|
return bytes;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
// The default Latin, Cyrillic and Greek glyph-fallback face (the office pack always loads it, fallback-only).
|
|
26
|
+
const FALLBACK_PACKAGE = "@expo-google-fonts/noto-sans";
|
|
27
|
+
|
|
28
|
+
// `skipped`: an optional npm package that is not installed is recorded there instead of failing (scripts: 'auto').
|
|
29
|
+
// `fallbackOnly`: the faces serve glyph fallback and requests by their own name, never a replacement for another family.
|
|
30
|
+
async function loadPackages(packages, skipped, {fallbackOnly = false} = {}) {
|
|
20
31
|
const entries = [], fontFiles = [];
|
|
21
|
-
for (const pkg of
|
|
32
|
+
for (const pkg of packages) {
|
|
33
|
+
if (pkg.vendored) {
|
|
34
|
+
// FF-31: vendored faces ship inside this package (pkg.vendored, for example fonts/carlito), hash-pinned like the npm packs. The open pack is
|
|
35
|
+
// embedded in an SVG only when the slide's text names the family (embed "used"); raster output reads the files.
|
|
36
|
+
const directory = fileURLToPath(new URL(`../${pkg.vendored}/`, import.meta.url));
|
|
37
|
+
let license = (await verifiedFile(path.join(directory, pkg.licenseFile), pkg.licenseSha256, {package:pkg.name, file:pkg.licenseFile})).toString("utf8");
|
|
38
|
+
// A notice file carries provenance and upstream copyright lines that the upstream license file omits.
|
|
39
|
+
if (pkg.noticeFile) license += "\n\n" + (await verifiedFile(path.join(directory, pkg.noticeFile), pkg.noticeSha256, {package:pkg.name, file:pkg.noticeFile})).toString("utf8");
|
|
40
|
+
// One rule for the vendored faces the SVG embeds only when its text names them: the open pack and any package flagged embed "used" (Intos).
|
|
41
|
+
const lazy = pkg.pack === "open" || pkg.embed === "used";
|
|
42
|
+
for (const face of pkg.faces) {
|
|
43
|
+
const file = path.join(directory, face.file);
|
|
44
|
+
const data = await verifiedFile(file, face.sha256, {package:pkg.name, file:face.file});
|
|
45
|
+
fontFiles.push(file);
|
|
46
|
+
entries.push({data:new Uint8Array(data), family:face.family, weight:face.weight, italic:face.italic, license, ...(lazy ? {embed:"used"} : {})});
|
|
47
|
+
}
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
22
50
|
let manifestPath, installed;
|
|
23
51
|
try {
|
|
24
52
|
manifestPath = require.resolve(`${pkg.name}/package.json`);
|
|
25
53
|
installed = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
26
54
|
} catch (error) {
|
|
55
|
+
if (skipped) { skipped.push({package: pkg.name, version: pkg.version, scripts: [...pkg.scripts]}); continue; }
|
|
27
56
|
throw new OPFFontError("font-resource-unavailable", `Install ${pkg.name}@${pkg.version} to use this offline font pack.`, {package:pkg.name, cause:error.code});
|
|
28
57
|
}
|
|
29
58
|
if (installed.version !== pkg.version) throw new OPFFontError("font-version-mismatch", `Expected ${pkg.name}@${pkg.version}; reinstall the pinned package.`, {package:pkg.name, expected:pkg.version, actual:installed.version});
|
|
@@ -33,39 +62,146 @@ async function loadPack(pack) {
|
|
|
33
62
|
const file = path.join(directory, face.file);
|
|
34
63
|
const data = await verifiedFile(file, face.sha256, {package:pkg.name, file:face.file});
|
|
35
64
|
fontFiles.push(file);
|
|
36
|
-
entries.push({data:new Uint8Array(data), family:face.family, weight:face.weight, italic:face.italic, license});
|
|
65
|
+
entries.push({data:new Uint8Array(data), family:face.family, weight:face.weight, italic:face.italic, license, ...(pkg.scripts ? {scripts:[...pkg.scripts]} : {}), ...(fallbackOnly ? {fallbackOnly:true, embed:"used"} : {})});
|
|
37
66
|
}
|
|
38
67
|
}
|
|
68
|
+
return {entries, fontFiles, packages:packages.map(pkg => pkg.name)};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A package that is the renamed successor of a family (Source Sans 3, formerly Source Sans Pro) answers to the
|
|
72
|
+
// old name through a built-in alias, reported visual like other aliases; it is not a claim of the old face.
|
|
73
|
+
const renamedAliases = () => Object.fromEntries(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "open" && item.renamedFrom).map(item => [item.renamedFrom, item.faces[0].family]));
|
|
74
|
+
// The vendored faces this registry holds that are embed "used": a host that serves fonts itself lists them to copy their files.
|
|
75
|
+
const lazyOf = registry => lazyFontList().filter(face => registry.describeFaces().some(held => held.family === face.family && held.weight === face.weight && held.italic === face.italic));
|
|
76
|
+
const loadPack = pack => loadPackages(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === pack));
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Adds the script pack. `scripts` is 'all', ISO 15924 codes, or 'auto' (FF-19): detect the scripts the
|
|
80
|
+
* `presentation` draws and load only their faces. Auto never fails on a missing optional package or a
|
|
81
|
+
* script no pinned font serves; it reports them through `onDiagnostic` and `selection`.
|
|
82
|
+
*/
|
|
83
|
+
async function withScripts(loaded, scripts, {presentation, onDiagnostic} = {}) {
|
|
84
|
+
if (scripts === undefined || (Array.isArray(scripts) && !scripts.length)) return loaded;
|
|
85
|
+
// A package the registry already loaded (the default Noto Sans fallback) is never loaded twice.
|
|
86
|
+
const notLoaded = list => list.filter(pkg => !loaded.packages?.includes(pkg.name));
|
|
87
|
+
if (scripts !== "auto") {
|
|
88
|
+
const extra = await loadPackages(notLoaded(scriptFontPackages(scripts)));
|
|
89
|
+
return {...loaded, entries:[...loaded.entries, ...embedUsed(extra.entries)], fontFiles:[...loaded.fontFiles, ...extra.fontFiles], packages:[...(loaded.packages ?? []), ...extra.packages]};
|
|
90
|
+
}
|
|
91
|
+
if (presentation === null || typeof presentation !== "object") throw new OPFFontError("invalid-font-scripts", "scripts: 'auto' needs the presentation whose text decides the scripts.", {scripts});
|
|
92
|
+
const selection = scriptSelectionOf(analyzePresentationScripts(presentation)), skipped = [];
|
|
93
|
+
const extra = await loadPackages(notLoaded(scriptFontPackages(selection.scripts)), skipped);
|
|
94
|
+
for (const script of selection.unavailable) onDiagnostic?.({code: "script-font-unavailable", script, message: `No pinned open font serves script '${script}'; that text uses the design font.`});
|
|
95
|
+
for (const item of skipped) onDiagnostic?.({code: "script-font-not-installed", package: item.package, scripts: item.scripts, message: `Install ${item.package}@${item.version} to preview ${item.scripts.join(", ")} text with its designated open font.`});
|
|
96
|
+
return {entries:[...loaded.entries, ...embedUsed(extra.entries)], fontFiles:[...loaded.fontFiles, ...extra.fontFiles], packages:[...(loaded.packages ?? []), ...extra.packages],
|
|
97
|
+
selection:{...selection, packages: scriptFontPackages(selection.scripts).map(item => item.name).filter(name => !skipped.some(item => item.package === name)), notInstalled: skipped.map(item => item.package)}};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* scripts: 'auto' with glyph fallback (FF-19): a drawn Han, kana or Hangul character that no loaded script face
|
|
102
|
+
* has (Japanese-only kanji in a Simplified Chinese run, hanja) needs the next CJK face along the fallback chain.
|
|
103
|
+
* Adds those packages to the registry and its fontFiles until every such character is covered or the chain ends.
|
|
104
|
+
*/
|
|
105
|
+
async function completeFallback(registry, presentation, loaded, onDiagnostic) {
|
|
106
|
+
const selection = loaded.selection;
|
|
107
|
+
if (!selection) return;
|
|
108
|
+
const seen = new Set([...selection.packages, ...selection.notInstalled]);
|
|
109
|
+
for (;;) {
|
|
110
|
+
const analysis = analyzePresentationScripts(presentation), covers = character => registry.scriptFacesCover(character);
|
|
111
|
+
const next = nextFallbackPackage(analysis, {covers, loaded: seen});
|
|
112
|
+
if (!next) {
|
|
113
|
+
const uncovered = uncoveredCjkCharacters(analysis, {covers, limit: 8});
|
|
114
|
+
if (uncovered.length) { selection.uncovered = uncovered; onDiagnostic?.({code: "script-glyph-uncovered", characters: uncovered, message: `No loaded script face has ${uncovered.map(character => "U+" + character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")).join(", ")}; the preview reports missing-glyph for them.`}); }
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
seen.add(next.name);
|
|
118
|
+
const skipped = [], extra = await loadPackages([next], skipped);
|
|
119
|
+
if (skipped.length) {
|
|
120
|
+
selection.notInstalled.push(next.name);
|
|
121
|
+
onDiagnostic?.({code: "script-font-not-installed", package: next.name, scripts: [...next.scripts], message: `Install ${next.name}@${next.version} for glyphs the loaded CJK faces lack.`});
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
registry.addFaces(embedUsed(extra.entries));
|
|
125
|
+
loaded.fontFiles.push(...extra.fontFiles);
|
|
126
|
+
selection.packages.push(next.name);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** FF-31: caller-supplied faces, for example the caller's own licensed Aptos files. Plain
|
|
131
|
+
* FontFaceInput entries; Node callers may pass `path` instead of `data`. They come first, so the
|
|
132
|
+
* real family resolves as an exact face ahead of any replacement. */
|
|
133
|
+
async function callerFaces(faces = []) {
|
|
134
|
+
if (!Array.isArray(faces)) throw new OPFFontError("invalid-font-data", "faces must be an array of font entries.");
|
|
135
|
+
const entries = [], fontFiles = [];
|
|
136
|
+
for (const face of faces) {
|
|
137
|
+
if (face?.data instanceof Uint8Array) { entries.push(face); continue; }
|
|
138
|
+
if (typeof face?.path !== "string") throw new OPFFontError("invalid-font-data", "Each caller face needs data (Uint8Array) or a file path.");
|
|
139
|
+
let data;
|
|
140
|
+
try { data = await readFile(face.path); } catch (error) { throw new OPFFontError("font-resource-unavailable", `Cannot read caller font file ${face.path}.`, {file:face.path, cause:error.code}); }
|
|
141
|
+
const {path:file, ...rest} = face;
|
|
142
|
+
entries.push({...rest, data:new Uint8Array(data)});
|
|
143
|
+
fontFiles.push(path.resolve(file));
|
|
144
|
+
}
|
|
39
145
|
return {entries, fontFiles};
|
|
40
146
|
}
|
|
41
147
|
|
|
148
|
+
async function withFaces(loaded, faces) {
|
|
149
|
+
if (!faces?.length) return loaded;
|
|
150
|
+
const own = await callerFaces(faces);
|
|
151
|
+
return {...loaded, entries:[...own.entries, ...loaded.entries], fontFiles:[...own.fontFiles, ...loaded.fontFiles]};
|
|
152
|
+
}
|
|
153
|
+
|
|
42
154
|
/** Bundled, openly licensed faces. No system font discovery or network requests. */
|
|
43
|
-
export async function loadBundledFontRegistry(options = {}) {
|
|
44
|
-
const {entries, fontFiles} = await loadPack("base");
|
|
45
|
-
|
|
155
|
+
export async function loadBundledFontRegistry({scripts, faces, presentation, onDiagnostic, ...options} = {}) {
|
|
156
|
+
const {entries, fontFiles, selection} = await withFaces(await withScripts(await loadPack("base"), scripts, {presentation, onDiagnostic}), faces);
|
|
157
|
+
const registry = Object.assign(createFontRegistry(entries,options),{fontFiles, ...(selection ? {scriptSelection: selection} : {})});
|
|
158
|
+
registry.lazyFonts = lazyOf(registry);
|
|
159
|
+
await completeFallback(registry, presentation, {fontFiles, selection}, onDiagnostic);
|
|
160
|
+
return registry;
|
|
46
161
|
}
|
|
47
162
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
163
|
+
/** Office substitutes plus the open families that font schemes select (FF-31),
|
|
164
|
+
* optionally alongside the base Roboto pack. `includeOpenFonts: false` leaves the open families out. */
|
|
165
|
+
export async function loadOfficeFontRegistry({scripts, faces, presentation, onDiagnostic, ...options} = {}) {
|
|
166
|
+
const {entries, fontFiles, packages} = await loadPack("office");
|
|
167
|
+
for (const [include, pack] of [[options.includeOpenFonts, "open"], [options.includeBaseFonts, "base"]]) {
|
|
168
|
+
if (include === false) continue;
|
|
169
|
+
const extra = await loadPack(pack);
|
|
170
|
+
fontFiles.push(...extra.fontFiles);
|
|
171
|
+
entries.push(...extra.entries);
|
|
172
|
+
packages.push(...extra.packages);
|
|
55
173
|
}
|
|
56
|
-
|
|
174
|
+
// Noto Sans (regular, bold, italic, bold italic) is the default Latin, Cyrillic and Greek fallback face
|
|
175
|
+
// (glyph fallback), so Georgia with Russian text previews without the scripts option. It is embedded in an
|
|
176
|
+
// SVG only when the slide's text draws it (embed "used"); raster output reads it from fontFiles. Unless the
|
|
177
|
+
// caller asked for it with scripts it is fallback-only, so no other family's preview changes.
|
|
178
|
+
const requested = scripts === undefined || scripts === "auto" || (Array.isArray(scripts) && !scripts.length) ? [] : scriptFontPackages(scripts).map(pkg => pkg.name);
|
|
179
|
+
const fallback = await loadPackages(scriptFontPackages(["Latn"]).filter(pkg => !requested.includes(pkg.name)), undefined, {fallbackOnly:true});
|
|
180
|
+
fontFiles.push(...fallback.fontFiles);
|
|
181
|
+
entries.push(...fallback.entries);
|
|
182
|
+
packages.push(...fallback.packages);
|
|
183
|
+
const loaded = await withFaces(await withScripts({entries, fontFiles, packages}, scripts, {presentation, onDiagnostic}), faces);
|
|
184
|
+
const aliases = options.includeOpenFonts === false ? options.aliases : {...renamedAliases(), ...options.aliases};
|
|
185
|
+
const registry = Object.assign(createFontRegistry(loaded.entries,{substitutionPolicy:"metric",...options,...(aliases?{aliases}:{})}),{fontFiles:loaded.fontFiles, ...(loaded.selection ? {scriptSelection: loaded.selection} : {})});
|
|
186
|
+
registry.lazyFonts = lazyOf(registry);
|
|
187
|
+
await completeFallback(registry, presentation, loaded, onDiagnostic);
|
|
188
|
+
return registry;
|
|
57
189
|
}
|
|
58
190
|
|
|
59
191
|
/** One set of verified font inputs for layout, SVG, editor, PPTX, and Node raster export. */
|
|
60
|
-
export async function prepareNodeFonts({pack = "base", ...options} = {}) {
|
|
192
|
+
export async function prepareNodeFonts({pack = "base", embedScriptFonts = false, ...options} = {}) {
|
|
61
193
|
if (pack !== "base" && pack !== "office") throw new OPFFontError("invalid-font-pack", "Choose the base or office font pack.", {pack});
|
|
62
194
|
const registry = await (pack === "base" ? loadBundledFontRegistry(options) : loadOfficeFontRegistry(options));
|
|
195
|
+
// Script faces are large (CJK faces are 5-10 MB each). Raster output reads them
|
|
196
|
+
// from fontFiles; embed them in standalone SVG only on request. The default Noto Sans fallback is always offered
|
|
197
|
+
// (embed "used"), so an SVG carries it exactly when its text draws it.
|
|
198
|
+
const embeddedFonts = registry.selectEmbeddedFonts(face => embedScriptFonts || !face.scripts || face.fallbackOnly);
|
|
63
199
|
return {
|
|
64
200
|
registry,
|
|
65
201
|
manifest:BUNDLED_FONT_MANIFEST,
|
|
66
202
|
options:{
|
|
67
203
|
textMeasurement:registry.textMeasurement,
|
|
68
|
-
embeddedFonts
|
|
204
|
+
embeddedFonts,
|
|
69
205
|
fontFiles:[...registry.fontFiles],
|
|
70
206
|
useBundledFonts:false,
|
|
71
207
|
loadSystemFonts:false,
|
package/dist/fonts.d.ts
CHANGED
|
@@ -1,21 +1,103 @@
|
|
|
1
1
|
import type { TextMeasurement, TextStyle } from "@openpresentation/opf/composition";
|
|
2
|
-
export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string }
|
|
3
|
-
export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string }
|
|
2
|
+
export interface FontFaceInput { data: Uint8Array; family?: string; weight?: number; italic?: boolean; postscriptName?: string; license?: string; /** Serves glyph fallback and requests by its own family only; never a replacement for another family. */ fallbackOnly?: boolean; /** "used" embeds the face in an SVG only when the slide's text names its family (the open pack, FF-31). Default "always". */ embed?: "always" | "used"; /** ISO 15924 scripts a designated script replacement face serves (FF-19). */ scripts?: string[] }
|
|
3
|
+
export interface EmbeddedFont { family: string; weight: number; italic?: boolean; dataUrl: string; license?: string; embed?: "used" }
|
|
4
4
|
export type FontCompatibility = "exact" | "metric" | "visual" | "generic";
|
|
5
|
-
export interface
|
|
5
|
+
export interface FontReplacementMeasurement { replacement:string; meanAbsWidthDelta:number; meanWidthDelta:number; maxAbsWidthDelta:number; styles:number; reference:string }
|
|
6
|
+
export interface FontResolution {
|
|
7
|
+
requestedFamily:string; sourceFamily:string; resolvedFamily:string; requestedWeight:number; resolvedWeight:number; italic:boolean; compatibility:FontCompatibility;
|
|
8
|
+
/** True when the face is not the chosen family itself (policy replacement, alias, script replacement or fallback).
|
|
9
|
+
* Exporters keep writing sourceFamily; the face only drives measurement and drawing (FF-31). */
|
|
10
|
+
substitute:boolean;
|
|
11
|
+
/** A visual replacement without the requested italic/upright style draws the other style. */
|
|
12
|
+
styleFallback?:true;
|
|
13
|
+
fontFace?:TextStyle['fontFace']; path?:string; source?:string; note?:string;
|
|
14
|
+
/** The provisional owner decision the replacement follows, if any. */
|
|
15
|
+
decision?:string;
|
|
16
|
+
/** Measured width difference of the declared replacement against the real font. */
|
|
17
|
+
measured?:FontReplacementMeasurement;
|
|
18
|
+
licenseClass?:FontPolicyEntry['licenseClass']; availability?:FontPolicyEntry['availability'];
|
|
19
|
+
}
|
|
20
|
+
/** One OPF font policy row (snapshot of opf spec/reference/font-policy.json, decisions applied). */
|
|
21
|
+
export interface FontPolicyEntry {
|
|
22
|
+
family:string; licenseClass:"open"|"proprietary-standard"|"proprietary-nonstandard"; license:string;
|
|
23
|
+
availability:("windows"|"windows-optional"|"macos"|"office"|"office-cloud")[]; embeddableByOpf:boolean;
|
|
24
|
+
replacement:{family:string; compatibility:"metric"|"visual"; decision?:string; metricModeFallback?:true; weight?:number; measured:FontReplacementMeasurement|null; source?:string}|null;
|
|
25
|
+
alternates?:string[];
|
|
26
|
+
}
|
|
27
|
+
export declare const FONT_POLICY: readonly Readonly<FontPolicyEntry>[];
|
|
28
|
+
export declare const FONT_POLICY_SOURCE: Readonly<{version:number; path:string; sha256:string}>;
|
|
29
|
+
/** Provisional owner decisions applied to the snapshot (owner may revise). */
|
|
30
|
+
export declare const FONT_POLICY_DECISIONS: Readonly<Record<string, unknown>>;
|
|
31
|
+
export declare function fontPolicyFor(family:string): Readonly<FontPolicyEntry>|undefined;
|
|
6
32
|
export interface FontRegistryOptions {
|
|
7
33
|
aliases?: Record<string,string>; fallbackFamily?: string; strictGlyphs?: boolean;
|
|
8
34
|
substitutionPolicy?: "none" | "metric" | "visual";
|
|
9
35
|
themeFonts?: Partial<Record<"majorLatin"|"minorLatin"|"majorEastAsia"|"minorEastAsia"|"majorComplexScript"|"minorComplexScript",string>>;
|
|
10
36
|
}
|
|
11
|
-
export declare const FONT_COMPATIBILITY: readonly Readonly<{requestedFamily:string;substitutes:readonly string[];compatibility:"metric"|"visual";weights?:readonly number[];source?:string;note:string}>[];
|
|
37
|
+
export declare const FONT_COMPATIBILITY: readonly Readonly<{requestedFamily:string;substitutes:readonly string[];compatibility:"metric"|"visual";weights?:readonly number[];weight?:number;source?:string;measured?:FontReplacementMeasurement;decision?:string;metricModeFallback?:true;licenseClass?:FontPolicyEntry['licenseClass'];note:string}>[];
|
|
12
38
|
export declare const EXPERIMENTAL_FONT_CANDIDATES: readonly Readonly<{requestedFamily:string;substitute:string;source:string;note:string}>[];
|
|
39
|
+
export interface FaceDescription { family:string; weight:number; italic:boolean; scripts?:string[] }
|
|
13
40
|
export interface FontRegistry {
|
|
14
|
-
textMeasurement: TextMeasurement & {outlineBounds: NonNullable<TextMeasurement['outlineBounds']
|
|
41
|
+
textMeasurement: TextMeasurement & {outlineBounds: NonNullable<TextMeasurement['outlineBounds']>; resolveFont(style: TextStyle): FontResolution};
|
|
15
42
|
clearSubstitutions(): void;
|
|
16
43
|
resolveFont(style: TextStyle): FontResolution;
|
|
17
44
|
readonly embeddedFonts: EmbeddedFont[];
|
|
45
|
+
/** Embedded faces matching `predicate`; large script faces can stay with raster fontFiles. */
|
|
46
|
+
selectEmbeddedFonts(predicate: (face: FaceDescription) => boolean): EmbeddedFont[];
|
|
47
|
+
/** Parsed face metadata in entry order, without encoding font bytes. */
|
|
48
|
+
describeFaces(): FaceDescription[];
|
|
18
49
|
readonly substitutions: FontResolution[];
|
|
50
|
+
/** True when a loaded script-pack face has a glyph for the character. */
|
|
51
|
+
scriptFacesCover(character: string): boolean;
|
|
52
|
+
/** Register more faces (FF-19 script faces once a document needs them). Atomic; returns the added faces' metadata. */
|
|
53
|
+
addFaces(entries: FontFaceInput[]): { family: string; weight: number; italic: boolean; scripts?: string[] }[];
|
|
19
54
|
}
|
|
20
55
|
export declare class OPFFontError extends Error { code:string; details:Record<string,unknown>; constructor(code:string,message:string,details?:Record<string,unknown>) }
|
|
21
56
|
export declare function createFontRegistry(entries: FontFaceInput[], options?: FontRegistryOptions): FontRegistry;
|
|
57
|
+
|
|
58
|
+
/** OOXML script slot. */
|
|
59
|
+
export type ScriptRole = "latin" | "eastAsian" | "complexScript";
|
|
60
|
+
export interface ScriptFontSlots { latin: string; eastAsian: string; complexScript: string }
|
|
61
|
+
/** Core `resolveScriptFonts` output, or the same shape. `serif` prefers serif replacements. */
|
|
62
|
+
export interface ScriptFontProfile {
|
|
63
|
+
heading?: ScriptFontSlots; body?: ScriptFontSlots;
|
|
64
|
+
supplement?: { script: string; heading: string; body: string };
|
|
65
|
+
script?: string; bcp47?: string; lang?: string; direction?: "ltr" | "rtl"; rtl?: boolean;
|
|
66
|
+
languageSource?: "document" | "option" | "default"; scriptRole?: ScriptRole; serif?: boolean;
|
|
67
|
+
}
|
|
68
|
+
export interface ScriptRun { text: string; script: string; role: ScriptRole }
|
|
69
|
+
export interface PlannedRun { text: string; family: string; own: boolean; stack?: string[] }
|
|
70
|
+
export interface ScriptFonts {
|
|
71
|
+
readonly profile: ScriptFontProfile;
|
|
72
|
+
readonly rtl: boolean;
|
|
73
|
+
/** Glyph fallback notes recorded so far. */
|
|
74
|
+
readonly fallbacks: readonly GlyphFallbackNote[];
|
|
75
|
+
/** Font runs for text drawn in a resolved latin style. */
|
|
76
|
+
plan(text: string, style: TextStyle): PlannedRun[];
|
|
77
|
+
runWidths(runs: PlannedRun[], size: number, style: TextStyle): number[] | undefined;
|
|
78
|
+
/** Script-aware measurement; undefined without a measurement provider. */
|
|
79
|
+
readonly textMeasurement?: TextMeasurement;
|
|
80
|
+
}
|
|
81
|
+
export declare const SCRIPT_FONT_FAMILIES: Readonly<Record<string, Readonly<{ sans?: string; serif?: string }>>>;
|
|
82
|
+
export declare const SCRIPT_FONT_REPLACEMENTS: readonly Readonly<{requestedFamily:string;script:string;substitutes:readonly string[];compatibility:"visual";note:string}>[];
|
|
83
|
+
export declare function scriptOfCharacter(character: string): string;
|
|
84
|
+
export declare function scriptFontRole(script: string): ScriptRole;
|
|
85
|
+
export declare function itemizeScripts(text: string, profile?: ScriptFontProfile): ScriptRun[];
|
|
86
|
+
/** Script keys in the strings of a JSON value. The profile's own script counts unless `includeLanguage` is false; `ignoreKeys` are not visited. */
|
|
87
|
+
export declare function detectScripts(value: unknown, profile?: ScriptFontProfile, options?: { includeLanguage?: boolean; ignoreKeys?: readonly string[] }): string[];
|
|
88
|
+
export declare function designatedFamilies(script: string, serif?: boolean): string[];
|
|
89
|
+
export declare function scriptFontAliases(families: Iterable<string>): Record<string, string>;
|
|
90
|
+
export interface GlyphFallbackNote { fontFamily: string; fallbackFamily: string; scripts: string[]; characters: string[]; path?: string }
|
|
91
|
+
export interface ScriptFontsOptions {
|
|
92
|
+
/** `"chain"` (default) draws and measures a character the face lacks with the first bundled face that has it; `"none"` keeps exact faces and a missing glyph raises `missing-glyph`. */
|
|
93
|
+
glyphFallback?: "chain" | "none";
|
|
94
|
+
onFallback?: (note: GlyphFallbackNote) => void;
|
|
95
|
+
}
|
|
96
|
+
export declare function createScriptFonts(profile?: ScriptFontProfile, measurement?: TextMeasurement, options?: ScriptFontsOptions): ScriptFonts;
|
|
97
|
+
/** Designated open families a preview tries, in order, for a character its face lacks (own script, deck script, Noto Sans, other CJK, other Noto scripts). */
|
|
98
|
+
export declare function glyphFallbackFamilies(character: string, profile?: ScriptFontProfile, serif?: boolean): string[];
|
|
99
|
+
/** Wrap a measurement so pagination, the renderer and the editor itemize script runs identically. */
|
|
100
|
+
export declare function createScriptTextMeasurement(measurement: TextMeasurement, profile: ScriptFontProfile, options?: ScriptFontsOptions): TextMeasurement;
|
|
101
|
+
export declare function openTypeLanguage(tag: string | undefined): string | undefined;
|
|
102
|
+
/** Heading or body role of a style from its OPF path; undefined without a slide path. */
|
|
103
|
+
export declare function textRole(style: { path?: string } | undefined): "heading" | "body" | undefined;
|
package/dist/fonts.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { create } from "fontkit";
|
|
2
|
-
import { FONT_COMPATIBILITY } from "./font-compatibility.js";
|
|
3
|
-
|
|
2
|
+
import { FONT_COMPATIBILITY, disabledFeaturesFor } from "./font-compatibility.js";
|
|
3
|
+
import { openTypeLanguage, scriptFontAliases } from "./script-fonts.js";
|
|
4
|
+
import { fontPolicyFor } from "./font-policy.js";
|
|
5
|
+
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
6
|
+
export { FONT_COMPATIBILITY, EXPERIMENTAL_FONT_CANDIDATES, disabledFeaturesFor } from "./font-compatibility.js";
|
|
7
|
+
export { FONT_POLICY, FONT_POLICY_DECISIONS, FONT_POLICY_SOURCE, fontPolicyFor } from "./font-policy.js";
|
|
8
|
+
export { SCRIPT_FONT_FAMILIES, SCRIPT_FONT_REPLACEMENTS, createScriptFonts, createScriptTextMeasurement, designatedFamilies, glyphFallbackFamilies, detectScripts, itemizeScripts, openTypeLanguage, scriptFontAliases, scriptFontRole, scriptOfCharacter, textRole } from "./script-fonts.js";
|
|
4
9
|
|
|
5
10
|
export class OPFFontError extends Error {
|
|
6
11
|
constructor(code, message, details = {}) { super(message); this.name = "OPFFontError"; this.code = code; this.details = details; }
|
|
@@ -15,10 +20,34 @@ function validFamily(family) {
|
|
|
15
20
|
if (typeof family !== "string" || !family.trim() || /[\u0000-\u001f"'\\<>;]/.test(family)) throw new OPFFontError("invalid-font-family", "Font family must be a nonempty plain name.");
|
|
16
21
|
return family;
|
|
17
22
|
}
|
|
23
|
+
// The open families load with the office pack (prepareNodeFonts({pack:"office"})).
|
|
24
|
+
const packFor = family => { const pack = BUNDLED_FONT_MANIFEST.packages.find(pkg=>pkg.renamedFrom?.toLowerCase()===family.toLowerCase() || pkg.faces.some(face=>face.family.toLowerCase()===family.toLowerCase()))?.pack; return pack==="open" ? "office" : pack; };
|
|
25
|
+
const percent = value => `${(value*100).toFixed(1)}%`;
|
|
26
|
+
/** FF-31: say why a family has no face and what the caller can do, from the OPF font policy. */
|
|
27
|
+
function unavailableFontError(family, style, policy) {
|
|
28
|
+
const row = fontPolicyFor(family), details = {path:style.path, fontFamily:family, substitutionPolicy:policy};
|
|
29
|
+
const hook = `supply licensed '${family}' font files (prepareNodeFonts({faces}) or createFontRegistry entries)`;
|
|
30
|
+
if (!row) return new OPFFontError("font-unavailable", `No local font face for '${family}'. '${family}' is not in the OPF font policy table, so there is no declared replacement: ${hook}, add an alias, or set fallbackFamily.`, {...details, licenseClass:"unknown"});
|
|
31
|
+
Object.assign(details, {licenseClass:row.licenseClass});
|
|
32
|
+
if (!row.replacement) {
|
|
33
|
+
const pack = packFor(family);
|
|
34
|
+
return new OPFFontError("font-unavailable", `No local font face for '${family}'. '${family}' is ${row.licenseClass === "open" ? `openly licensed (${row.license})` : row.license}${pack ? `; load the '${pack}' font pack` : `; no pinned renderer pack ships it yet, so supply its font files (for example from @expo-google-fonts/${family.toLowerCase().replace(/ +/g,"-")})`}.`, {...details, ...(pack?{pack}:{})});
|
|
35
|
+
}
|
|
36
|
+
const {family:replacement, compatibility, measured} = row.replacement;
|
|
37
|
+
const candidates = [replacement, ...(row.alternates ?? [])], packs = [...new Set(candidates.map(packFor).filter(Boolean))];
|
|
38
|
+
Object.assign(details, {replacement, replacementCompatibility:compatibility, candidates, ...(packs.length?{packs}:{}), ...(measured?{measured}:{}), ...(row.replacement.decision?{decision:row.replacement.decision}:{})});
|
|
39
|
+
const delta = measured ? ` (measured mean width difference ${percent(measured.meanAbsWidthDelta)}, max ${percent(measured.maxAbsWidthDelta)})` : " (not measured against the real font)";
|
|
40
|
+
const owner = `'${family}' is ${row.license} and OPF never bundles or embeds it.`;
|
|
41
|
+
if (compatibility === "visual" && policy !== "visual")
|
|
42
|
+
return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} Its declared replacement ${replacement} is visual only${delta}, which substitutionPolicy '${policy}' does not allow. Pass substitutionPolicy: 'visual' to preview with ${candidates.join(" or ")}, or ${hook}. The PPTX names '${family}' either way.`, details);
|
|
43
|
+
if (policy === "none")
|
|
44
|
+
return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} Its declared ${compatibility} replacement is ${replacement}${delta}; substitutionPolicy 'none' does not allow it. Pass substitutionPolicy: '${compatibility}', or ${hook}.`, details);
|
|
45
|
+
return new OPFFontError("font-unavailable", `No local font face for '${family}'. ${owner} None of its replacements (${candidates.join(", ")}) is loaded${packs.length ? `; load the ${packs.map(pack=>"'"+pack+"'").join(" or ")} font pack` : ""}, or ${hook}. The PPTX names '${family}' either way.`, details);
|
|
46
|
+
}
|
|
18
47
|
/** Local font files only. The caller explicitly chooses aliases and fallback. */
|
|
19
48
|
export function createFontRegistry(entries, options = {}) {
|
|
20
49
|
if (!Array.isArray(entries) || !entries.length) throw new OPFFontError("empty-font-registry", "Supply at least one font file.");
|
|
21
|
-
const
|
|
50
|
+
const makeFace = entry => {
|
|
22
51
|
if (!(entry.data instanceof Uint8Array)) throw new OPFFontError("invalid-font-data", "Font data must be a Uint8Array.");
|
|
23
52
|
const data = entry.data.slice();
|
|
24
53
|
let font;
|
|
@@ -38,22 +67,42 @@ export function createFontRegistry(entries, options = {}) {
|
|
|
38
67
|
if (!Number.isInteger(weight) || weight < 1 || weight > 1000) throw new OPFFontError("invalid-font-weight", "Font weight must be between 1 and 1000.");
|
|
39
68
|
const signature = String.fromCharCode(...data.subarray(0,4));
|
|
40
69
|
const format = signature === "OTTO" ? "otf" : signature === "wOFF" ? "woff" : signature === "wOF2" ? "woff2" : "ttf";
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
const familySlots = new Map();
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
70
|
+
// Script replacement faces (FF-19) declare the ISO 15924 scripts they serve.
|
|
71
|
+
const scripts = Array.isArray(entry.scripts) && entry.scripts.length ? Object.freeze(entry.scripts.map(String)) : undefined;
|
|
72
|
+
// "used": a bundled face is embedded in an SVG only when the slide's text names its family.
|
|
73
|
+
if (entry.embed !== undefined && entry.embed !== "always" && entry.embed !== "used") throw new OPFFontError("invalid-font-embed", "Font embed must be 'always' or 'used'.");
|
|
74
|
+
// A fallback-only face (the default Noto Sans) serves glyph fallback and explicit requests by its own name,
|
|
75
|
+
// but never stands in for another family, so loading it does not change what any other font previews with.
|
|
76
|
+
return {family,familyGroup,fontFace,weight,italic,font,data,format,license:entry.license,embed:entry.embed,scripts,fallbackOnly:entry.fallbackOnly===true,cache:new Map()};
|
|
77
|
+
};
|
|
78
|
+
const faces = [], duplicates = new Set(), familySlots = new Map();
|
|
79
|
+
// Adds faces atomically: every check runs before any face is registered.
|
|
80
|
+
const register = added => {
|
|
81
|
+
const ids = new Set(duplicates), slots = new Map(familySlots);
|
|
82
|
+
for (const face of added) {
|
|
83
|
+
const id = key(face.family,face.weight,face.italic);
|
|
84
|
+
if (ids.has(id)) throw new OPFFontError("duplicate-font-face", `Duplicate face: ${id}`);
|
|
85
|
+
ids.add(id);
|
|
86
|
+
}
|
|
87
|
+
for (const face of added) for (const family of new Set([face.family,face.familyGroup])) {
|
|
88
|
+
const id=key(family,face.weight,face.italic),previous=slots.get(id);
|
|
89
|
+
if(previous && previous!==face) throw new OPFFontError('ambiguous-font-face', `Multiple physical faces match '${family}' at weight ${face.weight}.`, {fontFamily:family,fontWeight:face.weight,italic:face.italic});
|
|
90
|
+
slots.set(id,face);
|
|
91
|
+
}
|
|
92
|
+
faces.push(...added);
|
|
93
|
+
for (const id of ids) duplicates.add(id);
|
|
94
|
+
for (const [id,face] of slots) familySlots.set(id,face);
|
|
95
|
+
};
|
|
96
|
+
register(entries.map(makeFace));
|
|
55
97
|
const substitutions = new Map();
|
|
56
|
-
|
|
98
|
+
// Loaded script replacement faces stand in for the proprietary script fonts
|
|
99
|
+
// they replace (Meiryo -> Noto Sans JP). Caller aliases take precedence.
|
|
100
|
+
let aliases = new Map();
|
|
101
|
+
const buildAliases = () => {
|
|
102
|
+
const scriptAliases = scriptFontAliases(new Set(faces.filter(face=>face.scripts&&!face.fallbackOnly).map(face=>face.family)));
|
|
103
|
+
aliases = new Map(Object.entries({...scriptAliases,...options.aliases}).map(([from,to])=>[validFamily(from).toLowerCase(),validFamily(to)]));
|
|
104
|
+
};
|
|
105
|
+
buildAliases();
|
|
57
106
|
const policy = options.substitutionPolicy ?? "none";
|
|
58
107
|
if (!["none","metric","visual"].includes(policy)) throw new OPFFontError("invalid-font-policy", "substitutionPolicy must be none, metric, or visual.");
|
|
59
108
|
if (options.fallbackFamily) validFamily(options.fallbackFamily);
|
|
@@ -68,33 +117,56 @@ export function createFontRegistry(entries, options = {}) {
|
|
|
68
117
|
const family = themeKey ? options.themeFonts?.[themeKey] : requested;
|
|
69
118
|
if (!family || family.startsWith("+")) throw new OPFFontError("unresolved-theme-font", `Supply a concrete theme family for '${requested}'.`, {path:style.path});
|
|
70
119
|
validFamily(family);
|
|
71
|
-
let matching = findFamily(family), compatibility = "exact", rule;
|
|
120
|
+
let matching = findFamily(family), compatibility = "exact", rule, targetWeight = weight, styleFallback = false, via = "family";
|
|
72
121
|
const encodedSymbol = /^(wingdings(?: [23])?|webdings|symbol)$/i.test(family);
|
|
73
122
|
if (!matching.length && encodedSymbol) throw new OPFFontError("font-encoding-required", `Font '${family}' requires character mapping before substitution.`, {path:style.path,fontFamily:family});
|
|
74
123
|
if (!matching.length && aliases.has(family.toLowerCase())) {
|
|
75
|
-
matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual";
|
|
124
|
+
matching = findFamily(aliases.get(family.toLowerCase())); compatibility = "visual"; via = "alias";
|
|
76
125
|
}
|
|
77
126
|
if (!matching.length && policy!=="none") {
|
|
78
127
|
const candidate = FONT_COMPATIBILITY.find(entry=>entry.requestedFamily.toLowerCase()===family.toLowerCase());
|
|
79
128
|
const tier = candidate?.compatibility==="metric" && !candidate.weights.includes(weight) ? "visual" : candidate?.compatibility;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
129
|
+
// A declared visual replacement is used in visual mode, and in metric mode only when the
|
|
130
|
+
// policy keeps it as a metric-mode fallback (Cambria -> Caladea); it is still reported visual.
|
|
131
|
+
// In metric mode the fallback applies only where the pre-FF-31 metric rule did: the declared
|
|
132
|
+
// replacement at weights 400 and 700, normal or italic, with an exact face weight.
|
|
133
|
+
const metricFallback = policy!=="visual" && candidate?.metricModeFallback===true && [400,700].includes(weight);
|
|
134
|
+
const allowVisual = policy==="visual" || metricFallback;
|
|
135
|
+
if (candidate && (allowVisual || tier==="metric")) {
|
|
136
|
+
// A family that names its weight (Segoe UI Semibold, Arial Black) selects that weight in
|
|
137
|
+
// the replacement; its bold style link still selects bold (FF-31).
|
|
138
|
+
const wanted = candidate.weight ? (weight>=600 ? Math.max(candidate.weight,700) : candidate.weight) : weight;
|
|
139
|
+
for (const [index, substitute] of candidate.substitutes.entries()) {
|
|
140
|
+
// Only the declared replacement can carry the row's metric claim; an alternate is visual.
|
|
141
|
+
const substituteTier = index===0 ? tier : "visual";
|
|
142
|
+
if (substituteTier==="visual" && !(policy==="visual" || metricFallback && index===0)) continue;
|
|
143
|
+
const exactWeight = substituteTier==="metric" || policy!=="visual";
|
|
144
|
+
const faces = findFamily(substitute).filter(face=>!face.fallbackOnly && (!exactWeight || face.weight===weight));
|
|
145
|
+
let available = faces.filter(face=>face.italic===!!style.italic);
|
|
146
|
+
// Visual replacements without the requested style draw the other one and say so.
|
|
147
|
+
if (!available.length && substituteTier==="visual" && policy==="visual" && faces.length) { available = faces.filter(face=>!face.italic); styleFallback = available.length>0; }
|
|
148
|
+
if (available.length) { matching=available; compatibility=substituteTier; rule={...candidate, substituteIndex:index}; targetWeight=wanted; via="replacement"; break; }
|
|
84
149
|
}
|
|
85
150
|
}
|
|
86
151
|
}
|
|
87
152
|
// Equations require an explicit math-aware choice; never fall through to body text.
|
|
88
153
|
if (!matching.length && /^(cambria math|stix two math|noto sans math)$/i.test(family)) throw new OPFFontError("math-font-required", `Supply '${family}' or an explicit math-font alias.`, {path:style.path});
|
|
89
|
-
if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; }
|
|
90
|
-
if (!matching.length) throw
|
|
91
|
-
|
|
154
|
+
if (!matching.length && options.fallbackFamily) { matching=findFamily(options.fallbackFamily); compatibility="generic"; via="fallback"; }
|
|
155
|
+
if (!matching.length) throw unavailableFontError(family, style, policy);
|
|
156
|
+
const styled = styleFallback ? matching : matching.filter(face=>face.italic===!!style.italic);
|
|
157
|
+
// Script replacement faces have no italics; use upright glyphs and advances.
|
|
158
|
+
if (!styled.length && style.italic && matching.length && matching.every(face=>face.scripts)) { if (compatibility!=="generic") compatibility="visual"; }
|
|
159
|
+
else matching = styled;
|
|
92
160
|
if (!matching.length) throw new OPFFontError("font-style-unavailable", `No ${style.italic ? "italic" : "upright"} face for '${family}'.`, {path:style.path});
|
|
93
|
-
matching.sort((a,b)=>Math.abs(a.weight-
|
|
161
|
+
matching.sort((a,b)=>Math.abs(a.weight-targetWeight)-Math.abs(b.weight-targetWeight) || a.weight-b.weight);
|
|
94
162
|
const face = matching[0];
|
|
95
|
-
if (face.weight!==
|
|
96
|
-
|
|
97
|
-
|
|
163
|
+
if ((face.weight!==targetWeight || styleFallback) && compatibility!=="generic") compatibility="visual";
|
|
164
|
+
// FF-31: `substitute` is true whenever the face is not the chosen family itself (policy
|
|
165
|
+
// replacement, alias, script replacement or fallback). Exporters keep writing sourceFamily.
|
|
166
|
+
const substitute = via!=="family", policyRow = substitute ? fontPolicyFor(family) : undefined;
|
|
167
|
+
const measured = rule?.measured && rule.substituteIndex===0 ? rule.measured : undefined;
|
|
168
|
+
const resolution={requestedFamily:requested,sourceFamily:family,resolvedFamily:face.family,requestedWeight:weight,resolvedWeight:face.weight,italic:face.italic,compatibility,substitute,...(styleFallback?{styleFallback:true}:{}),...(face.fontFace?{fontFace:{...face.fontFace}}:{}),...(style.path?{path:style.path}:{}),...(rule?{source:rule.source,note:rule.note,...(rule.decision?{decision:rule.decision}:{})}:{}),...(measured?{measured:{...measured}}:{}),...(policyRow?{licenseClass:policyRow.licenseClass,availability:[...policyRow.availability]}:{})};
|
|
169
|
+
if (face.family.toLowerCase()!==family.toLowerCase() || face.weight!==weight || face.italic!==!!style.italic) substitutions.set(JSON.stringify([requested,weight,!!style.italic,style.path]),resolution);
|
|
98
170
|
return {face,resolution};
|
|
99
171
|
};
|
|
100
172
|
const resolveFace = style => resolve(style).face;
|
|
@@ -108,7 +180,10 @@ export function createFontRegistry(entries, options = {}) {
|
|
|
108
180
|
const metrics = (text,size,style,includeOutline=false) => {
|
|
109
181
|
if (typeof text!=='string'||!Number.isFinite(size)||size<=0) throw new OPFFontError('invalid-text-measurement','Text measurement requires a string and a positive finite font size.');
|
|
110
182
|
const face=resolveFace(style);
|
|
111
|
-
|
|
183
|
+
// A BCP-47 `lang` selects the font's OpenType language system, as a browser
|
|
184
|
+
// does for an element's lang (FF-19). Without it, shaping is unchanged.
|
|
185
|
+
const language=openTypeLanguage(style.lang),key=language?`${language}\u0000${text}`:text;
|
|
186
|
+
let value=face.cache.get(key);
|
|
112
187
|
if (value===undefined) {
|
|
113
188
|
if (options.strictGlyphs!==false) for(const character of text) {
|
|
114
189
|
if (/\p{Default_Ignorable_Code_Point}/u.test(character)) continue;
|
|
@@ -116,7 +191,19 @@ export function createFontRegistry(entries, options = {}) {
|
|
|
116
191
|
}
|
|
117
192
|
}
|
|
118
193
|
if(value===undefined||includeOutline&&!Object.hasOwn(value,'outline')) {
|
|
119
|
-
|
|
194
|
+
// FF-31: features a policy row turns off in its replacement (Gelasio for Georgia: liga, clig), the
|
|
195
|
+
// same ones the SVG output turns off, so measured and drawn advances agree.
|
|
196
|
+
const off=disabledFeaturesFor(face.family),policyFeatures=off&&Object.fromEntries(off.map(tag=>[tag,false]));
|
|
197
|
+
const shape=extra=>{const features=policyFeatures||extra?{...policyFeatures,...extra}:undefined;return language?face.font.layout(text,features,undefined,language):features?face.font.layout(text,features):face.font.layout(text);};
|
|
198
|
+
let run;
|
|
199
|
+
try { run=shape(); }
|
|
200
|
+
catch (error) {
|
|
201
|
+
// fontkit rejects some mark-attachment lookups (null anchors, for example
|
|
202
|
+
// Gurmukhi tippi in Noto Sans Gurmukhi). Mark positioning moves marks
|
|
203
|
+
// without changing advances, so measure again without it (FF-19).
|
|
204
|
+
try { run=shape({abvm:false,blwm:false,mark:false,mkmk:false}); }
|
|
205
|
+
catch { throw new OPFFontError("font-shaping-failed", `Font '${face.family}' cannot shape this text.`, {path:style.path,fontFamily:face.family,cause:error?.message}); }
|
|
206
|
+
}
|
|
120
207
|
value={width:run.positions.reduce((total,position)=>total+position.xAdvance,0)/face.font.unitsPerEm,...value};
|
|
121
208
|
if(includeOutline) {
|
|
122
209
|
const bounds=run.bbox,unit=face.font.unitsPerEm;
|
|
@@ -124,8 +211,8 @@ export function createFontRegistry(entries, options = {}) {
|
|
|
124
211
|
?{x:bounds.minX/unit,y:-bounds.maxY/unit,width:bounds.width/unit,height:bounds.height/unit}:null;
|
|
125
212
|
}
|
|
126
213
|
if (text.length<=2048) {
|
|
127
|
-
if (face.cache.size>=512&&!face.cache.has(
|
|
128
|
-
face.cache.set(
|
|
214
|
+
if (face.cache.size>=512&&!face.cache.has(key)) face.cache.delete(face.cache.keys().next().value);
|
|
215
|
+
face.cache.set(key,value);
|
|
129
216
|
}
|
|
130
217
|
}
|
|
131
218
|
return value;
|
|
@@ -136,10 +223,33 @@ export function createFontRegistry(entries, options = {}) {
|
|
|
136
223
|
return bounds===null?null:{x:bounds.x*size,y:bounds.y*size,width:bounds.width*size,height:bounds.height*size};
|
|
137
224
|
};
|
|
138
225
|
return {
|
|
139
|
-
|
|
226
|
+
// resolveFont lets exporters tell a substitute from the chosen family (FF-31).
|
|
227
|
+
textMeasurement: {measure,resolveStyle,outlineBounds,resolveFont:style=>resolve(style).resolution},
|
|
140
228
|
resolveFont(style) { return resolve(style).resolution; },
|
|
141
229
|
clearSubstitutions() { substitutions.clear(); },
|
|
142
230
|
get substitutions() { return [...substitutions.values()]; },
|
|
143
|
-
|
|
231
|
+
// Faces flagged embed:"used" (the open pack, FF-31) are not in this eager list: supplied with selectEmbeddedFonts (or
|
|
232
|
+
// prepareNodeFonts().options.embeddedFonts) they are embedded only in SVGs whose text names their family.
|
|
233
|
+
get embeddedFonts() { return embedded(face=>face.embed!=="used"); },
|
|
234
|
+
/** Parsed face metadata in entry order, without encoding font bytes. */
|
|
235
|
+
describeFaces: ()=>faces.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{}),...(face.fallbackOnly?{fallbackOnly:true}:{})})),
|
|
236
|
+
/** Embedded faces for which `predicate({family,weight,italic,scripts})` holds; large script faces can be left to raster fontFiles. */
|
|
237
|
+
selectEmbeddedFonts: predicate=>embedded(predicate),
|
|
238
|
+
/** True when a loaded script-pack face has a glyph for the character (FF-19 glyph fallback planning). */
|
|
239
|
+
scriptFacesCover: character => faces.some(face => face.scripts && face.font.hasGlyphForCodePoint(character.codePointAt(0))),
|
|
240
|
+
/**
|
|
241
|
+
* Register more faces in this registry (FF-19: script faces are added once a document needs
|
|
242
|
+
* them). Atomic: on an error no face is added. Returns the added faces' metadata. Text
|
|
243
|
+
* measurements planned before the call may not know the new faces; plan again afterwards.
|
|
244
|
+
*/
|
|
245
|
+
addFaces(added) {
|
|
246
|
+
if (!Array.isArray(added)) throw new OPFFontError("invalid-font-data", "addFaces requires an array of font entries.");
|
|
247
|
+
const made = added.map(makeFace);
|
|
248
|
+
register(made);
|
|
249
|
+
buildAliases();
|
|
250
|
+
substitutions.clear();
|
|
251
|
+
return made.map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.scripts?{scripts:[...face.scripts]}:{})}));
|
|
252
|
+
},
|
|
144
253
|
};
|
|
254
|
+
function embedded(predicate) { return faces.filter(face=>predicate({family:face.family,weight:face.weight,italic:face.italic,scripts:face.scripts,embed:face.embed,fallbackOnly:face.fallbackOnly})).map(face=>({family:face.family,weight:face.weight,italic:face.italic,...(face.license ? {license:face.license} : {}),...(face.embed==="used" ? {embed:"used"} : {}),dataUrl:`data:font/${face.format};base64,${base64(face.data)}`})); }
|
|
145
255
|
}
|