@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
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
// Script fonts for previews (font-fidelity-everywhere FF-19).
|
|
2
|
+
//
|
|
3
|
+
// Browser-safe and deterministic: no DOM, network, clock or locale data. Text is
|
|
4
|
+
// itemized by Unicode script (the JavaScript engine's Unicode tables), each run
|
|
5
|
+
// takes the OOXML script slot its script uses (latin, eastAsian or
|
|
6
|
+
// complexScript) as resolved by core `resolveScriptFonts`, and a run whose slot
|
|
7
|
+
// face is missing or lacks glyphs falls back to the designated open (OFL Noto)
|
|
8
|
+
// replacement for that script. Licensed fonts are never bundled: a preview names
|
|
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`.
|
|
18
|
+
|
|
19
|
+
const freeze = value => { if (value && typeof value === "object") { for (const child of Object.values(value)) freeze(child); Object.freeze(value); } return value; };
|
|
20
|
+
|
|
21
|
+
/** Designated open replacement families per ISO 15924 script, by style. */
|
|
22
|
+
export const SCRIPT_FONT_FAMILIES = freeze({
|
|
23
|
+
Latn: {sans: "Noto Sans"}, Cyrl: {sans: "Noto Sans"}, Grek: {sans: "Noto Sans"},
|
|
24
|
+
Jpan: {sans: "Noto Sans JP", serif: "Noto Serif JP"},
|
|
25
|
+
Hans: {sans: "Noto Sans SC", serif: "Noto Serif SC"},
|
|
26
|
+
Hant: {sans: "Noto Sans TC", serif: "Noto Serif TC"},
|
|
27
|
+
Kore: {sans: "Noto Sans KR", serif: "Noto Serif KR"},
|
|
28
|
+
Arab: {sans: "Noto Sans Arabic", serif: "Noto Naskh Arabic"},
|
|
29
|
+
Hebr: {sans: "Noto Sans Hebrew", serif: "Noto Serif Hebrew"},
|
|
30
|
+
Deva: {sans: "Noto Sans Devanagari", serif: "Noto Serif Devanagari"},
|
|
31
|
+
Beng: {sans: "Noto Sans Bengali", serif: "Noto Serif Bengali"},
|
|
32
|
+
Guru: {sans: "Noto Sans Gurmukhi"}, Gujr: {sans: "Noto Sans Gujarati"}, Orya: {sans: "Noto Sans Oriya"},
|
|
33
|
+
Taml: {sans: "Noto Sans Tamil"}, Telu: {sans: "Noto Sans Telugu"}, Knda: {sans: "Noto Sans Kannada"},
|
|
34
|
+
Mlym: {sans: "Noto Sans Malayalam"}, Sinh: {sans: "Noto Sans Sinhala"},
|
|
35
|
+
Thai: {sans: "Noto Sans Thai", serif: "Noto Serif Thai"}, Laoo: {sans: "Noto Sans Lao"},
|
|
36
|
+
Khmr: {sans: "Noto Sans Khmer"}, Mymr: {sans: "Noto Sans Myanmar"},
|
|
37
|
+
Ethi: {sans: "Noto Sans Ethiopic"}, Armn: {sans: "Noto Sans Armenian"}, Geor: {sans: "Noto Sans Georgian"},
|
|
38
|
+
Mong: {sans: "Noto Sans Mongolian"}, Thaa: {sans: "Noto Sans Thaana"}, Syrc: {sans: "Noto Sans Syriac"},
|
|
39
|
+
Tibt: {serif: "Noto Serif Tibetan"},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const replacement = (script, substitutes, requestedFamilies) => requestedFamilies.map(requestedFamily => ({
|
|
43
|
+
requestedFamily, script, substitutes, compatibility: "visual",
|
|
44
|
+
note: "Designated open replacement for preview only; the PPTX keeps the requested family. Appearance and advances differ.",
|
|
45
|
+
}));
|
|
46
|
+
const sansThenSerif = script => [SCRIPT_FONT_FAMILIES[script].sans, SCRIPT_FONT_FAMILIES[script].serif].filter(Boolean);
|
|
47
|
+
const serifThenSans = script => [SCRIPT_FONT_FAMILIES[script].serif, SCRIPT_FONT_FAMILIES[script].sans].filter(Boolean);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Proprietary script fonts named by font schemes and language records, with
|
|
51
|
+
* their designated open replacements (first loaded substitute wins). Same shape
|
|
52
|
+
* as FONT_COMPATIBILITY rules so a shared font policy table can absorb them.
|
|
53
|
+
*/
|
|
54
|
+
export const SCRIPT_FONT_REPLACEMENTS = freeze([
|
|
55
|
+
...replacement("Jpan", sansThenSerif("Jpan"), ["Meiryo", "Meiryo UI", "Yu Gothic", "Yu Gothic UI", "MS Gothic", "MS PGothic", "MS UI Gothic"]),
|
|
56
|
+
...replacement("Jpan", serifThenSans("Jpan"), ["MS Mincho", "MS PMincho", "Yu Mincho"]),
|
|
57
|
+
...replacement("Hans", sansThenSerif("Hans"), ["Microsoft YaHei", "Microsoft YaHei UI", "SimHei", "DengXian"]),
|
|
58
|
+
...replacement("Hans", serifThenSans("Hans"), ["SimSun", "NSimSun", "FangSong", "KaiTi"]),
|
|
59
|
+
...replacement("Hant", sansThenSerif("Hant"), ["Microsoft JhengHei", "Microsoft JhengHei UI"]),
|
|
60
|
+
...replacement("Hant", serifThenSans("Hant"), ["PMingLiU", "MingLiU", "PMingLiU-ExtB", "MingLiU-ExtB", "DFKai-SB"]),
|
|
61
|
+
...replacement("Kore", sansThenSerif("Kore"), ["Malgun Gothic", "Gulim", "GulimChe", "Dotum", "DotumChe"]),
|
|
62
|
+
...replacement("Kore", serifThenSans("Kore"), ["Batang", "BatangChe", "Gungsuh", "GungsuhChe"]),
|
|
63
|
+
...replacement("Arab", serifThenSans("Arab"), ["Arabic Typesetting", "Traditional Arabic", "Simplified Arabic", "Sakkal Majalla", "Urdu Typesetting", "Andalus", "Aldhabi"]),
|
|
64
|
+
...replacement("Hebr", serifThenSans("Hebr"), ["David", "Narkisim", "FrankRuehl"]),
|
|
65
|
+
...replacement("Hebr", sansThenSerif("Hebr"), ["Miriam", "Gisha", "Levenim MT", "Aharoni", "Rod"]),
|
|
66
|
+
...replacement("Deva", sansThenSerif("Deva"), ["Mangal", "Aparajita", "Kokila", "Utsaah", "Nirmala UI"]),
|
|
67
|
+
...replacement("Beng", sansThenSerif("Beng"), ["Shonar Bangla", "Vrinda"]),
|
|
68
|
+
...replacement("Guru", sansThenSerif("Guru"), ["Raavi"]),
|
|
69
|
+
...replacement("Gujr", sansThenSerif("Gujr"), ["Shruti"]),
|
|
70
|
+
...replacement("Orya", sansThenSerif("Orya"), ["Kalinga"]),
|
|
71
|
+
...replacement("Taml", sansThenSerif("Taml"), ["Latha", "Vijaya"]),
|
|
72
|
+
...replacement("Telu", sansThenSerif("Telu"), ["Gautami", "Vani"]),
|
|
73
|
+
...replacement("Knda", sansThenSerif("Knda"), ["Tunga"]),
|
|
74
|
+
...replacement("Mlym", sansThenSerif("Mlym"), ["Kartika"]),
|
|
75
|
+
...replacement("Sinh", sansThenSerif("Sinh"), ["Iskoola Pota"]),
|
|
76
|
+
...replacement("Thai", sansThenSerif("Thai"), ["Angsana New", "AngsanaUPC", "Cordia New", "CordiaUPC", "Browallia New", "BrowalliaUPC", "DilleniaUPC", "EucrosiaUPC", "FreesiaUPC", "IrisUPC", "JasmineUPC", "KodchiangUPC", "LilyUPC", "Leelawadee", "Leelawadee UI"]),
|
|
77
|
+
...replacement("Laoo", sansThenSerif("Laoo"), ["DokChampa", "Lao UI"]),
|
|
78
|
+
...replacement("Khmr", sansThenSerif("Khmr"), ["DaunPenh", "Khmer UI", "MoolBoran"]),
|
|
79
|
+
...replacement("Mymr", sansThenSerif("Mymr"), ["Myanmar Text"]),
|
|
80
|
+
...replacement("Ethi", sansThenSerif("Ethi"), ["Nyala"]),
|
|
81
|
+
// Sylfaen covers Latin, Greek, Cyrillic, Armenian and Georgian. Its Latin text
|
|
82
|
+
// uses Noto Sans; Armenian and Georgian runs fall back by coverage.
|
|
83
|
+
...replacement("Latn", ["Noto Sans"], ["Sylfaen"]),
|
|
84
|
+
...replacement("Mong", sansThenSerif("Mong"), ["Mongolian Baiti"]),
|
|
85
|
+
...replacement("Thaa", sansThenSerif("Thaa"), ["MV Boli"]),
|
|
86
|
+
...replacement("Syrc", sansThenSerif("Syrc"), ["Estrangelo Edessa"]),
|
|
87
|
+
...replacement("Tibt", serifThenSans("Tibt"), ["Microsoft Himalaya"]),
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const eastAsianScripts = new Set(["Hani", "Hans", "Hant", "Hanb", "Jpan", "Kore", "Hang", "Jamo", "Hira", "Kana", "Hrkt", "Bopo", "Yiii"]);
|
|
91
|
+
const complexScripts = new Set([
|
|
92
|
+
"Arab", "Hebr", "Syrc", "Thaa", "Nkoo", "Adlm", "Rohg", "Mand", "Samr",
|
|
93
|
+
"Deva", "Beng", "Guru", "Gujr", "Orya", "Taml", "Telu", "Knda", "Mlym", "Sinh",
|
|
94
|
+
"Thai", "Laoo", "Tibt", "Mymr", "Khmr", "Mong", "Bali", "Java", "Lana", "Tale", "Talu", "Cakm", "Olck",
|
|
95
|
+
]);
|
|
96
|
+
const rtlScripts = new Set(["Arab", "Hebr", "Syrc", "Thaa", "Nkoo", "Adlm", "Rohg", "Mand", "Samr"]);
|
|
97
|
+
/** Scripts the latin slot covers directly; never split from the surrounding Latin run. */
|
|
98
|
+
const latinGroup = new Set(["Latn", "Cyrl", "Grek", "Zyyy", "Zinh", "Zzzz"]);
|
|
99
|
+
|
|
100
|
+
/** OOXML script slot for an ISO 15924 script code (the same table as core `scriptFontRole`). */
|
|
101
|
+
export function scriptFontRole(script) {
|
|
102
|
+
if (eastAsianScripts.has(script)) return "eastAsian";
|
|
103
|
+
if (complexScripts.has(script)) return "complexScript";
|
|
104
|
+
return "latin";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const detected = ["Latn", "Cyrl", "Grek", "Hani", "Hira", "Kana", "Hang", "Bopo", "Yiii", "Arab", "Hebr", "Syrc", "Thaa", "Nkoo", "Adlm", "Rohg", "Mand", "Samr",
|
|
108
|
+
"Deva", "Beng", "Guru", "Gujr", "Orya", "Taml", "Telu", "Knda", "Mlym", "Sinh", "Thai", "Laoo", "Tibt", "Mymr", "Khmr", "Mong", "Bali", "Java", "Lana",
|
|
109
|
+
"Tale", "Talu", "Cakm", "Olck", "Armn", "Geor", "Ethi", "Cher", "Tfng", "Vaii", "Zinh", "Zyyy"].map(code => [code, new RegExp(String.raw`^\p{Script=${code}}$`, "u")]);
|
|
110
|
+
const ignorable = /^\p{Default_Ignorable_Code_Point}$/u;
|
|
111
|
+
const scriptCache = new Map();
|
|
112
|
+
|
|
113
|
+
/** ISO 15924 script of one character: `Zyyy` common, `Zinh` inherited, `Zzzz` otherwise unknown. */
|
|
114
|
+
export function scriptOfCharacter(character) {
|
|
115
|
+
let script = scriptCache.get(character);
|
|
116
|
+
if (script === undefined) {
|
|
117
|
+
const code = character.codePointAt(0);
|
|
118
|
+
script = code < 0x80 ? (/[A-Za-z]/.test(character) ? "Latn" : "Zyyy")
|
|
119
|
+
: ignorable.test(character) ? "Zinh"
|
|
120
|
+
: (detected.find(([, pattern]) => pattern.test(character))?.[0] ?? "Zzzz");
|
|
121
|
+
if (scriptCache.size < 65536) scriptCache.set(character, script);
|
|
122
|
+
}
|
|
123
|
+
return script;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Latin, Greek, Cyrillic, general punctuation and letterlike text: no script split is possible. */
|
|
127
|
+
const latinOnly = /^[\u0000-\u036F\u0370-\u052F\u1E00-\u1FFF\u2000-\u2065\u206A-\u20CF\u2100-\u218F]*$/;
|
|
128
|
+
|
|
129
|
+
/** Han script for this text: kana marks Japanese, Hangul marks Korean, otherwise the language decides. */
|
|
130
|
+
function hanScript(text, profile) {
|
|
131
|
+
if (/[\p{Script=Hira}\p{Script=Kana}]/u.test(text)) return "Jpan";
|
|
132
|
+
if (/\p{Script=Hang}/u.test(text)) return "Kore";
|
|
133
|
+
const script = profile?.script;
|
|
134
|
+
if (script === "Jpan" || script === "Kore" || script === "Hans" || script === "Hant") return script;
|
|
135
|
+
const tag = String(profile?.bcp47 ?? "").toLowerCase();
|
|
136
|
+
if (/^ja\b/.test(tag)) return "Jpan";
|
|
137
|
+
if (/^ko\b/.test(tag)) return "Kore";
|
|
138
|
+
if (/^zh\b/.test(tag) && /-(hant|tw|hk|mo)\b/.test(tag)) return "Hant";
|
|
139
|
+
return "Hans";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fontKey(script, text, profile) {
|
|
143
|
+
if (script === "Hira" || script === "Kana") return "Jpan";
|
|
144
|
+
if (script === "Hang") return "Kore";
|
|
145
|
+
if (script === "Hani" || script === "Bopo") return hanScript(text, profile);
|
|
146
|
+
return latinGroup.has(script) ? "Latn" : script;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// East Asian common characters that PowerPoint always draws with the East Asian
|
|
150
|
+
// (a:ea) font: CJK symbols and punctuation, kana marks, enclosed CJK, CJK
|
|
151
|
+
// compatibility, vertical and small forms, and halfwidth/fullwidth forms.
|
|
152
|
+
const eastAsianCommon = /[\u3000-\u303F\u3040-\u30FF\u31F0-\u31FF\u3200-\u33FF\uFE30-\uFE4F\uFF00-\uFFEF]/u;
|
|
153
|
+
// Characters whose font PowerPoint picks from the run language: with an East
|
|
154
|
+
// Asian language they use the a:ea font (curly quotes, dashes, ellipsis,
|
|
155
|
+
// daggers, per-mille, primes, reference mark).
|
|
156
|
+
const eastAsianAmbiguous = /[\u2010-\u2016\u2018-\u2022\u2025\u2026\u2030\u2032\u2033\u203B]/u;
|
|
157
|
+
const eastAsianLanguage = profile => profile?.scriptRole === "eastAsian" || /^(ja|zh|ko)\b/i.test(String(profile?.bcp47 ?? ""));
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Split text into font runs by Unicode script, following PowerPoint's
|
|
161
|
+
* character-to-slot rules where they are known:
|
|
162
|
+
* - Letters take their script's slot. Latin, Greek and Cyrillic share one run.
|
|
163
|
+
* - East Asian common characters (CJK punctuation U+3000-303F, fullwidth forms
|
|
164
|
+
* U+FF00-FFEF and similar) are East Asian, also at the start of a text; with
|
|
165
|
+
* an East Asian language, curly quotes, dashes and the ellipsis are too.
|
|
166
|
+
* - ASCII spaces, digits and punctuation use the latin slot next to East Asian
|
|
167
|
+
* text, and stay in a complex-script run (as PowerPoint draws them with the
|
|
168
|
+
* complex-script font inside Arabic, Hebrew, Indic or Thai text).
|
|
169
|
+
* - Other common and inherited characters (combining marks, other punctuation)
|
|
170
|
+
* join the preceding run, or the following run at the start of the text.
|
|
171
|
+
* `profile` (for example core `resolveScriptFonts` output) supplies the
|
|
172
|
+
* language, which disambiguates Han and the language-dependent characters.
|
|
173
|
+
*/
|
|
174
|
+
export function itemizeScripts(text, profile) {
|
|
175
|
+
const source = String(text);
|
|
176
|
+
const runs = [];
|
|
177
|
+
let pending = "";
|
|
178
|
+
const eastAsian = eastAsianLanguage(profile);
|
|
179
|
+
const push = (character, key) => {
|
|
180
|
+
const last = runs.at(-1);
|
|
181
|
+
if (last && last.script === key) last.text += character;
|
|
182
|
+
else runs.push({text: character, script: key, role: scriptFontRole(key)});
|
|
183
|
+
};
|
|
184
|
+
const strong = (character, key) => {
|
|
185
|
+
// Leading neutrals join a complex-script run; otherwise they are Latin.
|
|
186
|
+
if (!runs.length && pending) {
|
|
187
|
+
if (scriptFontRole(key) === "complexScript") { runs.push({text: pending, script: key, role: "complexScript"}); }
|
|
188
|
+
else push(pending, "Latn");
|
|
189
|
+
pending = "";
|
|
190
|
+
}
|
|
191
|
+
push(character, key);
|
|
192
|
+
};
|
|
193
|
+
for (const character of source) {
|
|
194
|
+
const script = scriptOfCharacter(character);
|
|
195
|
+
if (eastAsianCommon.test(character) || (eastAsian && eastAsianAmbiguous.test(character))) {
|
|
196
|
+
strong(character, script === "Hira" || script === "Kana" ? "Jpan" : hanScript(source, profile));
|
|
197
|
+
} else if (script === "Zinh") {
|
|
198
|
+
if (runs.length) runs.at(-1).text += character; else pending += character;
|
|
199
|
+
} else if (script === "Zyyy") {
|
|
200
|
+
const last = runs.at(-1);
|
|
201
|
+
if (!last) pending += character;
|
|
202
|
+
else if (character.codePointAt(0) < 0x80 && last.role === "eastAsian") push(character, "Latn");
|
|
203
|
+
else last.text += character;
|
|
204
|
+
} else strong(character, fontKey(script, source, profile));
|
|
205
|
+
}
|
|
206
|
+
if (!runs.length) return [{text: pending, script: "Latn", role: "latin"}];
|
|
207
|
+
return runs;
|
|
208
|
+
}
|
|
209
|
+
|
|
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);
|
|
228
|
+
const visit = item => {
|
|
229
|
+
if (typeof item === "string") { for (const script of scriptsOfText(item, profile)) scripts.add(script); }
|
|
230
|
+
else if (Array.isArray(item)) item.forEach(visit);
|
|
231
|
+
else if (item && typeof item === "object") for (const [key, child] of Object.entries(item)) if (!ignored.has(key)) visit(child);
|
|
232
|
+
};
|
|
233
|
+
visit(value);
|
|
234
|
+
if (includeLanguage && profile?.script && !latinGroup.has(profile.script)) scripts.add(fontKey(profile.script, "", profile));
|
|
235
|
+
return [...scripts].sort();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Designated open families for a script key, preferring the serif face for serif schemes. */
|
|
239
|
+
export function designatedFamilies(script, serif = false) {
|
|
240
|
+
const entry = SCRIPT_FONT_FAMILIES[script];
|
|
241
|
+
if (!entry) return [];
|
|
242
|
+
return (serif ? [entry.serif, entry.sans] : [entry.sans, entry.serif]).filter(Boolean);
|
|
243
|
+
}
|
|
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
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Aliases from proprietary script fonts to the first loaded designated
|
|
276
|
+
* replacement. `families` are the loaded script-replacement families.
|
|
277
|
+
*/
|
|
278
|
+
export function scriptFontAliases(families) {
|
|
279
|
+
const loaded = new Map([...families].map(family => [String(family).toLowerCase(), String(family)]));
|
|
280
|
+
const aliases = {};
|
|
281
|
+
for (const rule of SCRIPT_FONT_REPLACEMENTS) {
|
|
282
|
+
const substitute = rule.substitutes.find(family => loaded.has(family.toLowerCase()));
|
|
283
|
+
if (substitute && !Object.hasOwn(aliases, rule.requestedFamily)) aliases[rule.requestedFamily] = loaded.get(substitute.toLowerCase());
|
|
284
|
+
}
|
|
285
|
+
return aliases;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// BCP-47 language subtag -> OpenType language system tag, following HarfBuzz
|
|
289
|
+
// (hb-ot-tag), so measurement selects the same language-specific lookups a
|
|
290
|
+
// browser applies for the SVG's lang (for example KOR spacing in Noto Sans KR).
|
|
291
|
+
const openTypeLanguages = freeze({
|
|
292
|
+
af: "AFK", am: "AMH", ar: "ARA", as: "ASM", ay: "AYM", az: "AZE", be: "BEL", ber: "BBR", bg: "BGR", bn: "BEN", bo: "TIB", bs: "BOS",
|
|
293
|
+
ca: "CAT", ceb: "CEB", crh: "CRT", cs: "CSY", cy: "WEL", da: "DAN", de: "DEU", dv: "DIV", el: "ELL", en: "ENG", es: "ESP", et: "ETI",
|
|
294
|
+
eu: "EUQ", fa: "FAR", ff: "FUL", fi: "FIN", fil: "PIL", fr: "FRA", ga: "IRI", gl: "GAL", gu: "GUJ", ha: "HAU", he: "IWR", iw: "IWR",
|
|
295
|
+
hi: "HIN", hr: "HRV", hu: "HUN", hy: "HYE0", id: "IND", in: "IND", ig: "IBO", is: "ISL", it: "ITA", ja: "JAN", ka: "KAT", kk: "KAZ",
|
|
296
|
+
km: "KHM", kmr: "KUR", kn: "KAN", ko: "KOR", ku: "KUR", lo: "LAO", lt: "LTH", lv: "LVI", mg: "MLG", mi: "MRI", mk: "MKD", ml: "MAL",
|
|
297
|
+
mn: "MNG", mo: "MOL", mr: "MAR", ms: "MLY", zsm: "MLY", my: "BRM", nb: "NOR", ne: "NEP", nl: "NLD", no: "NOR", nv: "NAV", om: "ORO",
|
|
298
|
+
or: "ORI", pa: "PAN", pl: "PLK", ps: "PAS", pt: "PTG", ro: "ROM", ru: "RUS", rw: "RUA", sa: "SAN", sd: "SND", si: "SNH", sk: "SKY",
|
|
299
|
+
sl: "SLV", sn: "SNA0", so: "SML", sq: "SQI", sr: "SRB", sv: "SVE", sw: "SWK", syr: "SYR", ta: "TAM", te: "TEL", tg: "TAJ", th: "THA",
|
|
300
|
+
tl: "PIL", tr: "TRK", tt: "TAT", uk: "UKR", ur: "URD", uz: "UZB", vi: "VIT", xh: "XHS", yo: "YBA", zu: "ZUL",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/** OpenType language system tag for a BCP-47 tag, or undefined when none applies. */
|
|
304
|
+
export function openTypeLanguage(tag) {
|
|
305
|
+
if (typeof tag !== "string" || !tag) return undefined;
|
|
306
|
+
const [language = "", ...rest] = tag.toLowerCase().split("-");
|
|
307
|
+
if (language === "zh") {
|
|
308
|
+
if (rest.includes("hk") || rest.includes("mo")) return "ZHH";
|
|
309
|
+
return rest.includes("hant") || rest.includes("tw") ? "ZHT" : "ZHS";
|
|
310
|
+
}
|
|
311
|
+
return openTypeLanguages[language];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Heading or body role of a text style from its OPF path: title, subtitle and
|
|
316
|
+
* tag placeholders are headings (the exporter's heading shapes), everything
|
|
317
|
+
* else is body. Undefined when the style carries no slide path.
|
|
318
|
+
*/
|
|
319
|
+
export function textRole(style) {
|
|
320
|
+
const path = typeof style?.path === "string" ? style.path : undefined;
|
|
321
|
+
if (!path || !/^slides\.\d+\./.test(path)) return undefined;
|
|
322
|
+
return /^slides\.\d+\.(?:title|subtitle|tag)(?:\.\d+)?$/.test(path) ? "heading" : "body";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** True when the profile's language is written right to left. */
|
|
326
|
+
export function isRtlProfile(profile) {
|
|
327
|
+
return profile?.rtl === true || profile?.direction === "rtl" || (!profile?.direction && rtlScripts.has(profile?.script));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const softErrors = new Set(["font-unavailable", "font-style-unavailable", "unresolved-theme-font", "font-encoding-required", "math-font-required", "invalid-font-family"]);
|
|
331
|
+
const scriptMeasurement = Symbol.for("@openpresentation/opf-render/script-measurement");
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Plan script font runs for one presentation (or slide).
|
|
335
|
+
*
|
|
336
|
+
* `profile` is core `resolveScriptFonts` output (or the same shape):
|
|
337
|
+
* `{heading, body: {latin, eastAsian, complexScript}, supplement?, script, bcp47, rtl}`
|
|
338
|
+
* plus an optional `serif` flag. `measurement` is an optional font-registry
|
|
339
|
+
* `textMeasurement`; without it the plan names every candidate family in order
|
|
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.
|
|
347
|
+
*/
|
|
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();
|
|
358
|
+
const measured = typeof inner?.measure === "function";
|
|
359
|
+
const serif = profile.serif === true;
|
|
360
|
+
// The language the SVG declares (lang) also selects OpenType language systems in measurement.
|
|
361
|
+
const lang = profile.languageSource === "document" || profile.languageSource === "option" ? profile.bcp47 : undefined;
|
|
362
|
+
const styled = style => lang && style.lang === undefined ? {...style, lang} : style;
|
|
363
|
+
const eastAsianText = eastAsianLanguage(profile);
|
|
364
|
+
const plans = new Map();
|
|
365
|
+
const resolvedNames = new Map();
|
|
366
|
+
const coverage = new Map();
|
|
367
|
+
|
|
368
|
+
const resolveName = (family, style) => {
|
|
369
|
+
const key = `${family}\u0000${style.fontWeight ?? 400}\u0000${!!style.italic}`;
|
|
370
|
+
if (resolvedNames.has(key)) return resolvedNames.get(key);
|
|
371
|
+
let name = null;
|
|
372
|
+
if (typeof inner?.resolveStyle !== "function") name = family;
|
|
373
|
+
else {
|
|
374
|
+
try { name = inner.resolveStyle({...style, fontFamily: family}).fontFamily ?? family; }
|
|
375
|
+
catch (error) { if (!softErrors.has(error?.code)) throw error; }
|
|
376
|
+
}
|
|
377
|
+
resolvedNames.set(key, name);
|
|
378
|
+
return name;
|
|
379
|
+
};
|
|
380
|
+
const covers = (family, text, style) => {
|
|
381
|
+
const key = `${family}\u0000${style.fontWeight ?? 400}\u0000${!!style.italic}\u0000${text}`;
|
|
382
|
+
if (coverage.has(key)) return coverage.get(key);
|
|
383
|
+
let result = true;
|
|
384
|
+
try { inner.measure(text, 1, {...style, fontFamily: family}); }
|
|
385
|
+
catch (error) { if (error?.code !== "missing-glyph" && !softErrors.has(error?.code)) throw error; result = false; }
|
|
386
|
+
if (coverage.size >= 8192) coverage.delete(coverage.keys().next().value);
|
|
387
|
+
coverage.set(key, result);
|
|
388
|
+
return result;
|
|
389
|
+
};
|
|
390
|
+
const headingLatin = () => profile.heading?.latin === undefined ? undefined : measured ? resolveName(profile.heading.latin, {fontWeight: 700}) : profile.heading.latin;
|
|
391
|
+
const slotsFor = style => {
|
|
392
|
+
const role = textRole(style);
|
|
393
|
+
// The text's role picks the major (heading) or minor (body) slots, as the
|
|
394
|
+
// exporter's heading shapes do. Only a style without an OPF path falls back
|
|
395
|
+
// to comparing its latin family with the heading's.
|
|
396
|
+
const useHeading = profile.heading && profile.body && JSON.stringify(profile.heading) !== JSON.stringify(profile.body) &&
|
|
397
|
+
(role ? role === "heading" : style.fontFamily === headingLatin());
|
|
398
|
+
const slots = (useHeading ? profile.heading : profile.body) ?? {};
|
|
399
|
+
const supplement = profile.supplement ? {script: profile.supplement.script === "Hang" ? "Kore" : profile.supplement.script, family: useHeading ? profile.supplement.heading : profile.supplement.body} : undefined;
|
|
400
|
+
return {latin: style.fontFamily, eastAsian: slots.eastAsian ?? style.fontFamily, complexScript: slots.complexScript ?? style.fontFamily, supplement};
|
|
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
|
+
};
|
|
433
|
+
const candidatesFor = (run, slots) => {
|
|
434
|
+
const list = [run.role === "eastAsian" ? slots.eastAsian : run.role === "complexScript" ? slots.complexScript : slots.latin];
|
|
435
|
+
if (run.script !== "Latn") {
|
|
436
|
+
if (slots.supplement && slots.supplement.script === run.script) list.push(slots.supplement.family);
|
|
437
|
+
list.push(...designatedFamilies(run.script, serif));
|
|
438
|
+
}
|
|
439
|
+
return [...new Set(list.filter(Boolean))];
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/** Font runs for text drawn in `style` (the resolved latin style). `own` runs use the style's family. */
|
|
443
|
+
const plan = (text, style) => {
|
|
444
|
+
text = String(text ?? "");
|
|
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}`;
|
|
454
|
+
const cached = plans.get(cacheKey);
|
|
455
|
+
if (cached) return cached;
|
|
456
|
+
const slots = slotsFor(style);
|
|
457
|
+
const runs = itemizeScripts(text, profile).map(run => ({...run, candidates: candidatesFor(run, slots)}));
|
|
458
|
+
const pending = new Map();
|
|
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);
|
|
461
|
+
const out = [];
|
|
462
|
+
const push = (text, family, stack) => {
|
|
463
|
+
const own = family === style.fontFamily && (!stack || stack.length === 1);
|
|
464
|
+
const last = out.at(-1);
|
|
465
|
+
if (last && last.family === family && JSON.stringify(last.stack) === JSON.stringify(stack)) last.text += text;
|
|
466
|
+
else out.push({text, family, own, ...(stack && !own ? {stack} : {})});
|
|
467
|
+
};
|
|
468
|
+
for (const run of runs) {
|
|
469
|
+
if (!measured) { push(run.text, run.candidates[0], run.candidates); continue; }
|
|
470
|
+
const chosen = resolveName(run.candidates[0], style);
|
|
471
|
+
const whole = run.candidates.map(family => resolveName(family, style)).find(name => name && covers(name, run.text, style));
|
|
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 = [];
|
|
491
|
+
for (const character of run.text) {
|
|
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
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
flushFallbacks(pending, style);
|
|
515
|
+
if (plans.size >= 4096) plans.delete(plans.keys().next().value);
|
|
516
|
+
plans.set(cacheKey, out);
|
|
517
|
+
return out;
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const styleFor = (run, style) => styled(run.own ? style : {...style, fontFamily: run.family});
|
|
521
|
+
/** Measured advance of each planned run, in order; undefined without a measurement provider. */
|
|
522
|
+
const runWidths = (runs, size, style) => measured ? runs.map(run => inner.measure(run.text, size, styleFor(run, style))) : undefined;
|
|
523
|
+
let textMeasurement;
|
|
524
|
+
if (measured) {
|
|
525
|
+
textMeasurement = {
|
|
526
|
+
...inner,
|
|
527
|
+
measure(text, size, style) {
|
|
528
|
+
const runs = plan(text, style);
|
|
529
|
+
if (runs.length === 1 && runs[0].own) return inner.measure(text, size, styled(style));
|
|
530
|
+
return runs.reduce((total, run) => total + inner.measure(run.text, size, styleFor(run, style)), 0);
|
|
531
|
+
},
|
|
532
|
+
[scriptMeasurement]: {inner, profile, options},
|
|
533
|
+
};
|
|
534
|
+
if (typeof inner.outlineBounds === "function") {
|
|
535
|
+
textMeasurement.outlineBounds = (text, size, style) => {
|
|
536
|
+
const runs = plan(text, style);
|
|
537
|
+
if (runs.length === 1 && runs[0].own) return inner.outlineBounds(text, size, styled(style));
|
|
538
|
+
let x = 0, box = null;
|
|
539
|
+
for (const run of runs) {
|
|
540
|
+
const runStyle = styleFor(run, style), bounds = inner.outlineBounds(run.text, size, runStyle);
|
|
541
|
+
if (bounds) {
|
|
542
|
+
const left = x + bounds.x, top = bounds.y, right = left + bounds.width, bottom = top + bounds.height;
|
|
543
|
+
box = box ? {left: Math.min(box.left, left), top: Math.min(box.top, top), right: Math.max(box.right, right), bottom: Math.max(box.bottom, bottom)} : {left, top, right, bottom};
|
|
544
|
+
}
|
|
545
|
+
x += inner.measure(run.text, size, runStyle);
|
|
546
|
+
}
|
|
547
|
+
return box && {x: box.left, y: box.top, width: box.right - box.left, height: box.bottom - box.top};
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return {profile, plan, runWidths, textMeasurement, rtl: isRtlProfile(profile), get fallbacks() { return [...fallbackNotes.values()].map(note => ({...note, scripts: [...note.scripts], characters: [...note.characters]})); }};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Wrap a font-registry `textMeasurement` so every measurement itemizes text by
|
|
556
|
+
* script and measures each run with its script-slot face (or designated
|
|
557
|
+
* replacement). Pass the same wrapper to core pagination, the renderer and the
|
|
558
|
+
* editor so their line breaks agree for non-Latin text.
|
|
559
|
+
*/
|
|
560
|
+
export function createScriptTextMeasurement(measurement, profile, options) {
|
|
561
|
+
if (typeof measurement?.measure !== "function") throw new TypeError("createScriptTextMeasurement requires a textMeasurement with measure().");
|
|
562
|
+
return createScriptFonts(profile, measurement, options).textMeasurement;
|
|
563
|
+
}
|
package/dist/svg.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EmbeddedFont } from "./fonts.js";
|
|
1
|
+
import type { EmbeddedFont, ScriptFonts } from "./fonts.js";
|
|
2
2
|
import type { SlideComposition, LayoutDiagnostic, TextMeasurement } from "@openpresentation/opf/composition";
|
|
3
3
|
export declare const packageName = "@openpresentation/opf-render";
|
|
4
4
|
|
|
@@ -32,9 +32,25 @@ export declare const engineDefaults: Readonly<{
|
|
|
32
32
|
}>;
|
|
33
33
|
|
|
34
34
|
export type RenderDiagnostic = LayoutDiagnostic | {
|
|
35
|
-
|
|
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
|
+
} | {
|
|
44
|
+
code: "unsupported-pattern" | "date-needs-value" | "language-preview-unavailable" | "language-preview-unresolved" | "paragraph-direction-unavailable";
|
|
45
|
+
path: string;
|
|
46
|
+
message: string;
|
|
47
|
+
} | {
|
|
48
|
+
/** A font-scheme id matched no record; the default font scheme (`aptos`) was used as the base. */
|
|
49
|
+
code: "unresolved-font-scheme";
|
|
36
50
|
path: string;
|
|
37
51
|
message: string;
|
|
52
|
+
id: string;
|
|
53
|
+
fallback: string;
|
|
38
54
|
} | {
|
|
39
55
|
code: "unresolved-asset";
|
|
40
56
|
path: string;
|
|
@@ -48,6 +64,8 @@ export type RenderDiagnostic = LayoutDiagnostic | {
|
|
|
48
64
|
|
|
49
65
|
export interface RenderSvgOptions {
|
|
50
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";
|
|
51
69
|
/** Unscaled reference-pixel clearance around supplied vector text outlines; default 1. */
|
|
52
70
|
textRasterPadding?: number;
|
|
53
71
|
embeddedFonts?: EmbeddedFont[];
|
|
@@ -57,6 +75,11 @@ export interface RenderSvgOptions {
|
|
|
57
75
|
/** When false, skip AJV boundary validation. Default true. */
|
|
58
76
|
validate?: boolean;
|
|
59
77
|
slideIndex?: number;
|
|
78
|
+
/**
|
|
79
|
+
* Today's calendar date (ISO YYYY-MM-DD) for `date: true` header/footer furniture. The renderer
|
|
80
|
+
* never reads a clock; without it a current date is reported as unresolved content.
|
|
81
|
+
*/
|
|
82
|
+
date?: string;
|
|
60
83
|
trace?: boolean;
|
|
61
84
|
catalogs?: Record<string, { records?: unknown[] } | unknown[]>;
|
|
62
85
|
catalogSources?: Record<string, { records?: unknown[] } | unknown[]>;
|
|
@@ -85,6 +108,10 @@ export interface ResolvedPresentation {
|
|
|
85
108
|
|
|
86
109
|
export interface ResolvedSlide {
|
|
87
110
|
geometry: SlideComposition;
|
|
111
|
+
/** Script font plan for this slide (FF-19): slots, language tags and direction. */
|
|
112
|
+
scriptFonts: ScriptFonts;
|
|
113
|
+
/** The script-aware measurement composition used; undefined for estimated layout. */
|
|
114
|
+
textMeasurement?: TextMeasurement;
|
|
88
115
|
index: number;
|
|
89
116
|
path: string;
|
|
90
117
|
slide: unknown;
|