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