@openpresentation/opf-render 0.10.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 +59 -7
- package/dist/font-compatibility.js +40 -1
- package/dist/font-manifest.js +869 -12
- package/dist/font-policy.js +86 -31
- package/dist/fonts-browser.d.ts +56 -2
- package/dist/fonts-browser.js +194 -22
- package/dist/fonts-node.d.ts +53 -9
- package/dist/fonts-node.js +121 -23
- package/dist/fonts.d.ts +20 -5
- package/dist/fonts.js +63 -27
- package/dist/index.d.ts +11 -0
- 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 +140 -1
- package/dist/script-fonts.js +161 -24
- package/dist/svg.d.ts +11 -0
- package/dist/svg.js +97 -5
- 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 +11 -10
package/dist/script-font-pack.js
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
// peers, so the renderer install stays small; hosts install only what they use.
|
|
4
4
|
import { BUNDLED_FONT_MANIFEST } from "./font-manifest.js";
|
|
5
5
|
import { OPFFontError } from "./fonts.js";
|
|
6
|
+
import * as scriptFontModule from "./script-fonts.js";
|
|
7
|
+
import { scriptOfCharacter, scriptsOfText } from "./script-fonts.js";
|
|
8
|
+
// Optional core exports are read from the namespace so an older published core still loads.
|
|
9
|
+
import * as opfCore from "@openpresentation/opf";
|
|
6
10
|
|
|
7
11
|
const SCRIPT_ALIASES = Object.freeze({Hira: "Jpan", Kana: "Jpan", Hrkt: "Jpan", Hang: "Kore", Hani: "Hans", Zyyy: "Latn"});
|
|
8
12
|
|
|
@@ -30,11 +34,146 @@ export function scriptFontPackages(scripts) {
|
|
|
30
34
|
* which `loadBrowserFontRegistry` verifies before use.
|
|
31
35
|
*/
|
|
32
36
|
export function scriptFontEntries(scripts, {baseUrl}) {
|
|
37
|
+
return scriptPackageEntries(scriptFontPackages(scripts), {baseUrl});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Browser entries for whole manifest packages (see `scriptFontEntries`). */
|
|
41
|
+
export function scriptPackageEntries(packages, {baseUrl}) {
|
|
33
42
|
if (typeof baseUrl !== "string" || !baseUrl) throw new OPFFontError("invalid-font-source", "scriptFontEntries requires the baseUrl that serves the installed font packages.");
|
|
34
43
|
const root = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
35
|
-
return
|
|
44
|
+
return packages.flatMap(pkg => pkg.faces.map(face => ({
|
|
36
45
|
url: `${root}${pkg.name.split("/").pop()}/${face.file}`,
|
|
37
46
|
family: face.family, weight: face.weight, italic: face.italic, sha256: face.sha256,
|
|
38
47
|
scripts: [...pkg.scripts], package: `${pkg.name}@${pkg.version}`, license: pkg.license,
|
|
48
|
+
// A script face is embedded in a standalone SVG only when the slide's text uses its family.
|
|
49
|
+
embed: "used",
|
|
39
50
|
})));
|
|
40
51
|
}
|
|
52
|
+
|
|
53
|
+
// Fields a preview never draws (checked against what the renderer and core composition draw).
|
|
54
|
+
// Everywhere: the schema URL, embedded assets, catalog records, speaker notes, ids, image alt text and sources,
|
|
55
|
+
// links, metadata and typed `extensions` passthrough. On the presentation: name (aria-label fallback), description,
|
|
56
|
+
// filename, author, speaker, audience, purpose, tone, takeaway, duration, tags, narrative, language and version.
|
|
57
|
+
// On a slide: beat. The organization (name) and a slide's section are drawn only as generated header/footer
|
|
58
|
+
// furniture (`organization: true` / `section: true` in a design), so they count only when a design asks for
|
|
59
|
+
// them. Everything else is drawn text: titles, text blocks, list items, runs, table cells, chart labels,
|
|
60
|
+
// categories and series names, code, quotes, metrics, timelines, furniture text.
|
|
61
|
+
const UNDRAWN_KEYS = Object.freeze(["$schema", "assets", "catalogs", "notes", "id", "alt", "altText", "src", "url", "href", "link", "metadata", "extensions"]);
|
|
62
|
+
const UNDRAWN_ROOT_KEYS = Object.freeze(["name", "description", "filename", "author", "authors", "creator", "speaker", "audience", "purpose", "tone", "takeaway", "duration", "tags", "narrative", "language", "license", "version", "keywords"]);
|
|
63
|
+
const UNDRAWN_SLIDE_KEYS = Object.freeze(["beat"]);
|
|
64
|
+
const UNDRAWN_VALUE = /^(?:https?|data|blob|pkg|file|mailto):\S*$/i;
|
|
65
|
+
|
|
66
|
+
/** True when a design in the presentation turns on generated `field` furniture (`organization` or `section`). */
|
|
67
|
+
function furnitureUses(presentation, field) {
|
|
68
|
+
const designs = [presentation?.design, ...(Array.isArray(presentation?.slides) ? presentation.slides.map(slide => slide?.design) : [])];
|
|
69
|
+
const pattern = new RegExp(`"${field}"\\s*:\\s*true`);
|
|
70
|
+
return designs.some(design => design && pattern.test(JSON.stringify(design)));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The strings a preview of this presentation draws. */
|
|
74
|
+
export function* drawnStrings(presentation) {
|
|
75
|
+
const undrawn = new Set(UNDRAWN_KEYS), undrawnRoot = new Set(UNDRAWN_ROOT_KEYS), undrawnSlide = new Set(UNDRAWN_SLIDE_KEYS);
|
|
76
|
+
if (!furnitureUses(presentation, "organization")) undrawnRoot.add("organization");
|
|
77
|
+
if (!furnitureUses(presentation, "section")) undrawnSlide.add("section");
|
|
78
|
+
function* visit(item, level) {
|
|
79
|
+
if (typeof item === "string") { if (!UNDRAWN_VALUE.test(item)) yield item; }
|
|
80
|
+
else if (Array.isArray(item)) for (const child of item) yield* visit(child, level === "slides" ? "slide" : level === "root" ? "deep" : level);
|
|
81
|
+
else if (item && typeof item === "object") {
|
|
82
|
+
for (const [key, child] of Object.entries(item)) {
|
|
83
|
+
if (undrawn.has(key) || (level === "root" && undrawnRoot.has(key)) || (level === "slide" && undrawnSlide.has(key))) continue;
|
|
84
|
+
yield* visit(child, level === "root" && key === "slides" ? "slides" : "deep");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
yield* visit(presentation, "root");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The script profile of a presentation: core `resolveScriptFonts` output, exactly what the renderer plans
|
|
93
|
+
* with. When the installed core has none (published core 0.11.0 and earlier) or it throws, the renderer
|
|
94
|
+
* ignores the document language, so the profile is empty and Han text is Simplified Chinese, as drawn.
|
|
95
|
+
*/
|
|
96
|
+
export function presentationScriptProfile(presentation) {
|
|
97
|
+
if (typeof opfCore.resolveScriptFonts === "function") {
|
|
98
|
+
try { return opfCore.resolveScriptFonts(presentation); } catch { /* the renderer falls back the same way */ }
|
|
99
|
+
}
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const servedScripts = () => new Set(BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "scripts").flatMap(item => item.scripts));
|
|
104
|
+
const cjkKeys = ["Jpan", "Hans", "Hant", "Kore"];
|
|
105
|
+
const cjkCharacters = new Set(["Hani", "Hira", "Kana", "Hang", "Bopo"]);
|
|
106
|
+
/** True when the renderer has per-character glyph fallback (FF-19 glyphFallbackFamilies). */
|
|
107
|
+
const hasGlyphFallback = () => typeof scriptFontModule.glyphFallbackFamilies === "function";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What a preview of the presentation draws: its script profile, the scripts of its text (the renderer's own
|
|
111
|
+
* itemization, so language-dependent punctuation counts), and the CJK characters that a glyph fallback might
|
|
112
|
+
* have to draw with another CJK face.
|
|
113
|
+
*/
|
|
114
|
+
export function analyzePresentationScripts(presentation, profile = presentationScriptProfile(presentation)) {
|
|
115
|
+
const scripts = new Set(), cjk = new Set();
|
|
116
|
+
for (const text of drawnStrings(presentation)) {
|
|
117
|
+
for (const script of scriptsOfText(text, profile)) scripts.add(script);
|
|
118
|
+
for (const character of text) if (character.codePointAt(0) > 0x2E7F && cjkCharacters.has(scriptOfCharacter(character))) cjk.add(character);
|
|
119
|
+
}
|
|
120
|
+
return { profile, detected: [...scripts].sort(), cjk };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Script keys (ISO 15924: `Jpan`, `Hans`, `Arab`, ...) whose faces a preview of this presentation needs.
|
|
125
|
+
* Text decides, itemized exactly as drawing does: the document language only tells Han text apart
|
|
126
|
+
* (Japanese, Korean, Simplified or Traditional Chinese) and makes curly quotes, dashes and the ellipsis
|
|
127
|
+
* East Asian. Text the preview never draws (ids, alt text, sources, assets, catalogs, notes) is ignored.
|
|
128
|
+
* Greek and Cyrillic add nothing: the office registry always carries Noto Sans (the glyph-fallback face) and Roboto covers them.
|
|
129
|
+
* Pass `profile` to reuse a resolved profile.
|
|
130
|
+
*/
|
|
131
|
+
export function detectPresentationScripts(presentation, { profile } = {}) {
|
|
132
|
+
return analyzePresentationScripts(presentation, profile).detected;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The scripts of an analysis that a pinned pack serves (`scripts`, the input for `scriptFontPackages`) and those it does not. */
|
|
136
|
+
export function scriptSelectionOf(analysis) {
|
|
137
|
+
const served = servedScripts();
|
|
138
|
+
return { detected: analysis.detected, scripts: analysis.detected.filter(script => served.has(script)), unavailable: analysis.detected.filter(script => !served.has(script)) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What `scripts: 'auto'` loads for a presentation: the `detected` scripts, those a pinned pack serves
|
|
143
|
+
* (`scripts`, the input for `scriptFontPackages`) and those it does not (`unavailable`, for example
|
|
144
|
+
* Cherokee or Tifinagh, which draw with whatever the design font covers).
|
|
145
|
+
*/
|
|
146
|
+
export function autoScriptSelection(presentation, options) {
|
|
147
|
+
return scriptSelectionOf(analyzePresentationScripts(presentation, options?.profile));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The next CJK package a glyph fallback needs (FF-19): a drawn Han, kana or Hangul character that no loaded
|
|
152
|
+
* script face has (Japanese-only kanji in a Simplified Chinese run, Simplified-only hanzi beside kana, hanja)
|
|
153
|
+
* takes the first unloaded CJK face along the renderer's fallback chain. `covers(character)` says whether a
|
|
154
|
+
* loaded script face has the glyph; `loaded` holds the loaded package names. Undefined when nothing is needed,
|
|
155
|
+
* the renderer has no glyph fallback, or the cap is reached: a character no CJK face covers must not pull in
|
|
156
|
+
* every CJK package (10 to 20 MiB each), so at most ONE fallback package is loaded beyond the packages the
|
|
157
|
+
* text's own scripts need. Characters still uncovered then are reported (`uncoveredCjkCharacters`) and the
|
|
158
|
+
* renderer reports `missing-glyph` for them.
|
|
159
|
+
*/
|
|
160
|
+
export function nextFallbackPackage(analysis, { covers, loaded }) {
|
|
161
|
+
if (!hasGlyphFallback()) return undefined;
|
|
162
|
+
const packages = BUNDLED_FONT_MANIFEST.packages.filter(item => item.pack === "scripts" && item.scripts.some(script => cjkKeys.includes(script)));
|
|
163
|
+
const primary = analysis.detected.filter(script => cjkKeys.includes(script)).length;
|
|
164
|
+
if (packages.filter(item => loaded.has(item.name)).length >= primary + 1) return undefined;
|
|
165
|
+
for (const character of analysis.cjk) {
|
|
166
|
+
if (covers(character)) continue;
|
|
167
|
+
for (const family of scriptFontModule.glyphFallbackFamilies(character, analysis.profile, analysis.profile?.serif === true)) {
|
|
168
|
+
const pkg = packages.find(item => !loaded.has(item.name) && item.faces.some(face => face.family === family));
|
|
169
|
+
if (pkg) return pkg;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Drawn Han, kana or Hangul characters (at most `limit`) that no loaded script face covers. Empty without glyph fallback (nothing else would draw them). */
|
|
176
|
+
export function uncoveredCjkCharacters(analysis, { covers, limit = 16 }) {
|
|
177
|
+
if (!hasGlyphFallback()) return [];
|
|
178
|
+
return [...analysis.cjk].filter(character => !covers(character)).slice(0, limit);
|
|
179
|
+
}
|
package/dist/script-fonts.js
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
// face is missing or lacks glyphs falls back to the designated open (OFL Noto)
|
|
8
8
|
// replacement for that script. Licensed fonts are never bundled: a preview names
|
|
9
9
|
// the designated replacement, while the PPTX keeps the chosen family (FF-07).
|
|
10
|
+
//
|
|
11
|
+
// Glyph fallback: a face that lacks a character (a Latin replacement without
|
|
12
|
+
// Cyrillic or Greek, a CJK face without a kanji or hanzi) never fails a preview.
|
|
13
|
+
// As in a browser or PowerPoint font linking, each such character takes the
|
|
14
|
+
// first bundled face that has it along the deterministic chain of
|
|
15
|
+
// `glyphFallbackFamilies`, in measurement and drawing alike, and the substitution
|
|
16
|
+
// is reported as a note. `glyphFallback: "none"` (strict mode) keeps the exact
|
|
17
|
+
// faces and lets a missing glyph raise `missing-glyph`.
|
|
10
18
|
|
|
11
19
|
const freeze = value => { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; };
|
|
12
20
|
|
|
@@ -199,16 +207,31 @@ export function itemizeScripts(text, profile) {
|
|
|
199
207
|
return runs;
|
|
200
208
|
}
|
|
201
209
|
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Non-Latin script keys the renderer would plan runs for in `text`. It applies the same shortcut as drawing
|
|
212
|
+
* (`createScriptFonts().plan`): Latin, Greek and Cyrillic text is one run in the design font, except that
|
|
213
|
+
* with an East Asian document language the curly quotes, dashes and ellipsis are East Asian characters.
|
|
214
|
+
*/
|
|
215
|
+
export function scriptsOfText(text, profile) {
|
|
216
|
+
const source = String(text ?? "");
|
|
217
|
+
if (latinOnly.test(source) && !(eastAsianLanguage(profile) && eastAsianAmbiguous.test(source))) return [];
|
|
218
|
+
return itemizeScripts(source, profile).filter(run => run.script !== "Latn").map(run => run.script);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Script keys (as used by the script font pack) present in the strings of any JSON value.
|
|
223
|
+
* The profile's own script is included too, unless `includeLanguage` is false (then only
|
|
224
|
+
* text decides). `ignoreKeys` names object keys whose values are not visited.
|
|
225
|
+
*/
|
|
226
|
+
export function detectScripts(value, profile, { includeLanguage = true, ignoreKeys = [] } = {}) {
|
|
227
|
+
const scripts = new Set(), ignored = new Set(ignoreKeys);
|
|
205
228
|
const visit = item => {
|
|
206
|
-
if (typeof item === "string") {
|
|
229
|
+
if (typeof item === "string") { for (const script of scriptsOfText(item, profile)) scripts.add(script); }
|
|
207
230
|
else if (Array.isArray(item)) item.forEach(visit);
|
|
208
|
-
else if (item && typeof item === "object") Object.
|
|
231
|
+
else if (item && typeof item === "object") for (const [key, child] of Object.entries(item)) if (!ignored.has(key)) visit(child);
|
|
209
232
|
};
|
|
210
233
|
visit(value);
|
|
211
|
-
if (profile?.script && !latinGroup.has(profile.script)) scripts.add(fontKey(profile.script, "", profile));
|
|
234
|
+
if (includeLanguage && profile?.script && !latinGroup.has(profile.script)) scripts.add(fontKey(profile.script, "", profile));
|
|
212
235
|
return [...scripts].sort();
|
|
213
236
|
}
|
|
214
237
|
|
|
@@ -219,6 +242,35 @@ export function designatedFamilies(script, serif = false) {
|
|
|
219
242
|
return (serif ? [entry.serif, entry.sans] : [entry.sans, entry.serif]).filter(Boolean);
|
|
220
243
|
}
|
|
221
244
|
|
|
245
|
+
const hanKeys = ["Jpan", "Hans", "Hant", "Kore"];
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Deterministic glyph fallback chain for one character: the designated open
|
|
249
|
+
* families, in order, that a preview tries when the chosen face lacks the
|
|
250
|
+
* character (the same order in measurement and drawing).
|
|
251
|
+
*
|
|
252
|
+
* 1. the character's own script policy face (`SCRIPT_FONT_FAMILIES`; a Han
|
|
253
|
+
* character takes the deck language's face: Japanese, Simplified, Traditional
|
|
254
|
+
* or Korean);
|
|
255
|
+
* 2. the deck language's script face;
|
|
256
|
+
* 3. Noto Sans, which covers Latin, Cyrillic and Greek;
|
|
257
|
+
* 4. the other CJK faces (Japanese, Simplified, Traditional, Korean);
|
|
258
|
+
* 5. every other Noto script face, in `SCRIPT_FONT_FAMILIES` order.
|
|
259
|
+
*
|
|
260
|
+
* `profile` is core `resolveScriptFonts` output (or the same shape); serif
|
|
261
|
+
* profiles prefer the serif face of each script. Families that are not loaded
|
|
262
|
+
* are skipped by the caller.
|
|
263
|
+
*/
|
|
264
|
+
export function glyphFallbackFamilies(character, profile = {}, serif = profile?.serif === true) {
|
|
265
|
+
const script = scriptOfCharacter(character);
|
|
266
|
+
const language = fontKey(profile?.script ?? "Zzzz", "", profile);
|
|
267
|
+
// A Han character has no script of its own: it takes the deck language's face when that is CJK,
|
|
268
|
+
// otherwise every CJK face is tried in turn (Japanese, Simplified, Traditional, Korean).
|
|
269
|
+
const own = script === "Hani" ? (hanKeys.includes(language) ? language : "Zzzz") : fontKey(script, character, profile);
|
|
270
|
+
const keys = [own, language, "Latn", ...hanKeys, ...Object.keys(SCRIPT_FONT_FAMILIES)];
|
|
271
|
+
return [...new Set([...new Set(keys)].flatMap(key => designatedFamilies(key, serif)))];
|
|
272
|
+
}
|
|
273
|
+
|
|
222
274
|
/**
|
|
223
275
|
* Aliases from proprietary script fonts to the first loaded designated
|
|
224
276
|
* replacement. `families` are the loaded script-replacement families.
|
|
@@ -286,9 +338,23 @@ const scriptMeasurement = Symbol.for("@openpresentation/opf-render/script-measur
|
|
|
286
338
|
* plus an optional `serif` flag. `measurement` is an optional font-registry
|
|
287
339
|
* `textMeasurement`; without it the plan names every candidate family in order
|
|
288
340
|
* so the host resolves glyphs per character.
|
|
341
|
+
*
|
|
342
|
+
* `options.glyphFallback` is `"chain"` (default: a character its face lacks
|
|
343
|
+
* takes the first bundled face along `glyphFallbackFamilies`) or `"none"`
|
|
344
|
+
* (exact faces: a missing glyph raises `missing-glyph`). `options.onFallback`
|
|
345
|
+
* receives each new substitution note `{fontFamily, fallbackFamily, scripts,
|
|
346
|
+
* characters, path?}` once; `fallbacks` lists them all.
|
|
289
347
|
*/
|
|
290
|
-
export function createScriptFonts(profile = {}, measurement) {
|
|
291
|
-
const
|
|
348
|
+
export function createScriptFonts(profile = {}, measurement, options = {}) {
|
|
349
|
+
const wrapped = measurement?.[scriptMeasurement];
|
|
350
|
+
const inner = wrapped?.inner ?? measurement;
|
|
351
|
+
// Glyph fallback is on unless `glyphFallback: "none"` (strict faces); a wrapped measurement keeps its options.
|
|
352
|
+
// Only defined keys override: a caller passing `glyphFallback: undefined` must not undo a wrapper's `"none"`.
|
|
353
|
+
// A renderer-supplied `onFallback` chains with, and does not replace, the wrapper's own.
|
|
354
|
+
const notifiers = [wrapped?.options?.onFallback, options?.onFallback].filter(notify => typeof notify === "function");
|
|
355
|
+
options = {...wrapped?.options, ...Object.fromEntries(Object.entries(options ?? {}).filter(([, value]) => value !== undefined)), onFallback: notifiers.length ? note => { for (const notify of notifiers) notify(note); } : undefined};
|
|
356
|
+
const fallbackEnabled = options.glyphFallback !== "none";
|
|
357
|
+
const fallbackNotes = new Map();
|
|
292
358
|
const measured = typeof inner?.measure === "function";
|
|
293
359
|
const serif = profile.serif === true;
|
|
294
360
|
// The language the SVG declares (lang) also selects OpenType language systems in measurement.
|
|
@@ -333,6 +399,37 @@ export function createScriptFonts(profile = {}, measurement) {
|
|
|
333
399
|
const supplement = profile.supplement ? {script: profile.supplement.script === "Hang" ? "Kore" : profile.supplement.script, family: useHeading ? profile.supplement.heading : profile.supplement.body} : undefined;
|
|
334
400
|
return {latin: style.fontFamily, eastAsian: slots.eastAsian ?? style.fontFamily, complexScript: slots.complexScript ?? style.fontFamily, supplement};
|
|
335
401
|
};
|
|
402
|
+
const nonAscii = /[^\u0020-\u007E]/;
|
|
403
|
+
const cyrillicOrGreek = /[\u0370-\u052F\u1F00-\u1FFF]/;
|
|
404
|
+
const chains = new Map();
|
|
405
|
+
const chainFor = character => {
|
|
406
|
+
const script = scriptOfCharacter(character);
|
|
407
|
+
let chain = chains.get(script);
|
|
408
|
+
if (!chain) chains.set(script, chain = glyphFallbackFamilies(character, profile, serif));
|
|
409
|
+
return chain;
|
|
410
|
+
};
|
|
411
|
+
/** Collect that `family` draws characters the chosen `from` face lacks; `flushFallbacks` records them per planned text. */
|
|
412
|
+
const noteFallback = (pending, from, family, characters) => {
|
|
413
|
+
if (from === null || from === family) return;
|
|
414
|
+
const key = `${from}\u0000${family}`;
|
|
415
|
+
let entry = pending.get(key);
|
|
416
|
+
if (!entry) pending.set(key, entry = {from, family, characters: new Set()});
|
|
417
|
+
for (const character of characters) entry.characters.add(character);
|
|
418
|
+
};
|
|
419
|
+
const flushFallbacks = (pending, style) => {
|
|
420
|
+
for (const {from, family, characters} of pending.values()) {
|
|
421
|
+
const key = `${from}\u0000${family}\u0000${style.path ?? ""}`;
|
|
422
|
+
let note = fallbackNotes.get(key);
|
|
423
|
+
const isNew = !note;
|
|
424
|
+
if (isNew) fallbackNotes.set(key, note = {fontFamily: from, fallbackFamily: family, scripts: [], characters: [], ...(style.path ? {path: style.path} : {})});
|
|
425
|
+
for (const character of characters) {
|
|
426
|
+
const script = scriptOfCharacter(character);
|
|
427
|
+
if (!note.scripts.includes(script)) note.scripts.push(script);
|
|
428
|
+
if (note.characters.length < 16 && !note.characters.includes(character)) note.characters.push(character);
|
|
429
|
+
}
|
|
430
|
+
if (isNew) options.onFallback?.(note);
|
|
431
|
+
}
|
|
432
|
+
};
|
|
336
433
|
const candidatesFor = (run, slots) => {
|
|
337
434
|
const list = [run.role === "eastAsian" ? slots.eastAsian : run.role === "complexScript" ? slots.complexScript : slots.latin];
|
|
338
435
|
if (run.script !== "Latn") {
|
|
@@ -345,13 +442,22 @@ export function createScriptFonts(profile = {}, measurement) {
|
|
|
345
442
|
/** Font runs for text drawn in `style` (the resolved latin style). `own` runs use the style's family. */
|
|
346
443
|
const plan = (text, style) => {
|
|
347
444
|
text = String(text ?? "");
|
|
348
|
-
if (latinOnly.test(text) && !(eastAsianText && eastAsianAmbiguous.test(text)))
|
|
349
|
-
|
|
445
|
+
if (latinOnly.test(text) && !(eastAsianText && eastAsianAmbiguous.test(text))) {
|
|
446
|
+
// Latin-group text is one run in the latin slot's face, unless that face lacks a character
|
|
447
|
+
// (Cyrillic or Greek beyond a Latin replacement): then it is planned per character below.
|
|
448
|
+
if (!fallbackEnabled || !nonAscii.test(text)) return [{text, family: style.fontFamily, own: true}];
|
|
449
|
+
if (!measured) return cyrillicOrGreek.test(text) ? [{text, family: style.fontFamily, own: false, stack: [style.fontFamily, ...designatedFamilies("Latn", serif)]}] : [{text, family: style.fontFamily, own: true}];
|
|
450
|
+
const own = resolveName(style.fontFamily, style);
|
|
451
|
+
if (own === null || covers(own, text, style)) return [{text, family: style.fontFamily, own: true}];
|
|
452
|
+
}
|
|
453
|
+
const cacheKey = `${style.fontFamily}\u0000${style.fontWeight ?? 400}\u0000${!!style.italic}\u0000${textRole(style) ?? ""}\u0000${style.path ?? ""}\u0000${text}`;
|
|
350
454
|
const cached = plans.get(cacheKey);
|
|
351
455
|
if (cached) return cached;
|
|
352
456
|
const slots = slotsFor(style);
|
|
353
457
|
const runs = itemizeScripts(text, profile).map(run => ({...run, candidates: candidatesFor(run, slots)}));
|
|
458
|
+
const pending = new Map();
|
|
354
459
|
const global = [...new Set([...runs.flatMap(run => run.candidates), ...designatedFamilies(fontKey(profile.script ?? "Zzzz", text, profile), serif), ...designatedFamilies("Latn", serif)])];
|
|
460
|
+
const unique = names => names.map(name => resolveName(name, style)).filter((name, index, all) => name && all.indexOf(name) === index);
|
|
355
461
|
const out = [];
|
|
356
462
|
const push = (text, family, stack) => {
|
|
357
463
|
const own = family === style.fontFamily && (!stack || stack.length === 1);
|
|
@@ -361,20 +467,51 @@ export function createScriptFonts(profile = {}, measurement) {
|
|
|
361
467
|
};
|
|
362
468
|
for (const run of runs) {
|
|
363
469
|
if (!measured) { push(run.text, run.candidates[0], run.candidates); continue; }
|
|
470
|
+
const chosen = resolveName(run.candidates[0], style);
|
|
364
471
|
const whole = run.candidates.map(family => resolveName(family, style)).find(name => name && covers(name, run.text, style));
|
|
365
|
-
if (whole) {
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
472
|
+
if (whole) {
|
|
473
|
+
if (whole !== chosen) noteFallback(pending, chosen, whole, [...run.text].filter(character => !covers(chosen ?? whole, character, style)));
|
|
474
|
+
push(run.text, whole);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
// No single candidate covers the run. Split it into grapheme clusters (a base character with
|
|
478
|
+
// the combining marks and joiners that follow it) and group those into script segments
|
|
479
|
+
// (common characters stay with their neighbours; white space ends a word). Each segment takes the first face that
|
|
480
|
+
// covers all of its letters and marks: the chosen face, the previous fallback face, the
|
|
481
|
+
// other candidates, then the fallback chain. A word is never drawn in two faces when one
|
|
482
|
+
// face has all of it (Greek beside a Latin serif, a kanji run beside Hangul). A cluster
|
|
483
|
+
// that face still lacks, mark included, moves whole to the first face that has the cluster,
|
|
484
|
+
// so a base and its mark are never split across faces.
|
|
485
|
+
const primary = chosen ?? style.fontFamily;
|
|
486
|
+
let sticky = null;
|
|
487
|
+
// The note lists what the chosen face lacks (a mark, not its covered base letter); a cluster it only lacks as a whole lists all.
|
|
488
|
+
const missingFrom = (face, text) => { const lacking = [...text].filter(character => !covers(face, character, style)); return lacking.length ? lacking : [...text]; };
|
|
489
|
+
const choose = (subject, sample) => unique([run.candidates[0], ...(sticky ? [sticky] : []), ...run.candidates, ...global, ...(fallbackEnabled ? chainFor(sample) : [])]).find(name => covers(name, subject, style));
|
|
490
|
+
const segments = [];
|
|
370
491
|
for (const character of run.text) {
|
|
371
|
-
const script = scriptOfCharacter(character);
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
492
|
+
const script = scriptOfCharacter(character), last = segments.at(-1)?.clusters.at(-1);
|
|
493
|
+
if (script === "Zinh" && last) { last.text += character; continue; }
|
|
494
|
+
const weak = script === "Zyyy" || script === "Zinh", segment = segments.at(-1);
|
|
495
|
+
const cluster = {text: character, base: character, weak};
|
|
496
|
+
// A segment is one script and one word: white space closes it, so a single lacking mark
|
|
497
|
+
// moves its word to another face, not the whole sentence.
|
|
498
|
+
if (segment && !(segment.closed && !weak) && (weak || segment.script === undefined || segment.script === script)) {
|
|
499
|
+
segment.clusters.push(cluster);
|
|
500
|
+
if (!weak) segment.script = script; else if (/^\s/.test(character)) segment.closed = true;
|
|
501
|
+
} else segments.push({script: weak ? undefined : script, clusters: [cluster]});
|
|
502
|
+
}
|
|
503
|
+
for (const segment of segments) {
|
|
504
|
+
const strong = segment.clusters.filter(cluster => !cluster.weak);
|
|
505
|
+
const face = strong.length ? choose(strong.map(cluster => cluster.text).join(""), strong[0].base) : undefined;
|
|
506
|
+
for (const cluster of segment.clusters) {
|
|
507
|
+
const family = face && covers(face, cluster.text, style) ? face : choose(cluster.text, cluster.base) ?? choose(cluster.base, cluster.base) ?? primary;
|
|
508
|
+
if (family !== primary && family !== chosen) sticky = family;
|
|
509
|
+
if (chosen !== null && family !== chosen && covers(family, cluster.text, style)) noteFallback(pending, chosen, family, missingFrom(chosen, cluster.text));
|
|
510
|
+
push(cluster.text, family);
|
|
511
|
+
}
|
|
376
512
|
}
|
|
377
513
|
}
|
|
514
|
+
flushFallbacks(pending, style);
|
|
378
515
|
if (plans.size >= 4096) plans.delete(plans.keys().next().value);
|
|
379
516
|
plans.set(cacheKey, out);
|
|
380
517
|
return out;
|
|
@@ -392,7 +529,7 @@ export function createScriptFonts(profile = {}, measurement) {
|
|
|
392
529
|
if (runs.length === 1 && runs[0].own) return inner.measure(text, size, styled(style));
|
|
393
530
|
return runs.reduce((total, run) => total + inner.measure(run.text, size, styleFor(run, style)), 0);
|
|
394
531
|
},
|
|
395
|
-
[scriptMeasurement]: {inner, profile},
|
|
532
|
+
[scriptMeasurement]: {inner, profile, options},
|
|
396
533
|
};
|
|
397
534
|
if (typeof inner.outlineBounds === "function") {
|
|
398
535
|
textMeasurement.outlineBounds = (text, size, style) => {
|
|
@@ -411,7 +548,7 @@ export function createScriptFonts(profile = {}, measurement) {
|
|
|
411
548
|
};
|
|
412
549
|
}
|
|
413
550
|
}
|
|
414
|
-
return {profile, plan, runWidths, textMeasurement, rtl: isRtlProfile(profile)};
|
|
551
|
+
return {profile, plan, runWidths, textMeasurement, rtl: isRtlProfile(profile), get fallbacks() { return [...fallbackNotes.values()].map(note => ({...note, scripts: [...note.scripts], characters: [...note.characters]})); }};
|
|
415
552
|
}
|
|
416
553
|
|
|
417
554
|
/**
|
|
@@ -420,7 +557,7 @@ export function createScriptFonts(profile = {}, measurement) {
|
|
|
420
557
|
* replacement). Pass the same wrapper to core pagination, the renderer and the
|
|
421
558
|
* editor so their line breaks agree for non-Latin text.
|
|
422
559
|
*/
|
|
423
|
-
export function createScriptTextMeasurement(measurement, profile) {
|
|
560
|
+
export function createScriptTextMeasurement(measurement, profile, options) {
|
|
424
561
|
if (typeof measurement?.measure !== "function") throw new TypeError("createScriptTextMeasurement requires a textMeasurement with measure().");
|
|
425
|
-
return createScriptFonts(profile, measurement).textMeasurement;
|
|
562
|
+
return createScriptFonts(profile, measurement, options).textMeasurement;
|
|
426
563
|
}
|
package/dist/svg.d.ts
CHANGED
|
@@ -32,6 +32,15 @@ export declare const engineDefaults: Readonly<{
|
|
|
32
32
|
}>;
|
|
33
33
|
|
|
34
34
|
export type RenderDiagnostic = LayoutDiagnostic | {
|
|
35
|
+
/** A face lacked glyphs for the text; a bundled fallback face draws them in the preview. A note, not an error. */
|
|
36
|
+
code: "font-glyph-fallback";
|
|
37
|
+
path?: string;
|
|
38
|
+
message: string;
|
|
39
|
+
fontFamily: string;
|
|
40
|
+
fallbackFamily: string;
|
|
41
|
+
scripts: string[];
|
|
42
|
+
characters: string[];
|
|
43
|
+
} | {
|
|
35
44
|
code: "unsupported-pattern" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
|
|
36
45
|
path: string;
|
|
37
46
|
message: string;
|
|
@@ -55,6 +64,8 @@ export type RenderDiagnostic = LayoutDiagnostic | {
|
|
|
55
64
|
|
|
56
65
|
export interface RenderSvgOptions {
|
|
57
66
|
textMeasurement?: TextMeasurement;
|
|
67
|
+
/** `"chain"` (default): a character the resolved face lacks is drawn with the first bundled face that has it, and reported as `font-glyph-fallback`. `"none"`: exact faces, a missing glyph raises `missing-glyph`. */
|
|
68
|
+
glyphFallback?: "chain" | "none";
|
|
58
69
|
/** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
|
|
59
70
|
textRasterPadding?: number;
|
|
60
71
|
embeddedFonts?: EmbeddedFont[];
|
package/dist/svg.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
// still loads; resolveScriptFonts ships with core FF-18.
|
|
9
9
|
import * as opfCore from "@openpresentation/opf";
|
|
10
10
|
import { createScriptFonts } from "./script-fonts.js";
|
|
11
|
+
import { disabledFeaturesStyle } from "./font-compatibility.js";
|
|
11
12
|
|
|
12
13
|
export const packageName = "@openpresentation/opf-render";
|
|
13
14
|
|
|
@@ -485,6 +486,28 @@ function reportLanguageDiagnostic(context, diagnostic) {
|
|
|
485
486
|
context.options.onDiagnostic?.(diagnostic);
|
|
486
487
|
}
|
|
487
488
|
|
|
489
|
+
/**
|
|
490
|
+
* Report a glyph fallback once per resolvePresentation call, family pair and path (FF-19).
|
|
491
|
+
* It is a note, not an error: the face the font scheme resolved to lacks glyphs for the text,
|
|
492
|
+
* so a bundled face that has them draws those characters. The PPTX still names the chosen font.
|
|
493
|
+
*/
|
|
494
|
+
function reportGlyphFallback(context, note) {
|
|
495
|
+
context.glyphFallbackDiagnostics ??= new Set();
|
|
496
|
+
const key = `${note.fontFamily}\u0000${note.fallbackFamily}\u0000${note.path ?? ""}`;
|
|
497
|
+
if (context.glyphFallbackDiagnostics.has(key)) return;
|
|
498
|
+
context.glyphFallbackDiagnostics.add(key);
|
|
499
|
+
context.options.onDiagnostic?.({
|
|
500
|
+
code: "font-glyph-fallback",
|
|
501
|
+
...(note.path ? { path: note.path } : {}),
|
|
502
|
+
// The note is reported once per family pair and path: it names characters the face lacks, such as these, not every one.
|
|
503
|
+
message: `'${note.fontFamily}' lacks glyphs for characters such as ${note.characters.slice(0, 8).map(character => `U+${character.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}`).join(", ")}; the preview draws those with '${note.fallbackFamily}'. The PPTX keeps the chosen font.`,
|
|
504
|
+
fontFamily: note.fontFamily,
|
|
505
|
+
fallbackFamily: note.fallbackFamily,
|
|
506
|
+
scripts: [...note.scripts],
|
|
507
|
+
characters: [...note.characters]
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
488
511
|
function inferLayoutId(slide) {
|
|
489
512
|
if (slide.layout) return slide.layout;
|
|
490
513
|
if (slide.blocks?.length) return "blank";
|
|
@@ -609,7 +632,10 @@ function bindSlide(presentation, slide, layout, index, context) {
|
|
|
609
632
|
const design = resolveDesign(presentation, slide, context, index);
|
|
610
633
|
// Script fonts (FF-19): each text run is measured and drawn with its script
|
|
611
634
|
// slot's face; the latin slot stays the design font scheme's family.
|
|
612
|
-
const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement
|
|
635
|
+
const scriptFonts = createScriptFonts(scriptProfile(presentation, index, design, context), context.options.textMeasurement, {
|
|
636
|
+
glyphFallback: context.options.glyphFallback,
|
|
637
|
+
onFallback: note => reportGlyphFallback(context, note)
|
|
638
|
+
});
|
|
613
639
|
const textMeasurement = scriptFonts.textMeasurement ?? context.options.textMeasurement;
|
|
614
640
|
for (const role of ["heading","body","code"]) design.fonts[role] = resolveTextStyle({fontFamily:design.fonts[role],fontWeight:role === "heading" ? 700 : 400},textMeasurement).fontFamily;
|
|
615
641
|
const geometry = composeSlide(slide, { ...design.dimensions, layout, presentation, slideIndex: index, fonts: design.fonts, contentAlignment:design.contentAlignment, titleAlignment:design.titleAlignment, textRasterPadding:context.options.textRasterPadding, contentBox:design.contentBox, textMeasurement, date: context.options.date, socialPlatforms: socialPlatformRecords(context) });
|
|
@@ -674,8 +700,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
674
700
|
const lang = script?.languageSource === "document" || script?.languageSource === "option" ? script.bcp47 : undefined;
|
|
675
701
|
const { width, height } = bound.design.dimensions;
|
|
676
702
|
const title = bound.slide.title ?? resolved.presentation.name ?? `Slide ${slideIndex + 1}`;
|
|
677
|
-
const
|
|
678
|
-
renderEmbeddedFonts(options.embeddedFonts),
|
|
703
|
+
const content = [
|
|
679
704
|
renderBackground(bound, width, height, options),
|
|
680
705
|
renderSlideImage(bound, options),
|
|
681
706
|
renderBranding(bound, resolved.presentation, width, height, options),
|
|
@@ -683,6 +708,7 @@ function renderResolvedSlide(resolved, slideIndex, options) {
|
|
|
683
708
|
renderFurniture(bound, resolved.presentation, width, height, options, "header"),
|
|
684
709
|
renderFurniture(bound, resolved.presentation, width, height, options, "footer")
|
|
685
710
|
].filter(Boolean);
|
|
711
|
+
const children = [renderEmbeddedFonts(embeddedFontsFor(options.embeddedFonts, content)), ...content].filter(Boolean);
|
|
686
712
|
|
|
687
713
|
return tag(
|
|
688
714
|
"svg",
|
|
@@ -1434,6 +1460,13 @@ function paragraphRtl(bound, text) {
|
|
|
1434
1460
|
* a textLength spanning differently fonted tspans does not rasterize reliably;
|
|
1435
1461
|
* the caller then drops its own textLength and anchors at the start.
|
|
1436
1462
|
*/
|
|
1463
|
+
// Nested script runs of a line drawn with a flagged face (Gelasio) opt back into default shaping,
|
|
1464
|
+
// unless the run itself uses a flagged family (tag() then adds that family's own style).
|
|
1465
|
+
function nestedReset(style, run) {
|
|
1466
|
+
const parent = disabledFeaturesStyle(style?.fontFamily);
|
|
1467
|
+
return parent && !disabledFeaturesStyle([run.stack ?? run.family].flat()[0]) ? RESET_POLICY_FEATURES : undefined;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1437
1470
|
function scriptLine(text, style, bound, type, { rtl = false, placement, trace } = {}) {
|
|
1438
1471
|
const value = String(text ?? "");
|
|
1439
1472
|
const scripts = bound.scriptFonts;
|
|
@@ -1447,7 +1480,7 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace }
|
|
|
1447
1480
|
const widths = placement && placement.width > 0 ? scripts.runWidths(runs, placement.fontSize, style) : undefined;
|
|
1448
1481
|
if (!widths) {
|
|
1449
1482
|
return { content: isolate(runs.map(run => run.own ? escapeText(run.text)
|
|
1450
|
-
: tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type) }, escapeText(run.text))).join("")) };
|
|
1483
|
+
: tag("tspan", { "font-family": fontStack(run.stack ?? run.family, type), style: nestedReset(style, run) }, escapeText(run.text))).join("")) };
|
|
1451
1484
|
}
|
|
1452
1485
|
const total = widths.reduce((sum, width) => sum + width, 0), factor = total > 0 ? placement.width / total : 1;
|
|
1453
1486
|
let advance = 0, offset = 0;
|
|
@@ -1460,6 +1493,7 @@ function scriptLine(text, style, bound, type, { rtl = false, placement, trace }
|
|
|
1460
1493
|
x: stableNumber(placement.x + left),
|
|
1461
1494
|
textLength: width > 0 ? stableNumber(width) : undefined, lengthAdjust: width > 0 ? "spacingAndGlyphs" : undefined,
|
|
1462
1495
|
"font-family": run.own ? undefined : fontStack(run.family, type),
|
|
1496
|
+
style: run.own ? undefined : nestedReset(style, run),
|
|
1463
1497
|
...(trace ? trace(start, offset) : {})
|
|
1464
1498
|
}, isolate(escapeText(run.text)));
|
|
1465
1499
|
}).join("");
|
|
@@ -1472,6 +1506,49 @@ function segmentSpan(attrs, segment, text, style, bound, type, options) {
|
|
|
1472
1506
|
return tag("tspan", { ...attrs, "font-family": scripted.family }, scripted.content);
|
|
1473
1507
|
}
|
|
1474
1508
|
|
|
1509
|
+
// Faces flagged embed:"used" (the vendored open, Intos and script-pack faces) are embedded only when the slide's own markup draws
|
|
1510
|
+
// them: the family in a font-family list, at a font-weight and font-style some text of the slide takes (attributes are
|
|
1511
|
+
// inherited down the element tree, as in SVG). A used family none of whose faces matches a drawn weight and style keeps all
|
|
1512
|
+
// its faces, so the browser can always choose. Every other face (the npm packs) is embedded as before.
|
|
1513
|
+
function embeddedFontsFor(fonts = [], content) {
|
|
1514
|
+
if (!fonts.some(font => font?.embed === "used")) return fonts;
|
|
1515
|
+
const drawn = drawnFaces(content.join("\n"));
|
|
1516
|
+
const wanted = font => {
|
|
1517
|
+
const triples = drawn.get(String(font.family).toLowerCase());
|
|
1518
|
+
if (!triples) return false;
|
|
1519
|
+
const family = fonts.filter(other => other?.embed === "used" && String(other.family).toLowerCase() === String(font.family).toLowerCase());
|
|
1520
|
+
const matching = family.filter(other => triples.has(`${other.weight}|${other.italic ? "italic" : "normal"}`));
|
|
1521
|
+
return matching.length ? matching.includes(font) : true;
|
|
1522
|
+
};
|
|
1523
|
+
return fonts.filter(font => font?.embed !== "used" || wanted(font));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
const FONT_WEIGHT_KEYWORDS = { normal: "400", bold: "700" };
|
|
1527
|
+
/** family (lowercase) to the set of "weight|style" pairs the markup draws text in. */
|
|
1528
|
+
function drawnFaces(markup) {
|
|
1529
|
+
const drawn = new Map();
|
|
1530
|
+
const tokens = /<(\/?)([A-Za-z][\w:.-]*)((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|[^<]+/g;
|
|
1531
|
+
const stack = [{ families: [], weight: "400", style: "normal", text: false }];
|
|
1532
|
+
const attribute = (attributes, name) => { const found = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attributes); return found ? found[1] ?? found[2] : undefined; };
|
|
1533
|
+
for (const [token, close, name, attributes, selfClose] of markup.matchAll(tokens)) {
|
|
1534
|
+
const top = stack.at(-1);
|
|
1535
|
+
if (name === undefined) {
|
|
1536
|
+
if (top.text && /\S/.test(token)) for (const family of top.families) { const set = drawn.get(family) ?? new Set(); set.add(`${top.weight}|${top.style}`); drawn.set(family, set); }
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
if (close) { if (stack.length > 1) stack.pop(); continue; }
|
|
1540
|
+
const family = attribute(attributes, "font-family"), weight = attribute(attributes, "font-weight"), style = attribute(attributes, "font-style");
|
|
1541
|
+
const next = {
|
|
1542
|
+
families: family === undefined ? top.families : family.split(",").map(item => item.trim().replace(/^"|"$|^["']|["']$/g, "").toLowerCase()).filter(Boolean),
|
|
1543
|
+
weight: weight === undefined ? top.weight : FONT_WEIGHT_KEYWORDS[weight.trim()] ?? weight.trim(),
|
|
1544
|
+
style: style === undefined ? top.style : /italic|oblique/.test(style) ? "italic" : "normal",
|
|
1545
|
+
text: top.text || name === "text",
|
|
1546
|
+
};
|
|
1547
|
+
if (!selfClose) stack.push(next);
|
|
1548
|
+
}
|
|
1549
|
+
return drawn;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1475
1552
|
function renderEmbeddedFonts(fonts = []) {
|
|
1476
1553
|
if (!fonts.length) return "";
|
|
1477
1554
|
const css = fonts.map(font => {
|
|
@@ -1541,7 +1618,7 @@ function renderRichLines(value,fit,box,bound,config) {
|
|
|
1541
1618
|
if(!flow)return fragments.join('\n');
|
|
1542
1619
|
const x=alignment==='right'?box.x+box.width:alignment==='center'?box.x+box.width/2:box.x;
|
|
1543
1620
|
const first=line.fragments[0];
|
|
1544
|
-
return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined},fragments.join(''));
|
|
1621
|
+
return tag('text',{x:stableNumber(x),y:stableNumber(box.y+line.baseline),'text-anchor':alignment==='right'?'end':alignment==='center'?'middle':'start','xml:space':'preserve','font-family':fontStack(first?.style.fontFamily??config.fontFamily,bound.design.fontScheme.type),'font-size':stableNumber(first?.fontSize??fit.fontSize),'font-weight':first?.style.fontWeight??config.fontWeight??400,'font-style':first?.style.italic?'italic':undefined,[NO_POLICY_FEATURES]:true},fragments.join(''));
|
|
1545
1622
|
});
|
|
1546
1623
|
let cursor=0;
|
|
1547
1624
|
const whole=value.map(run=>typeof run==='string'?run:run.text).join('');
|
|
@@ -1694,7 +1771,22 @@ function escapeAttr(value) {
|
|
|
1694
1771
|
return escapeText(value).replaceAll('"', """);
|
|
1695
1772
|
}
|
|
1696
1773
|
|
|
1774
|
+
// FF-31: text drawn with a face whose features the font policy turns off (Gelasio for Georgia) says so, so
|
|
1775
|
+
// browsers do not ligate what the measurement did not. Only text or tspan elements that name such a family.
|
|
1776
|
+
// A flow line's outer text element, which only carries the first fragment's family, opts out: each of its
|
|
1777
|
+
// tspans names its own family and gets its own style, so other faces do not inherit ligatures:none.
|
|
1778
|
+
const NO_POLICY_FEATURES = Symbol("noPolicyFeatures");
|
|
1779
|
+
// Text in another script nested in a flagged run (scriptLine tspans) goes back to default shaping.
|
|
1780
|
+
const RESET_POLICY_FEATURES = "font-variant-ligatures:normal;font-feature-settings:normal";
|
|
1781
|
+
function withPolicyFeatures(name, attrs) {
|
|
1782
|
+
if (attrs[NO_POLICY_FEATURES]) return attrs;
|
|
1783
|
+
const family = (name === "text" || name === "tspan") && typeof attrs["font-family"] === "string" ? attrs["font-family"].split(",")[0].trim() : undefined;
|
|
1784
|
+
const features = family && disabledFeaturesStyle(family);
|
|
1785
|
+
return features ? { ...attrs, style: attrs.style ? `${attrs.style};${features}` : features } : attrs;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1697
1788
|
function tag(name, attrs = {}, children = "") {
|
|
1789
|
+
attrs = withPolicyFeatures(name, attrs);
|
|
1698
1790
|
const serializedAttrs = Object.keys(attrs)
|
|
1699
1791
|
.filter((key) => attrs[key] !== undefined && attrs[key] !== null && attrs[key] !== false)
|
|
1700
1792
|
.sort()
|
|
Binary file
|