@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.
Files changed (98) hide show
  1. package/README.md +115 -13
  2. package/dist/font-compatibility.js +72 -3
  3. package/dist/font-manifest.js +1746 -12
  4. package/dist/font-policy.js +2518 -0
  5. package/dist/fonts-browser.d.ts +65 -1
  6. package/dist/fonts-browser.js +229 -35
  7. package/dist/fonts-node.d.ts +68 -10
  8. package/dist/fonts-node.js +152 -16
  9. package/dist/fonts.d.ts +87 -5
  10. package/dist/fonts.js +147 -37
  11. package/dist/index.d.ts +31 -2
  12. package/dist/lazy-font-list.js +23 -0
  13. package/dist/lazy-fonts.js +52 -0
  14. package/dist/raster.js +3 -1
  15. package/dist/script-font-pack.js +179 -0
  16. package/dist/script-fonts.js +563 -0
  17. package/dist/svg.d.ts +29 -2
  18. package/dist/svg.js +446 -41
  19. package/fonts/bebas-neue/BebasNeue_400Regular.ttf +0 -0
  20. package/fonts/bebas-neue/OFL.txt +93 -0
  21. package/fonts/bebas-neue/PROVENANCE.json +23 -0
  22. package/fonts/carlito/Carlito-Bold.ttf +0 -0
  23. package/fonts/carlito/Carlito-BoldItalic.ttf +0 -0
  24. package/fonts/carlito/Carlito-Italic.ttf +0 -0
  25. package/fonts/carlito/Carlito-Regular.ttf +0 -0
  26. package/fonts/carlito/OFL.txt +94 -0
  27. package/fonts/intos/Intos-Bold.ttf +0 -0
  28. package/fonts/intos/Intos-BoldItalic.ttf +0 -0
  29. package/fonts/intos/Intos-Italic.ttf +0 -0
  30. package/fonts/intos/Intos-Regular.ttf +0 -0
  31. package/fonts/intos/IntosDisplay-Bold.ttf +0 -0
  32. package/fonts/intos/IntosDisplay-BoldItalic.ttf +0 -0
  33. package/fonts/intos/IntosDisplay-Italic.ttf +0 -0
  34. package/fonts/intos/IntosDisplay-Regular.ttf +0 -0
  35. package/fonts/intos/IntosNarrow-Bold.ttf +0 -0
  36. package/fonts/intos/IntosNarrow-BoldItalic.ttf +0 -0
  37. package/fonts/intos/IntosNarrow-Italic.ttf +0 -0
  38. package/fonts/intos/IntosNarrow-Regular.ttf +0 -0
  39. package/fonts/intos/IntosSerif-Bold.ttf +0 -0
  40. package/fonts/intos/IntosSerif-BoldItalic.ttf +0 -0
  41. package/fonts/intos/IntosSerif-Italic.ttf +0 -0
  42. package/fonts/intos/IntosSerif-Regular.ttf +0 -0
  43. package/fonts/intos/NOTICE.md +18 -0
  44. package/fonts/intos/OFL.txt +94 -0
  45. package/fonts/intos/PROVENANCE.json +144 -0
  46. package/fonts/lora/Lora-Bold.ttf +0 -0
  47. package/fonts/lora/Lora-BoldItalic.ttf +0 -0
  48. package/fonts/lora/Lora-Italic.ttf +0 -0
  49. package/fonts/lora/Lora-Regular.ttf +0 -0
  50. package/fonts/lora/OFL.txt +93 -0
  51. package/fonts/lora/PROVENANCE.json +48 -0
  52. package/fonts/merriweather-sans/MerriweatherSans-Bold.ttf +0 -0
  53. package/fonts/merriweather-sans/MerriweatherSans-BoldItalic.ttf +0 -0
  54. package/fonts/merriweather-sans/MerriweatherSans-Italic.ttf +0 -0
  55. package/fonts/merriweather-sans/MerriweatherSans-Regular.ttf +0 -0
  56. package/fonts/merriweather-sans/OFL.txt +96 -0
  57. package/fonts/merriweather-sans/PROVENANCE.json +48 -0
  58. package/fonts/montserrat/Montserrat_400Regular.ttf +0 -0
  59. package/fonts/montserrat/Montserrat_400Regular_Italic.ttf +0 -0
  60. package/fonts/montserrat/Montserrat_700Bold.ttf +0 -0
  61. package/fonts/montserrat/Montserrat_700Bold_Italic.ttf +0 -0
  62. package/fonts/montserrat/Montserrat_900Black.ttf +0 -0
  63. package/fonts/montserrat/OFL.txt +93 -0
  64. package/fonts/montserrat/PROVENANCE.json +55 -0
  65. package/fonts/open-sans/OFL.txt +92 -0
  66. package/fonts/open-sans/OpenSans_400Regular.ttf +0 -0
  67. package/fonts/open-sans/OpenSans_400Regular_Italic.ttf +0 -0
  68. package/fonts/open-sans/OpenSans_700Bold.ttf +0 -0
  69. package/fonts/open-sans/OpenSans_700Bold_Italic.ttf +0 -0
  70. package/fonts/open-sans/PROVENANCE.json +47 -0
  71. package/fonts/poppins/OFL.txt +93 -0
  72. package/fonts/poppins/PROVENANCE.json +47 -0
  73. package/fonts/poppins/Poppins_400Regular.ttf +0 -0
  74. package/fonts/poppins/Poppins_400Regular_Italic.ttf +0 -0
  75. package/fonts/poppins/Poppins_700Bold.ttf +0 -0
  76. package/fonts/poppins/Poppins_700Bold_Italic.ttf +0 -0
  77. package/fonts/pt-serif/OFL.txt +93 -0
  78. package/fonts/pt-serif/PROVENANCE.json +50 -0
  79. package/fonts/pt-serif/PT_Serif-Web-Bold.ttf +0 -0
  80. package/fonts/pt-serif/PT_Serif-Web-BoldItalic.ttf +0 -0
  81. package/fonts/pt-serif/PT_Serif-Web-Italic.ttf +0 -0
  82. package/fonts/pt-serif/PT_Serif-Web-Regular.ttf +0 -0
  83. package/fonts/red-hat-display/OFL.txt +93 -0
  84. package/fonts/red-hat-display/PROVENANCE.json +38 -0
  85. package/fonts/red-hat-display/RedHatDisplay-Bold.ttf +0 -0
  86. package/fonts/red-hat-display/RedHatDisplay-Light.ttf +0 -0
  87. package/fonts/red-hat-display/RedHatDisplay-Regular.ttf +0 -0
  88. package/fonts/red-hat-text/OFL.txt +93 -0
  89. package/fonts/red-hat-text/PROVENANCE.json +30 -0
  90. package/fonts/red-hat-text/RedHatText-Bold.ttf +0 -0
  91. package/fonts/red-hat-text/RedHatText-Regular.ttf +0 -0
  92. package/fonts/source-sans-3/OFL.txt +93 -0
  93. package/fonts/source-sans-3/PROVENANCE.json +49 -0
  94. package/fonts/source-sans-3/SourceSans3-Bold.ttf +0 -0
  95. package/fonts/source-sans-3/SourceSans3-BoldIt.ttf +0 -0
  96. package/fonts/source-sans-3/SourceSans3-It.ttf +0 -0
  97. package/fonts/source-sans-3/SourceSans3-Regular.ttf +0 -0
  98. 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
- code: "unsupported-pattern" | "date-needs-value";
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;