@furifont/core 0.1.1 → 0.2.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/dist/index.mjs ADDED
@@ -0,0 +1,1346 @@
1
+ import * as v from "valibot";
2
+ //#region src/defaults.ts
3
+ /**
4
+ * Every default of a build, and how the prebuilt fonts are built, in one place.
5
+ * Config files, the CLI, the studio and `scripts/build-fonts.ts` all read it, so
6
+ * a change here reaches them together. What an option means is in `schema.ts`.
7
+ */
8
+ const DEFAULTS = {
9
+ /** What a plan does when a config leaves an option out. */
10
+ plan: {
11
+ family: "Furifont Sans",
12
+ rubyScript: "hiragana",
13
+ /** Newspaper frequency buckets, nf01 to nf48: the window of words that get ruby. All of them: it adds about 400 glyphs. */
14
+ nfMin: 1,
15
+ nfMax: 48,
16
+ /** JMdict priority tags that select a word whatever its bucket: the first tier of each list, which JMdict marks as common. */
17
+ selectTags: [
18
+ "ichi1",
19
+ "news1",
20
+ "spec1",
21
+ "gai1"
22
+ ],
23
+ /** Uses in Tatoeba's example sentences that select a word whatever its bucket: about 1,000 more words, and 50 glyphs. */
24
+ minTatoeba: 3,
25
+ places: true,
26
+ isolated: true,
27
+ isolatedMinShare: .8,
28
+ /** Sizes in em, relative to the base font. */
29
+ metrics: {
30
+ rubyScale: .5,
31
+ rubyGap: .05,
32
+ compressionFloor: .8,
33
+ maxRubyWidth: 1.5
34
+ }
35
+ },
36
+ /** What the CLI adds to a plan: where the glyphs are drawn, and what is written. */
37
+ build: {
38
+ base: "Source Han Sans JP VF",
39
+ formats: ["otf", "woff2"]
40
+ }
41
+ };
42
+ /** The prebuilt fonts, which each release carries; the site serves their variable WOFF2s. */
43
+ const PREBUILT = {
44
+ /** Each Source Han face in each ruby script. The first is the default family. */
45
+ fonts: [
46
+ {
47
+ family: "Furifont Sans",
48
+ base: "Source Han Sans JP",
49
+ face: "sans",
50
+ rubyScript: "hiragana"
51
+ },
52
+ {
53
+ family: "Furifont Sans Romaji",
54
+ base: "Source Han Sans JP",
55
+ face: "sans",
56
+ rubyScript: "hepburn"
57
+ },
58
+ {
59
+ family: "Furifont Serif",
60
+ base: "Source Han Serif JP",
61
+ face: "serif",
62
+ rubyScript: "hiragana"
63
+ },
64
+ {
65
+ family: "Furifont Serif Romaji",
66
+ base: "Source Han Serif JP",
67
+ face: "serif",
68
+ rubyScript: "hepburn"
69
+ }
70
+ ],
71
+ /** The static weights of the release, each of the base's weights by name. Bold is the variable font's. */
72
+ weights: [{
73
+ style: "Regular",
74
+ weight: 400
75
+ }],
76
+ /**
77
+ * Every weight in one font, from the base's variable font (`<base> VF`). It
78
+ * is named apart, `<family> VF`, so it installs beside the static weights.
79
+ */
80
+ variable: {
81
+ style: "VF",
82
+ weights: [250, 900]
83
+ },
84
+ /** Options set on every prebuilt font over the defaults above, such as `{ nfMax: 24 }`. */
85
+ options: {}
86
+ };
87
+ /**
88
+ * The sequence, 。 and VS1, a page measures to tell which version of Furifont
89
+ * it has: the font maps it to an empty glyph two ems wide plus the revision in
90
+ * ems (`0.1.2`, revision 0.102, is 2.102 ems). A font from before it draws the
91
+ * 。 alone, an em wide at most.
92
+ */
93
+ const VERSION_PROBE = {
94
+ base: 12290,
95
+ selector: 65024
96
+ };
97
+ //#endregion
98
+ //#region src/dictionary.ts
99
+ const HAN = /\p{Script=Han}/u;
100
+ function isHan(char) {
101
+ return HAN.test(char);
102
+ }
103
+ function parseFurigana(furigana) {
104
+ return furigana.split(";").filter(Boolean).map((part) => {
105
+ const [range, ruby = ""] = part.split(":");
106
+ const [start, end = start] = range.split("-").map(Number);
107
+ return {
108
+ start,
109
+ end: end + 1,
110
+ ruby
111
+ };
112
+ });
113
+ }
114
+ /**
115
+ * Splits a form into its Han runs. Browsers itemize text by script before
116
+ * shaping, so a font only ever sees one run at a time: okurigana and other
117
+ * kana are never visible as context. Runs with an unannotated kanji or an
118
+ * annotation leaking outside the run are dropped.
119
+ */
120
+ function hanRuns(form, spans) {
121
+ const chars = [...form];
122
+ const runs = [];
123
+ for (let i = 0; i < chars.length;) {
124
+ if (!isHan(chars[i])) {
125
+ i++;
126
+ continue;
127
+ }
128
+ let end = i;
129
+ while (end < chars.length && isHan(chars[end])) end++;
130
+ const inside = spans.filter((s) => s.start < end && s.end > i);
131
+ if (inside.reduce((n, s) => n + s.end - s.start, 0) === end - i && inside.every((s) => s.start >= i && s.end <= end && s.ruby)) runs.push({
132
+ text: chars.slice(i, end).join(""),
133
+ spans: inside.map((s) => ({
134
+ ...s,
135
+ start: s.start - i,
136
+ end: s.end - i
137
+ }))
138
+ });
139
+ i = end;
140
+ }
141
+ return runs;
142
+ }
143
+ /**
144
+ * Parses the inline notation for custom words: `日[に]本[ほん]` for one reading
145
+ * per kanji, `今日[きょう]` for a reading over several kanji, kana as written.
146
+ */
147
+ function parseNotation(notation) {
148
+ const spans = [];
149
+ let form = "";
150
+ for (const match of notation.matchAll(/([^[\]]*)\[([^\]]*)\]|([^[\]]+)$/g)) {
151
+ const [, base = match[3], ruby] = match;
152
+ const chars = [...base];
153
+ const start = [...form].length;
154
+ form += base;
155
+ if (ruby === void 0) continue;
156
+ let kanjiFrom = chars.length;
157
+ while (kanjiFrom > 0 && isHan(chars[kanjiFrom - 1])) kanjiFrom--;
158
+ if (kanjiFrom === chars.length) throw new Error(`"[${ruby}]" in "${notation}" does not follow a kanji`);
159
+ spans.push({
160
+ start: start + kanjiFrom,
161
+ end: start + chars.length,
162
+ ruby
163
+ });
164
+ }
165
+ if (!spans.length) throw new Error(`"${notation}" has no reading, write it like 日[に]本[ほん]`);
166
+ return {
167
+ form,
168
+ spans
169
+ };
170
+ }
171
+ function formatNotation(text, spans) {
172
+ const chars = [...text];
173
+ let out = "";
174
+ let at = 0;
175
+ for (const s of spans) {
176
+ out += `${chars.slice(at, s.end).join("")}[${s.ruby}]`;
177
+ at = s.end;
178
+ }
179
+ return out + chars.slice(at).join("");
180
+ }
181
+ //#endregion
182
+ //#region src/glyphs.ts
183
+ function glyphName(codepoint) {
184
+ const hex = codepoint.toString(16).toUpperCase();
185
+ return codepoint > 65535 ? `u${hex}` : `uni${hex.padStart(4, "0")}`;
186
+ }
187
+ function codepoints(text) {
188
+ return [...text].map((c) => c.codePointAt(0));
189
+ }
190
+ function isGrouped(run) {
191
+ return run.spans.some((s) => s.end - s.start > 1);
192
+ }
193
+ /** Glyphs keyed by what they draw, so every word sharing a reading shares the glyph. */
194
+ function createGlyphRegistry(render) {
195
+ const glyphs = /* @__PURE__ */ new Map();
196
+ const counters = /* @__PURE__ */ new Map();
197
+ const draw = (text, ruby) => {
198
+ const key = `${text}|${ruby.map((r) => `${r.start}-${r.end}:${r.text}`).join(";")}`;
199
+ const existing = glyphs.get(key);
200
+ if (existing) return existing;
201
+ const bases = codepoints(text);
202
+ const base = bases.map(glyphName).join("_");
203
+ const n = (counters.get(base) ?? 0) + 1;
204
+ counters.set(base, n);
205
+ const glyph = {
206
+ name: base.length > 56 ? `run${glyphs.size}.rb.${n}` : `${base}.rb.${n}`,
207
+ bases,
208
+ ruby
209
+ };
210
+ glyphs.set(key, glyph);
211
+ return glyph;
212
+ };
213
+ const kanji = (run, index) => {
214
+ const span = run.spans[index];
215
+ return draw([...run.text].slice(span.start, span.end).join(""), [{
216
+ start: 0,
217
+ end: 1,
218
+ text: render(run, index)
219
+ }]);
220
+ };
221
+ return {
222
+ all: () => [...glyphs.values()],
223
+ run: (run) => isGrouped(run) ? [draw(run.text, run.spans.map((s, i) => ({
224
+ start: s.start,
225
+ end: s.end,
226
+ text: render(run, i)
227
+ })))] : run.spans.map((_, i) => kanji(run, i)),
228
+ lone: (text, ruby) => kanji({
229
+ text,
230
+ spans: [{
231
+ start: 0,
232
+ end: 1,
233
+ ruby
234
+ }]
235
+ }, 0)
236
+ };
237
+ }
238
+ /** Single substitutions, packed so each lookup maps an input glyph at most once. */
239
+ function createSingleLookups() {
240
+ const byInput = /* @__PURE__ */ new Map();
241
+ return {
242
+ lookupFor(input, output) {
243
+ let outputs = byInput.get(input);
244
+ if (!outputs) byInput.set(input, outputs = /* @__PURE__ */ new Map());
245
+ let slot = outputs.get(output);
246
+ if (!slot) outputs.set(output, slot = outputs.size + 1);
247
+ return `rb_${slot}`;
248
+ },
249
+ all: () => [...byInput].flatMap(([input, outputs]) => [...outputs].map(([output, slot]) => ({
250
+ lookup: `rb_${slot}`,
251
+ input,
252
+ output
253
+ })))
254
+ };
255
+ }
256
+ //#endregion
257
+ //#region src/kana.ts
258
+ /** Hiragana U+3041..U+3096 sit exactly 0x60 below their katakana. */
259
+ const KATAKANA_OFFSET = 96;
260
+ function toKatakana(hiragana) {
261
+ return hiragana.replace(/[\u3041-\u3096]/g, (c) => String.fromCharCode(c.charCodeAt(0) + KATAKANA_OFFSET));
262
+ }
263
+ function toHiragana(katakana) {
264
+ return katakana.replace(/[\u30A1-\u30F6]/g, (c) => String.fromCharCode(c.charCodeAt(0) - KATAKANA_OFFSET));
265
+ }
266
+ const VOICED = {
267
+ か: ["が"],
268
+ き: ["ぎ"],
269
+ く: ["ぐ"],
270
+ け: ["げ"],
271
+ こ: ["ご"],
272
+ さ: ["ざ"],
273
+ し: ["じ"],
274
+ す: ["ず"],
275
+ せ: ["ぜ"],
276
+ そ: ["ぞ"],
277
+ た: ["だ"],
278
+ ち: ["ぢ", "じ"],
279
+ つ: ["づ", "ず"],
280
+ て: ["で"],
281
+ と: ["ど"],
282
+ は: ["ば", "ぱ"],
283
+ ひ: ["び", "ぴ"],
284
+ ふ: ["ぶ", "ぷ"],
285
+ へ: ["べ", "ぺ"],
286
+ ほ: ["ぼ", "ぽ"]
287
+ };
288
+ /**
289
+ * Whether `reading` is one of the kanji's on'yomi as it surfaces inside a word:
290
+ * as listed, with its first kana voiced (rendaku), or with a final く/き/ち/つ
291
+ * turned into っ (sokuon), e.g. 学 ガク surfacing as がっ in 学校.
292
+ */
293
+ function isOnyomi(reading, onyomi) {
294
+ return onyomi.some((on) => {
295
+ const forms = [on, ...(VOICED[on[0]] ?? []).map((v) => v + on.slice(1))];
296
+ if (/[くきちつ]$/.test(on)) forms.push(...forms.map((f) => `${f.slice(0, -1)}っ`));
297
+ return forms.includes(reading);
298
+ });
299
+ }
300
+ const ROMAJI = {
301
+ あ: "a",
302
+ い: "i",
303
+ う: "u",
304
+ え: "e",
305
+ お: "o",
306
+ か: "ka",
307
+ き: "ki",
308
+ く: "ku",
309
+ け: "ke",
310
+ こ: "ko",
311
+ が: "ga",
312
+ ぎ: "gi",
313
+ ぐ: "gu",
314
+ げ: "ge",
315
+ ご: "go",
316
+ さ: "sa",
317
+ し: "shi",
318
+ す: "su",
319
+ せ: "se",
320
+ そ: "so",
321
+ ざ: "za",
322
+ じ: "ji",
323
+ ず: "zu",
324
+ ぜ: "ze",
325
+ ぞ: "zo",
326
+ た: "ta",
327
+ ち: "chi",
328
+ つ: "tsu",
329
+ て: "te",
330
+ と: "to",
331
+ だ: "da",
332
+ ぢ: "ji",
333
+ づ: "zu",
334
+ で: "de",
335
+ ど: "do",
336
+ な: "na",
337
+ に: "ni",
338
+ ぬ: "nu",
339
+ ね: "ne",
340
+ の: "no",
341
+ は: "ha",
342
+ ひ: "hi",
343
+ ふ: "fu",
344
+ へ: "he",
345
+ ほ: "ho",
346
+ ば: "ba",
347
+ び: "bi",
348
+ ぶ: "bu",
349
+ べ: "be",
350
+ ぼ: "bo",
351
+ ぱ: "pa",
352
+ ぴ: "pi",
353
+ ぷ: "pu",
354
+ ぺ: "pe",
355
+ ぽ: "po",
356
+ ま: "ma",
357
+ み: "mi",
358
+ む: "mu",
359
+ め: "me",
360
+ も: "mo",
361
+ や: "ya",
362
+ ゆ: "yu",
363
+ よ: "yo",
364
+ ら: "ra",
365
+ り: "ri",
366
+ る: "ru",
367
+ れ: "re",
368
+ ろ: "ro",
369
+ わ: "wa",
370
+ ゐ: "i",
371
+ ゑ: "e",
372
+ を: "o",
373
+ ゔ: "vu",
374
+ ぁ: "a",
375
+ ぃ: "i",
376
+ ぅ: "u",
377
+ ぇ: "e",
378
+ ぉ: "o",
379
+ ゃ: "ya",
380
+ ゅ: "yu",
381
+ ょ: "yo",
382
+ ゎ: "wa"
383
+ };
384
+ const YOON = {
385
+ ゃ: "a",
386
+ ゅ: "u",
387
+ ょ: "o"
388
+ };
389
+ function syllables(kana) {
390
+ const out = [];
391
+ for (let i = 0; i < kana.length; i++) {
392
+ const c = kana[i];
393
+ const small = YOON[kana[i + 1] ?? ""];
394
+ const base = ROMAJI[c];
395
+ if (small && base && base.length > 1 && base.endsWith("i")) {
396
+ const stem = base.slice(0, -1);
397
+ out.push({
398
+ kana: c + kana[++i],
399
+ romaji: (/(?:sh|ch|j)$/.test(stem) ? stem : `${stem}y`) + small
400
+ });
401
+ } else out.push({
402
+ kana: c,
403
+ romaji: base ?? c
404
+ });
405
+ }
406
+ return out;
407
+ }
408
+ function doubled(next) {
409
+ if (next.startsWith("ch")) return "t";
410
+ return /^[^aeiou\W]/.test(next) ? next[0] : "";
411
+ }
412
+ /** Folds a long vowel into a macron: おう and おお give ō, うう gives ū. */
413
+ function lengthen(out, kana) {
414
+ if (kana === "う" && /[ou]$/.test(out) || kana === "お" && out.endsWith("o")) return out.slice(0, -1) + (out.endsWith("u") ? "ū" : "ō");
415
+ }
416
+ /**
417
+ * Modified Hepburn with macrons (きょう → kyō, おおきい → ōkii). `following` is
418
+ * the kana written after this one in the word, so a trailing っ can double
419
+ * the next consonant (学 がっ before こう → gak).
420
+ */
421
+ function toHepburn(kana, following = "") {
422
+ const parts = syllables(kana);
423
+ const after = [...parts, ...syllables(following)];
424
+ let out = "";
425
+ parts.forEach(({ kana: k, romaji }, i) => {
426
+ const next = after.slice(i + 1).find((s) => s.kana !== "っ")?.romaji ?? "";
427
+ if (k === "っ") out += doubled(next);
428
+ else if (k === "ん") out += i + 1 < parts.length && /^[aeiouy]/.test(next) ? "n'" : "n";
429
+ else out = lengthen(out, k) ?? out + romaji;
430
+ });
431
+ return out;
432
+ }
433
+ //#endregion
434
+ //#region src/fea.ts
435
+ /**
436
+ * Rules per subtable of the main lookup. Breaking it up lets the compiler pack
437
+ * each part as one glyph-keyed format 1 subtable under the 16-bit offset limit,
438
+ * instead of thousands of format 3 subtables; order is kept across the breaks.
439
+ */
440
+ const RULES_PER_SUBTABLE = 600;
441
+ function block(keyword, name, body) {
442
+ return `${keyword} ${name}${keyword === "lookup" ? " useExtension" : ""} {\n${body.map((l) => ` ${l}`).join("\n")}\n} ${name};\n`;
443
+ }
444
+ /** A lookup of single substitutions, or none when there are none. */
445
+ function singleLookup(name, substitutions) {
446
+ return substitutions.length ? [block("lookup", name, substitutions.map((s) => `sub ${s.input} by ${s.output};`))] : [];
447
+ }
448
+ function chainRule(rule) {
449
+ if (rule.ignore) return `ignore sub ${rule.input.map((g) => `${g}'`).join(" ")};`;
450
+ return `sub ${rule.input.map((g, i) => `${g}'${rule.lookups[i] ? ` lookup ${rule.lookups[i]}` : ""}`).join(" ")};`;
451
+ }
452
+ const glyphClass = (substitutions) => `[${substitutions.map((s) => s.input).join(" ")}]`;
453
+ /** The chain rules, longest first, then the suffix and lone-kanji class rules. */
454
+ function mainRules(input) {
455
+ const words = input.rules.flatMap((rule, i) => i && i % RULES_PER_SUBTABLE === 0 ? ["subtable;", chainRule(rule)] : [chainRule(rule)]);
456
+ const classes = [];
457
+ const final = input.suffixes.filter((s) => s.final);
458
+ if (final.length) classes.push(`ignore sub @han ${glyphClass(final)}' @han;`);
459
+ if (input.suffixes.length) classes.push(`sub @han ${glyphClass(input.suffixes)}' lookup suffixes;`);
460
+ if (input.isolated.length) {
461
+ const kanji = glyphClass(input.isolated);
462
+ classes.push(`ignore sub @han ${kanji}';`, `ignore sub ${kanji}' @han;`, `sub ${kanji}' lookup isolated;`);
463
+ }
464
+ return [
465
+ ...words,
466
+ ...words.length && classes.length ? ["subtable;"] : [],
467
+ ...classes
468
+ ];
469
+ }
470
+ /**
471
+ * Writes the feature file. Nested lookups come first so the main one can call
472
+ * them; the fallbacks come after it, in lookup order, so they only see what it
473
+ * left: `ccmp` turns unclaimed variant glyphs back into kanji, `ss01` undoes ruby.
474
+ */
475
+ function emitFea(input) {
476
+ const out = ["languagesystem DFLT dflt;\n"];
477
+ if (input.hanClass.length) out.push(`@han = [${input.hanClass.join(" ")}];\n`);
478
+ const bySlot = Map.groupBy(input.singles, (s) => s.lookup);
479
+ for (const [lookup, singles] of [...bySlot].sort((a, b) => a[0].localeCompare(b[0], "en", { numeric: true }))) out.push(block("lookup", lookup, singles.map((s) => `sub ${s.input} by ${s.output};`)));
480
+ const byLigatureSlot = Map.groupBy(input.ligatures, (l) => l.lookup);
481
+ for (const [lookup, ligatures] of [...byLigatureSlot].sort((a, b) => a[0].localeCompare(b[0], "en", { numeric: true }))) out.push(block("lookup", lookup, ligatures.map((l) => `sub ${l.components.join(" ")} by ${l.glyph};`)));
482
+ out.push(...singleLookup("isolated", input.isolated), ...singleLookup("suffixes", input.suffixes));
483
+ const salt = {
484
+ lookup: [],
485
+ feature: []
486
+ };
487
+ if (input.alternates.length) {
488
+ const selectors = `[${input.alternates.flatMap((a) => a.outputs).join(" ")}]`;
489
+ salt.lookup = [
490
+ block("lookup", "alternates", input.alternates.map((a) => `sub ${a.input} from [${a.outputs.join(" ")}];`)),
491
+ block("lookup", "salt_back", input.alternates.flatMap((a) => a.outputs.map((o) => `sub ${o} by ${a.input};`))),
492
+ block("lookup", "salt_first", [`sub @han ${selectors}' lookup salt_back;`])
493
+ ];
494
+ salt.feature = [block("feature", "salt", ["lookup alternates;", "lookup salt_first;"])];
495
+ }
496
+ out.push(...salt.lookup);
497
+ const main = mainRules(input);
498
+ if (main.length) out.push(block("lookup", "furigana", main));
499
+ out.push(...singleLookup("variant_readings", input.variantReadings), ...singleLookup("variants_off", input.variantsOff));
500
+ const singles = input.rubyOff.filter((r) => r.bases.length === 1);
501
+ const ligatures = input.rubyOff.filter((r) => r.bases.length > 1);
502
+ const off = [];
503
+ if (singles.length) {
504
+ out.push(block("lookup", "ruby_off", singles.map((r) => `sub ${r.glyph} by ${r.bases[0]};`)));
505
+ off.push("lookup ruby_off;");
506
+ }
507
+ if (ligatures.length) {
508
+ out.push(block("lookup", "ruby_off_ligatures", ligatures.map((r) => `sub ${r.glyph} by ${r.bases.join(" ")};`)));
509
+ off.push("lookup ruby_off_ligatures;");
510
+ }
511
+ const rclt = [main.length && "lookup furigana;", input.variantReadings.length && "lookup variant_readings;"].filter((l) => typeof l === "string");
512
+ if (rclt.length) out.push(block("feature", "rclt", rclt));
513
+ if (input.variantsOff.length) out.push(block("feature", "ccmp", ["lookup variants_off;"]));
514
+ if (off.length) out.push(block("feature", "ss01", off));
515
+ out.push(...salt.feature);
516
+ return out.join("\n");
517
+ }
518
+ //#endregion
519
+ //#region src/planner.ts
520
+ /** VS225: shows the kanji before it without ruby. */
521
+ const SELECTOR_SUPPRESS = 917984;
522
+ /** VS226: reading 1 of the kanji (or word) before it; VS227 reading 2, and so on. */
523
+ const SELECTOR_FIRST_READING = 917985;
524
+ /** The IVD leaves VS225..VS240 unregistered: one to suppress, fifteen readings. */
525
+ const MAX_READINGS = 15;
526
+ /** A lone kanji's reading needs this share of the evidence to be offered through a selector. */
527
+ const MIN_ALTERNATE_SHARE = .02;
528
+ /** A suffix reading is the one a kanji takes in this share of the runs it ends, out of at least `SUFFIX_MIN_RUNS`. */
529
+ const SUFFIX_MIN_SHARE = .9;
530
+ const SUFFIX_MIN_RUNS = 10;
531
+ /** With this many uses alone in the example sentences, they decide a lone kanji rather than the word lists. */
532
+ const LONE_MIN_USES = 20;
533
+ /** JMdict orders readings within a form; a run read inside a longer form ranks after the form's own readings. */
534
+ const OTHER_FORM_ORDER = 1e3;
535
+ const length = (text) => [...text].length;
536
+ function spanKey(spans) {
537
+ return spans.map((s) => `${s.start}-${s.end}:${s.ruby}`).join(";");
538
+ }
539
+ function rank(a, b) {
540
+ return (b.pinned ?? 0) - (a.pinned ?? 0) || Number(b.tagged) - Number(a.tagged) || a.order - b.order || b.weight - a.weight;
541
+ }
542
+ /** Rough corpus frequency of an entry: nf buckets hold ~500 words each, ranked by frequency. */
543
+ function entryWeight(entry) {
544
+ if (!entry.priority) return 0;
545
+ const nf = entry.nf || Number(/nf(\d+)/.exec(entry.priority)?.[1] ?? 0);
546
+ if (nf) return 1 / nf;
547
+ return /[a-z]1\b/.test(entry.priority) ? 1 / 24 : 1 / 48;
548
+ }
549
+ function keepBest(current, value) {
550
+ current.weight = Math.max(current.weight, value.weight);
551
+ if (value.nf && (!current.nf || value.nf < current.nf)) current.nf = value.nf;
552
+ current.jlpt = Math.max(current.jlpt, value.jlpt);
553
+ for (const tag of value.tags) current.tags.add(tag);
554
+ }
555
+ function priorityTags(entry) {
556
+ return new Set(entry.priority.split(" ").filter(Boolean));
557
+ }
558
+ function getOrCreate(map, key, create) {
559
+ let value = map.get(key);
560
+ if (value === void 0) map.set(key, value = create());
561
+ return value;
562
+ }
563
+ function indexRun(index, run, entry) {
564
+ const order = run.text === entry.form ? entry.order : OTHER_FORM_ORDER + entry.order;
565
+ const reading = {
566
+ spans: run.spans,
567
+ weight: entryWeight(entry),
568
+ tagged: !!entry.priority,
569
+ order,
570
+ nf: entry.priority ? entry.nf : 0,
571
+ jlpt: entry.jlpt ?? 0,
572
+ tags: priorityTags(entry),
573
+ tatoeba: entry.tatoeba ?? 0
574
+ };
575
+ const readings = getOrCreate(index.runs, run.text, () => /* @__PURE__ */ new Map());
576
+ const current = readings.get(spanKey(run.spans));
577
+ if (!current) {
578
+ readings.set(spanKey(run.spans), reading);
579
+ return;
580
+ }
581
+ keepBest(current, reading);
582
+ current.tagged ||= reading.tagged;
583
+ current.order = Math.min(current.order, reading.order);
584
+ current.tatoeba = Math.max(current.tatoeba ?? 0, reading.tatoeba);
585
+ }
586
+ function indexSingle(index, run, entry) {
587
+ const ruby = run.spans[0].ruby;
588
+ const readings = getOrCreate(index.singles, run.text, () => /* @__PURE__ */ new Map());
589
+ for (const r of entry.conjugatesReading && ruby === "く" ? [
590
+ "く",
591
+ "き",
592
+ "こ"
593
+ ] : [ruby]) {
594
+ const value = {
595
+ weight: entryWeight(entry),
596
+ nf: entry.nf,
597
+ jlpt: entry.jlpt ?? 0,
598
+ tags: priorityTags(entry)
599
+ };
600
+ const current = readings.get(r);
601
+ if (current) keepBest(current, value);
602
+ else readings.set(r, value);
603
+ }
604
+ }
605
+ const OKURIGANA = /[\u3041-\u3096]/;
606
+ function indexEntry(index, entry) {
607
+ for (const run of hanRuns(entry.form, parseFurigana(entry.furigana))) {
608
+ if (length(run.text) > 1) {
609
+ indexRun(index, run, entry);
610
+ continue;
611
+ }
612
+ if (entry.tatoeba && !entry.conjugatesReading) {
613
+ const uses = getOrCreate(index.loneUses, run.text, () => /* @__PURE__ */ new Map());
614
+ uses.set(run.spans[0].ruby, (uses.get(run.spans[0].ruby) ?? 0) + entry.tatoeba);
615
+ }
616
+ if (entry.priority) indexSingle(index, run, entry);
617
+ else if (OKURIGANA.test(entry.form[entry.form.indexOf(run.text) + run.text.length] ?? "")) getOrCreate(index.inflecting, run.text, () => /* @__PURE__ */ new Set()).add(run.spans[0].ruby);
618
+ }
619
+ }
620
+ function createContext(dictionary, options, coverage) {
621
+ const covered = (text) => codepoints(text).every((cp) => coverage.has(cp));
622
+ const render = (run, index) => {
623
+ const span = run.spans[index];
624
+ if (options.rubyScript === "hepburn") return toHepburn(span.ruby, run.spans.slice(index + 1).map((s) => s.ruby).join(""));
625
+ const kanji = [...run.text].slice(span.start, span.end);
626
+ if (options.rubyScript === "katakanaOn" && kanji.length === 1 && isOnyomi(span.ruby, dictionary.onyomi[kanji[0]] ?? [])) return toKatakana(span.ruby);
627
+ return span.ruby;
628
+ };
629
+ const known = options.skipJlpt ? Number(options.skipJlpt.slice(1)) : Infinity;
630
+ return {
631
+ options,
632
+ removed: /* @__PURE__ */ new Set([...dictionary.bare ?? [], ...options.words.remove]),
633
+ covered,
634
+ selected: (r) => r.nf >= options.nfMin && r.nf <= options.nfMax || options.selectTags.some((t) => r.tags.has(t)) || options.minTatoeba > 0 && (r.tatoeba ?? 0) >= options.minTatoeba,
635
+ skipped: (r) => r.nf > 0 && r.nf < options.nfMin || r.jlpt >= known,
636
+ render,
637
+ renderable: (run) => covered(run.text) && run.spans.every((_, i) => covered(render(run, i)))
638
+ };
639
+ }
640
+ function addCustomWord(patterns, lonePins, notation, pinned, ctx) {
641
+ const { form, spans } = parseNotation(notation);
642
+ for (const run of hanRuns(form, spans).filter(ctx.renderable)) {
643
+ if (length(run.text) === 1) {
644
+ if (pinned) lonePins.set(run.text, run.spans[0].ruby);
645
+ continue;
646
+ }
647
+ if (pinned === 1 && !patterns.has(run.text)) continue;
648
+ const readings = getOrCreate(patterns, run.text, () => []);
649
+ const existing = readings.find((r) => spanKey(r.spans) === spanKey(run.spans));
650
+ if (existing) existing.pinned = Math.max(existing.pinned ?? 0, pinned);
651
+ else readings.push({
652
+ spans: run.spans,
653
+ weight: 1,
654
+ tagged: true,
655
+ order: Number.MAX_SAFE_INTEGER,
656
+ nf: 1,
657
+ jlpt: 0,
658
+ tags: /* @__PURE__ */ new Set(),
659
+ pinned
660
+ });
661
+ }
662
+ }
663
+ /** Place names fill the runs no common word claims, nor a skipped one. */
664
+ function addPlaces(patterns, places, skipped, ctx) {
665
+ for (const place of places ?? []) {
666
+ const spans = parseFurigana(place.furigana);
667
+ if (patterns.has(place.form) || skipped.has(place.form) || ctx.removed.has(place.form) || !ctx.renderable({
668
+ text: place.form,
669
+ spans
670
+ })) continue;
671
+ patterns.set(place.form, [{
672
+ spans,
673
+ weight: 0,
674
+ tagged: false,
675
+ order: 0,
676
+ nf: 0,
677
+ jlpt: 0,
678
+ tags: /* @__PURE__ */ new Set(),
679
+ place: true
680
+ }]);
681
+ }
682
+ }
683
+ /**
684
+ * Runs of common words, then place names and custom words; pinned readings of
685
+ * lone kanji are returned apart. A word is skipped whole: were only its known
686
+ * reading dropped, 今日 would show こんにち.
687
+ */
688
+ function selectWords(index, dictionary, ctx) {
689
+ const patterns = /* @__PURE__ */ new Map();
690
+ const skipped = /* @__PURE__ */ new Set();
691
+ for (const [text, readings] of index.runs) {
692
+ if (ctx.removed.has(text)) continue;
693
+ if ([...readings.values()].some(ctx.skipped)) {
694
+ skipped.add(text);
695
+ continue;
696
+ }
697
+ const common = [...readings.values()].filter((r) => ctx.selected(r) && ctx.renderable({
698
+ text,
699
+ spans: r.spans
700
+ }));
701
+ if (common.length) patterns.set(text, common.map((r) => ({ ...r })));
702
+ }
703
+ if (ctx.options.places) addPlaces(patterns, dictionary.places, skipped, ctx);
704
+ const lonePins = /* @__PURE__ */ new Map();
705
+ for (const notation of ctx.options.words.add) addCustomWord(patterns, lonePins, notation, 0, ctx);
706
+ for (const notation of dictionary.defaults ?? []) addCustomWord(patterns, lonePins, notation, 1, ctx);
707
+ for (const notation of ctx.options.words.pin) addCustomWord(patterns, lonePins, notation, 2, ctx);
708
+ for (const readings of patterns.values()) readings.sort(rank).splice(15);
709
+ return {
710
+ patterns,
711
+ lonePins,
712
+ skipped
713
+ };
714
+ }
715
+ /** Readings of a lone kanji the font can draw, heaviest first, with their share of the evidence. */
716
+ function loneReadings(kanji, readings, ctx) {
717
+ const drawable = [...readings].filter(([ruby]) => ctx.renderable({
718
+ text: kanji,
719
+ spans: [{
720
+ start: 0,
721
+ end: 1,
722
+ ruby
723
+ }]
724
+ }));
725
+ const total = drawable.reduce((sum, [, r]) => sum + r.weight, 0);
726
+ return drawable.map(([ruby, r]) => ({
727
+ ruby,
728
+ weight: r.weight,
729
+ share: r.weight / total
730
+ })).sort((a, b) => b.weight - a.weight);
731
+ }
732
+ /** The reading running text gives a lone kanji most, with its share of the uses, when the sentences use the kanji alone often enough to tell. */
733
+ function usedReading(kanji, uses, ctx) {
734
+ const drawable = [...uses ?? []].filter(([ruby]) => ctx.renderable({
735
+ text: kanji,
736
+ spans: [{
737
+ start: 0,
738
+ end: 1,
739
+ ruby
740
+ }]
741
+ }));
742
+ const total = drawable.reduce((sum, [, n]) => sum + n, 0);
743
+ if (ctx.options.minTatoeba === 0 || total < LONE_MIN_USES) return;
744
+ const [ruby, n] = drawable.sort((a, b) => b[1] - a[1])[0];
745
+ return {
746
+ ruby,
747
+ share: n / total
748
+ };
749
+ }
750
+ function addWordEvidence(patterns, add) {
751
+ for (const [text, readings] of patterns) {
752
+ const chars = [...text];
753
+ for (const r of readings.filter((r) => !r.place)) for (const s of r.spans.filter((s) => s.end - s.start === 1)) add(chars[s.start], s.ruby, r.weight || 1e-6);
754
+ }
755
+ }
756
+ /** Last in each list: readings a selector helper may need for verbs too rare to rank. */
757
+ function addRareVerbEvidence(inflecting, ctx, add) {
758
+ for (const [kanji, rubies] of inflecting) for (const ruby of [...rubies].filter((ruby) => !ctx.removed.has(kanji) && ctx.renderable({
759
+ text: kanji,
760
+ spans: [{
761
+ start: 0,
762
+ end: 1,
763
+ ruby
764
+ }]
765
+ }))) add(kanji, ruby, 1e-9);
766
+ }
767
+ /**
768
+ * A kanji alone in its run gets ruby only when one reading clearly dominates
769
+ * the common words it stands alone in, whatever the word list's size: verbs
770
+ * like 書く rank low in newspapers yet decide how 書 reads. Every kanji also
771
+ * gets an ordered list of its readings, which the variation selectors index
772
+ * into, except a kanji whose dominant reading the reader is taken to know.
773
+ */
774
+ function chooseLoneKanji(index, patterns, lonePins, ctx) {
775
+ const isolated = new Map(ctx.options.isolated ? lonePins : []);
776
+ const skipped = /* @__PURE__ */ new Set();
777
+ const evidence = /* @__PURE__ */ new Map();
778
+ const add = (kanji, ruby, weight) => {
779
+ const readings = getOrCreate(evidence, kanji, () => /* @__PURE__ */ new Map());
780
+ readings.set(ruby, (readings.get(ruby) ?? 0) + weight);
781
+ };
782
+ for (const [kanji, ruby] of lonePins) add(kanji, ruby, 1);
783
+ for (const [kanji, readings] of index.singles) {
784
+ if (ctx.removed.has(kanji)) continue;
785
+ const ranked = loneReadings(kanji, readings, ctx).filter((r) => r.share >= MIN_ALTERNATE_SHARE);
786
+ const top = ranked[0];
787
+ const chosen = usedReading(kanji, index.loneUses.get(kanji), ctx) ?? top;
788
+ if (top && !lonePins.has(kanji) && ctx.skipped(readings.get(top.ruby))) skipped.add(kanji);
789
+ else if (chosen && chosen.share >= ctx.options.isolatedMinShare && ctx.options.isolated && !isolated.has(kanji)) isolated.set(kanji, chosen.ruby);
790
+ for (const r of ranked) add(kanji, r.ruby, r.weight);
791
+ }
792
+ addWordEvidence(patterns, add);
793
+ addRareVerbEvidence(index.inflecting, ctx, add);
794
+ const kanjiReadings = /* @__PURE__ */ new Map();
795
+ for (const [kanji, readings] of evidence) {
796
+ if (skipped.has(kanji)) continue;
797
+ const ordered = [...readings].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
798
+ kanjiReadings.set(kanji, ordered.slice(0, 15).map(([r]) => r));
799
+ }
800
+ return {
801
+ isolated,
802
+ kanjiReadings
803
+ };
804
+ }
805
+ /**
806
+ * A kanji no word claimed after another kanji, as 的 in 平均的 or 者 in
807
+ * 被災者支援, reads as it reads ending the dictionary's runs, when one reading
808
+ * clearly dominates there: 的 てき, 化 か; not 人, にん or じん.
809
+ */
810
+ function chooseSuffixes(index, ctx) {
811
+ const suffixes = {
812
+ readings: /* @__PURE__ */ new Map(),
813
+ final: /* @__PURE__ */ new Set()
814
+ };
815
+ if (!ctx.options.isolated) return suffixes;
816
+ const evidence = /* @__PURE__ */ new Map();
817
+ const count = (counts, key) => void counts.set(key, (counts.get(key) ?? 0) + 1);
818
+ const starts = /* @__PURE__ */ new Map();
819
+ const ends = /* @__PURE__ */ new Map();
820
+ for (const [text, readings] of index.runs) {
821
+ const chars = [...text];
822
+ count(starts, chars[0]);
823
+ count(ends, chars.at(-1));
824
+ for (const { spans } of readings.values()) {
825
+ const span = spans.at(-1);
826
+ if (span.end - span.start === 1) count(getOrCreate(evidence, chars.at(-1), () => /* @__PURE__ */ new Map()), span.ruby);
827
+ }
828
+ }
829
+ for (const [kanji, counts] of evidence) {
830
+ const total = [...counts.values()].reduce((a, b) => a + b, 0);
831
+ const [ruby, top] = [...counts].sort((a, b) => b[1] - a[1])[0];
832
+ if (total < SUFFIX_MIN_RUNS || top / total < SUFFIX_MIN_SHARE || ctx.removed.has(kanji) || !ctx.renderable({
833
+ text: kanji,
834
+ spans: [{
835
+ start: 0,
836
+ end: 1,
837
+ ruby
838
+ }]
839
+ })) continue;
840
+ suffixes.readings.set(kanji, ruby);
841
+ if ((starts.get(kanji) ?? 0) > ends.get(kanji)) suffixes.final.add(kanji);
842
+ }
843
+ return suffixes;
844
+ }
845
+ /** Per character, the span reading it; keys are relative so they compare at any offset. */
846
+ function assignment(spans, size) {
847
+ const out = Array.from({ length: size });
848
+ for (const s of spans) for (let i = s.start; i < s.end; i++) out[i] = `${s.start - i}:${s.end - i}:${s.ruby}`;
849
+ return out;
850
+ }
851
+ /** The suffix reading of the kanji at `p` of a run when no word claimed it. */
852
+ function suffixAt(chars, p, suffixes) {
853
+ const kanji = chars[p];
854
+ return p > 0 && (p === chars.length - 1 || !suffixes.final.has(kanji)) ? suffixes.readings.get(kanji) : void 0;
855
+ }
856
+ /** Runs the longest-match rules, then the suffix ones, over `chars` and reports whether any kanji gets a reading other than `expected`. */
857
+ function misreads(chars, expected, known, longest, suffixes) {
858
+ for (let p = 0; p < chars.length;) {
859
+ let size = Math.min(longest, chars.length - p);
860
+ for (; size >= 2; size--) {
861
+ const hit = known.get(chars.slice(p, p + size).join(""));
862
+ if (hit !== void 0) {
863
+ if (hit?.some((key, i) => key !== void 0 && key !== expected[p + i])) return true;
864
+ break;
865
+ }
866
+ }
867
+ const suffix = size < 2 ? suffixAt(chars, p, suffixes) : void 0;
868
+ if (suffix !== void 0 && expected[p] !== `0:1:${suffix}`) return true;
869
+ p += Math.max(size, 1);
870
+ }
871
+ return false;
872
+ }
873
+ /**
874
+ * Longer dictionary words that the selected words would misread, such as
875
+ * 大人気 read through 大人: annotated with their own reading when that only
876
+ * takes readings the font already has, left bare otherwise. Skipped words are
877
+ * always left bare.
878
+ */
879
+ function findMisreadRuns(index, patterns, kanjiReadings, suffixes, skipped, ctx) {
880
+ const known = new Map([...patterns].map(([text, readings]) => [text, assignment(readings[0].spans, length(text))]));
881
+ const protectedRuns = /* @__PURE__ */ new Map();
882
+ for (const text of [...ctx.removed].filter((t) => length(t) > 1)) {
883
+ known.set(text, null);
884
+ protectedRuns.set(text, null);
885
+ }
886
+ const protect = (text, readings, longest) => {
887
+ const chars = [...text];
888
+ const candidates = [...new Set([...readings.values()].map((r) => assignment(r.spans, chars.length).join("|")))];
889
+ const expected = [...readings.values()].map((r) => assignment(r.spans, chars.length));
890
+ if (!expected.some((e) => misreads(chars, e, known, longest, suffixes))) return false;
891
+ const truth = [...readings.values()].sort(rank)[0];
892
+ const run = {
893
+ text,
894
+ spans: truth.spans
895
+ };
896
+ const reusable = !skipped.has(text) && candidates.length === 1 && !isGrouped(run) && ctx.renderable(run) && truth.spans.every((s) => kanjiReadings.get(chars[s.start])?.includes(s.ruby));
897
+ protectedRuns.set(text, reusable ? truth.spans : null);
898
+ known.set(text, reusable ? expected[0] : null);
899
+ return true;
900
+ };
901
+ for (let pass = 0, changed = true; changed && pass < 5; pass++) {
902
+ let longest = 2;
903
+ for (const text of known.keys()) longest = Math.max(longest, length(text));
904
+ changed = false;
905
+ for (const [text, readings] of index.runs) if (!known.has(text) && ctx.covered(text) && protect(text, readings, longest)) changed = true;
906
+ }
907
+ return protectedRuns;
908
+ }
909
+ /**
910
+ * A base kanji followed by a variation selector is its own glyph, mapped in
911
+ * `cmap` format 14 like any registered ideographic variation. Browsers keep a
912
+ * supported sequence in the run they shape (they split unsupported ones off),
913
+ * and the rules see the choice as a glyph. The glyph itself is empty: the rules
914
+ * always replace it, by a ruby glyph or, failing that, by the bare kanji.
915
+ */
916
+ function createVariants() {
917
+ const variants = /* @__PURE__ */ new Map();
918
+ return {
919
+ /** `kanji` written with VS225 + `n`: n = 0 drops the ruby, n >= 1 picks reading n. */
920
+ name(kanji, n) {
921
+ const base = kanji.codePointAt(0);
922
+ const name = `${glyphName(base)}.vs${n}`;
923
+ variants.set(name, {
924
+ name,
925
+ base,
926
+ selector: SELECTOR_SUPPRESS + n
927
+ });
928
+ return name;
929
+ },
930
+ all: () => [...variants.values()]
931
+ };
932
+ }
933
+ function buildRules(patterns, kanjiReadings, protectedRuns, glyphs) {
934
+ const ligatures = /* @__PURE__ */ new Map();
935
+ const perComponents = /* @__PURE__ */ new Map();
936
+ const ligatureLookup = (components, glyph, codepoints) => {
937
+ const key = `${components.join(" ")}>${glyph}`;
938
+ let ligature = ligatures.get(key);
939
+ if (!ligature) {
940
+ const n = (perComponents.get(components.join(" ")) ?? 0) + 1;
941
+ perComponents.set(components.join(" "), n);
942
+ ligatures.set(key, ligature = {
943
+ lookup: `lig_${n}`,
944
+ glyph,
945
+ components,
946
+ codepoints
947
+ });
948
+ }
949
+ return ligature.lookup;
950
+ };
951
+ const singles = createSingleLookups();
952
+ const variants = createVariants();
953
+ /** The rule annotating `run`; with `reading`, written with that reading's selector after its first kanji. */
954
+ const runRule = (run, reading) => {
955
+ const cps = codepoints(run.text);
956
+ const input = cps.map(glyphName);
957
+ if (reading) {
958
+ input[0] = variants.name(run.text, reading);
959
+ cps.splice(1, 0, SELECTOR_SUPPRESS + reading);
960
+ }
961
+ const output = glyphs.run(run);
962
+ if (isGrouped(run)) {
963
+ const lookup = ligatureLookup(input, output[0].name, cps);
964
+ return {
965
+ input,
966
+ lookups: input.map((_, i) => i === 0 ? lookup : null),
967
+ codepoints: cps
968
+ };
969
+ }
970
+ return {
971
+ input,
972
+ lookups: output.map((g, i) => singles.lookupFor(input[i], g.name)),
973
+ codepoints: cps
974
+ };
975
+ };
976
+ const rules = [];
977
+ const ivsWords = {};
978
+ for (const [text, readings] of patterns) {
979
+ rules.push(runRule({
980
+ text,
981
+ spans: readings[0].spans
982
+ }));
983
+ if (readings.length < 2) continue;
984
+ readings.forEach((r, i) => rules.push(runRule({
985
+ text,
986
+ spans: r.spans
987
+ }, i + 1)));
988
+ ivsWords[text] = readings.map((r) => formatNotation(text, r.spans));
989
+ }
990
+ for (const [text, spans] of protectedRuns) {
991
+ const cps = codepoints(text);
992
+ rules.push(spans ? runRule({
993
+ text,
994
+ spans
995
+ }) : {
996
+ input: cps.map(glyphName),
997
+ lookups: [],
998
+ codepoints: cps,
999
+ ignore: true
1000
+ });
1001
+ }
1002
+ const variantReadings = [];
1003
+ for (const [kanji, readings] of kanjiReadings) {
1004
+ variants.name(kanji, 0);
1005
+ if (readings.length < 2) continue;
1006
+ readings.forEach((ruby, i) => variantReadings.push({
1007
+ input: variants.name(kanji, i + 1),
1008
+ glyph: glyphs.lone(kanji, ruby),
1009
+ selector: SELECTOR_SUPPRESS + i + 1
1010
+ }));
1011
+ }
1012
+ rules.sort((a, b) => a.input[0].localeCompare(b.input[0]) || b.input.length - a.input.length);
1013
+ const top = /* @__PURE__ */ new Map();
1014
+ for (const { base, selector } of variants.all()) if (selector > 917984) top.set(base, Math.max(top.get(base) ?? 0, selector - SELECTOR_SUPPRESS));
1015
+ const alternates = [...top].map(([base, n]) => ({
1016
+ input: glyphName(base),
1017
+ outputs: Array.from({ length: n }, (_, i) => variants.name(String.fromCodePoint(base), i + 1))
1018
+ }));
1019
+ return {
1020
+ rules,
1021
+ ligatures: [...ligatures.values()],
1022
+ singles,
1023
+ variants: variants.all(),
1024
+ variantReadings,
1025
+ alternates,
1026
+ ivsWords
1027
+ };
1028
+ }
1029
+ function smokeChecks(patterns, lone, glyphs) {
1030
+ const byFrequency = [...patterns].sort((a, b) => (a[1][0].nf || 99) - (b[1][0].nf || 99));
1031
+ const checks = [];
1032
+ for (const grouped of [false, true]) for (const [text, readings] of byFrequency.filter(([text, r]) => isGrouped({
1033
+ text,
1034
+ spans: r[0].spans
1035
+ }) === grouped).slice(0, 3)) checks.push({
1036
+ text,
1037
+ glyphs: glyphs.run({
1038
+ text,
1039
+ spans: readings[0].spans
1040
+ }).map((g) => g.name)
1041
+ });
1042
+ const [word, readings] = byFrequency.find(([, r]) => r.length > 1) ?? [];
1043
+ if (word && readings) {
1044
+ const [first = "", ...rest] = [...word];
1045
+ checks.push({
1046
+ text: first + String.fromCodePoint(917986) + rest.join(""),
1047
+ glyphs: glyphs.run({
1048
+ text: word,
1049
+ spans: readings[1].spans
1050
+ }).map((g) => g.name)
1051
+ });
1052
+ }
1053
+ if (lone[0]) checks.push({
1054
+ text: String.fromCodePoint(lone[0].glyph.bases[0]),
1055
+ glyphs: [lone[0].glyph.name]
1056
+ });
1057
+ return checks;
1058
+ }
1059
+ /**
1060
+ * Indexes the dictionary once into Han runs, then plans fonts from it. Every
1061
+ * decision about readings happens here: which words get ruby, which reading
1062
+ * is the default, which longer words must be protected from a shorter match.
1063
+ */
1064
+ function createFontPlanner(dictionary) {
1065
+ const index = {
1066
+ runs: /* @__PURE__ */ new Map(),
1067
+ singles: /* @__PURE__ */ new Map(),
1068
+ inflecting: /* @__PURE__ */ new Map(),
1069
+ loneUses: /* @__PURE__ */ new Map()
1070
+ };
1071
+ for (const entry of dictionary.entries) indexEntry(index, entry);
1072
+ const sources = Object.entries(dictionary.sources).map(([name, version]) => `${name} ${version}`).join(", ");
1073
+ /** Every decision for one set of options, over the whole dictionary: slow, and the same whatever the preview text. */
1074
+ function analyze(options, coverage) {
1075
+ const ctx = createContext(dictionary, options, coverage);
1076
+ const { patterns, lonePins, skipped } = selectWords(index, dictionary, ctx);
1077
+ const { isolated, kanjiReadings } = chooseLoneKanji(index, patterns, lonePins, ctx);
1078
+ const suffixes = chooseSuffixes(index, ctx);
1079
+ const protectedRuns = findMisreadRuns(index, patterns, kanjiReadings, suffixes, skipped, ctx);
1080
+ const glyphs = createGlyphRegistry(ctx.render);
1081
+ const { rules, ligatures, singles, variants, variantReadings, alternates, ivsWords } = buildRules(patterns, kanjiReadings, protectedRuns, glyphs);
1082
+ const loneGlyphs = (readings) => [...readings].map(([kanji, ruby]) => ({
1083
+ base: glyphName(kanji.codePointAt(0)),
1084
+ glyph: glyphs.lone(kanji, ruby)
1085
+ }));
1086
+ return {
1087
+ patterns,
1088
+ isolated,
1089
+ kanjiReadings,
1090
+ protectedRuns,
1091
+ glyphs,
1092
+ rules,
1093
+ ligatures,
1094
+ singles,
1095
+ variants,
1096
+ variantReadings,
1097
+ alternates,
1098
+ ivsWords,
1099
+ lone: loneGlyphs(isolated),
1100
+ suffixes: loneGlyphs(suffixes.readings),
1101
+ finalSuffixes: new Set([...suffixes.final].map((k) => glyphName(k.codePointAt(0))))
1102
+ };
1103
+ }
1104
+ let last;
1105
+ function analysisFor(options, coverage) {
1106
+ const key = JSON.stringify(options);
1107
+ if (last?.options !== key || last.coverage !== coverage) last = {
1108
+ options: key,
1109
+ coverage,
1110
+ analysis: analyze(options, coverage)
1111
+ };
1112
+ return last.analysis;
1113
+ }
1114
+ /** Plans a font; with `previewText`, keeps only what that text can trigger. */
1115
+ return function planFont(options, coverage, previewText) {
1116
+ const { patterns, isolated, kanjiReadings, protectedRuns, glyphs, rules, ligatures, singles, variants, variantReadings, alternates, ivsWords, lone, suffixes, finalSuffixes } = analysisFor(options, coverage);
1117
+ const sample = previewText === void 0 ? void 0 : new Set(codepoints(previewText));
1118
+ const inSample = (cps) => !sample || cps.every((cp) => sample.has(cp));
1119
+ const keptRules = rules.filter((r) => inSample(r.codepoints));
1120
+ const keptLone = lone.filter((l) => inSample(l.glyph.bases));
1121
+ const keptSuffixes = suffixes.filter((l) => inSample(l.glyph.bases));
1122
+ const keptVariants = variants.filter((v) => inSample([v.base, v.selector]));
1123
+ const keptVariantReadings = variantReadings.filter((v) => inSample([...v.glyph.bases, v.selector]));
1124
+ const keptAlternates = sample ? [] : alternates;
1125
+ const usedLookups = new Set(keptRules.flatMap((r) => r.lookups.map((lookup, i) => `${r.input[i]}@${lookup}`)));
1126
+ const keptSingles = singles.all().filter((s) => usedLookups.has(`${s.input}@${s.lookup}`));
1127
+ const keptLigatures = ligatures.filter((l) => inSample(l.codepoints));
1128
+ const keptNames = /* @__PURE__ */ new Set([
1129
+ ...keptSingles.map((s) => s.output),
1130
+ ...keptLigatures.map((l) => l.glyph),
1131
+ ...keptLone.map((l) => l.glyph.name),
1132
+ ...keptSuffixes.map((l) => l.glyph.name),
1133
+ ...keptVariantReadings.map((v) => v.glyph.name)
1134
+ ]);
1135
+ const keptGlyphs = glyphs.all().filter((g) => keptNames.has(g.name));
1136
+ const han = [...coverage].filter((cp) => inSample([cp]) && isHan(String.fromCodePoint(cp))).sort((a, b) => a - b);
1137
+ const fea = emitFea({
1138
+ hanClass: [
1139
+ ...han.map(glyphName),
1140
+ ...keptVariants.map((v) => v.name),
1141
+ ...keptGlyphs.map((g) => g.name)
1142
+ ],
1143
+ singles: keptSingles,
1144
+ ligatures: keptLigatures,
1145
+ isolated: keptLone.map((l) => ({
1146
+ input: l.base,
1147
+ output: l.glyph.name
1148
+ })),
1149
+ suffixes: keptSuffixes.map((l) => ({
1150
+ input: l.base,
1151
+ output: l.glyph.name,
1152
+ final: finalSuffixes.has(l.base)
1153
+ })),
1154
+ rules: keptRules,
1155
+ variantReadings: keptVariantReadings.map((v) => ({
1156
+ input: v.input,
1157
+ output: v.glyph.name
1158
+ })),
1159
+ alternates: keptAlternates,
1160
+ variantsOff: keptVariants.map((v) => ({
1161
+ input: v.name,
1162
+ output: glyphName(v.base)
1163
+ })),
1164
+ rubyOff: keptGlyphs.map((g) => ({
1165
+ glyph: g.name,
1166
+ bases: g.bases.map(glyphName)
1167
+ }))
1168
+ });
1169
+ const named = /* @__PURE__ */ new Set([
1170
+ ...han,
1171
+ ...keptVariants.map((v) => v.base),
1172
+ ...keptGlyphs.flatMap((g) => g.bases)
1173
+ ]);
1174
+ return {
1175
+ plan: {
1176
+ version: 1,
1177
+ names: {
1178
+ family: options.family,
1179
+ description: `Furigana rules compiled from ${sources} (CC BY-SA 4.0: EDRDG, JmdictFurigana, stephenmk after Jonathan Waller; CC BY 2.0 FR: Tatoeba).`
1180
+ },
1181
+ metrics: options.metrics,
1182
+ codepoints: [...named].sort((a, b) => a - b),
1183
+ variants: keptVariants,
1184
+ glyphs: keptGlyphs,
1185
+ fea,
1186
+ subset: sample && [.../* @__PURE__ */ new Set([...sample, ...keptGlyphs.flatMap((g) => g.ruby.flatMap((r) => codepoints(r.text)))])].filter((cp) => !keptVariants.some((v) => v.selector === cp)).sort((a, b) => a - b),
1187
+ smoke: sample ? [] : smokeChecks(patterns, lone, glyphs)
1188
+ },
1189
+ report: {
1190
+ stats: {
1191
+ patterns: patterns.size,
1192
+ places: [...patterns.values()].filter((r) => r[0].place).length,
1193
+ ambiguous: Object.keys(ivsWords).length,
1194
+ isolated: isolated.size,
1195
+ suffixes: suffixes.length,
1196
+ protectedAnnotated: [...protectedRuns.values()].filter(Boolean).length,
1197
+ protectedBare: [...protectedRuns.values()].filter((s) => !s).length,
1198
+ glyphs: keptGlyphs.length,
1199
+ rules: keptRules.length
1200
+ },
1201
+ ivs: {
1202
+ kanji: Object.fromEntries(kanjiReadings),
1203
+ words: ivsWords,
1204
+ lone: Object.fromEntries(isolated),
1205
+ salt: keptAlternates.length > 0
1206
+ }
1207
+ }
1208
+ };
1209
+ };
1210
+ }
1211
+ //#endregion
1212
+ //#region src/schema.ts
1213
+ const RUBY_SCRIPTS = [
1214
+ "hiragana",
1215
+ "katakanaOn",
1216
+ "hepburn"
1217
+ ];
1218
+ const PRIORITY_TAGS = [
1219
+ "ichi1",
1220
+ "ichi2",
1221
+ "news1",
1222
+ "news2",
1223
+ "spec1",
1224
+ "spec2",
1225
+ "gai1",
1226
+ "gai2"
1227
+ ];
1228
+ /** Easiest first. */
1229
+ const JLPT_LEVELS = [
1230
+ "N5",
1231
+ "N4",
1232
+ "N3",
1233
+ "N2",
1234
+ "N1"
1235
+ ];
1236
+ /** Sizes in em, relative to the base font. */
1237
+ const RubyMetricsSchema = v.object({
1238
+ /** Ruby size relative to the base characters. */
1239
+ rubyScale: v.optional(v.pipe(v.number(), v.minValue(.2), v.maxValue(.8)), DEFAULTS.plan.metrics.rubyScale),
1240
+ /** Space between the top of the em box and the bottom of the ruby. */
1241
+ rubyGap: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(.5)), DEFAULTS.plan.metrics.rubyGap),
1242
+ /** Ruby is squeezed horizontally down to this scale before it may overhang. */
1243
+ compressionFloor: v.optional(v.pipe(v.number(), v.minValue(.3), v.maxValue(1)), DEFAULTS.plan.metrics.compressionFloor),
1244
+ /** Widest a ruby may get, overhanging its base symmetrically. */
1245
+ maxRubyWidth: v.optional(v.pipe(v.number(), v.minValue(1), v.maxValue(3)), DEFAULTS.plan.metrics.maxRubyWidth)
1246
+ });
1247
+ const Bucket = v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(48));
1248
+ /** `schema`, checking that its frequency window `nfMin..nfMax` holds a bucket; for schemas that extend the options too. */
1249
+ function withNfWindow(schema) {
1250
+ return v.pipe(schema, v.check((o) => o.nfMin <= o.nfMax, "nfMin must not exceed nfMax"));
1251
+ }
1252
+ const PlanOptionsSchema = withNfWindow(v.object({
1253
+ family: v.optional(v.pipe(v.string(), v.trim(), v.nonEmpty(), v.check((name) => !/source/i.test(name), "Source is a reserved font name of the base font")), DEFAULTS.plan.family),
1254
+ rubyScript: v.optional(v.picklist(RUBY_SCRIPTS), DEFAULTS.plan.rubyScript),
1255
+ /**
1256
+ * Words whose newspaper frequency bucket is below this stay bare: the most
1257
+ * common ones, which a reader knows. Tags don't bring them back.
1258
+ */
1259
+ nfMin: v.optional(Bucket, DEFAULTS.plan.nfMin),
1260
+ /** Words whose newspaper frequency bucket is at most this are annotated. */
1261
+ nfMax: v.optional(Bucket, DEFAULTS.plan.nfMax),
1262
+ /**
1263
+ * JMdict priority tags that select a word whatever its frequency bucket:
1264
+ * `spec1` catches words like 日本 that the newspaper ranking misses.
1265
+ */
1266
+ selectTags: v.optional(v.array(v.picklist(PRIORITY_TAGS)), () => [...DEFAULTS.plan.selectTags]),
1267
+ /**
1268
+ * Words of two or more kanji that Tatoeba's example sentences use at least
1269
+ * this many times are annotated whatever their bucket: everyday words that
1270
+ * no list tags, such as 留守中, which would otherwise be bare or read as a
1271
+ * place name (国中 くになか, not くにじゅう). The sentences also decide how a
1272
+ * lone kanji reads, where they use it alone often enough. 0 turns them off.
1273
+ */
1274
+ minTatoeba: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0)), DEFAULTS.plan.minTatoeba),
1275
+ /** Words on the JLPT lists of this level and every easier one stay bare. */
1276
+ skipJlpt: v.optional(v.picklist(JLPT_LEVELS)),
1277
+ /** Annotate place names from the dictionary where no common word claims the run. */
1278
+ places: v.optional(v.boolean(), DEFAULTS.plan.places),
1279
+ /** Annotate a kanji alone in its run, or one no word claims after another kanji (a suffix); when off, only selectors give them ruby. */
1280
+ isolated: v.optional(v.boolean(), DEFAULTS.plan.isolated),
1281
+ /** A kanji alone in its run is annotated only when one reading has this share of the evidence. */
1282
+ isolatedMinShare: v.optional(v.pipe(v.number(), v.minValue(.5), v.maxValue(1)), DEFAULTS.plan.isolatedMinShare),
1283
+ words: v.optional(v.object({
1284
+ /** Extra words in inline notation, `日[に]本[ほん]`. */
1285
+ add: v.optional(v.array(v.string()), []),
1286
+ /** Written forms never to annotate. */
1287
+ remove: v.optional(v.array(v.string()), []),
1288
+ /** Readings to make the default, in inline notation. */
1289
+ pin: v.optional(v.array(v.string()), [])
1290
+ }), {}),
1291
+ metrics: v.optional(RubyMetricsSchema, {})
1292
+ }));
1293
+ function resolveOptions(input = {}) {
1294
+ return v.parse(PlanOptionsSchema, input);
1295
+ }
1296
+ const Codepoint = v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(1114111));
1297
+ /**
1298
+ * The contract between the planner and the compiler. The compiler makes no
1299
+ * decision about readings: it draws what `glyphs` describes and compiles `fea`.
1300
+ * In `fea`, base characters are named `uniXXXX` / `uXXXXX` after their codepoint.
1301
+ */
1302
+ const PlanSchema = v.object({
1303
+ version: v.literal(1),
1304
+ names: v.object({
1305
+ family: v.string(),
1306
+ description: v.string()
1307
+ }),
1308
+ metrics: v.object({
1309
+ rubyScale: v.number(),
1310
+ rubyGap: v.number(),
1311
+ compressionFloor: v.number(),
1312
+ maxRubyWidth: v.number()
1313
+ }),
1314
+ /** Base characters the rules name; each must be in the base font's cmap. */
1315
+ codepoints: v.array(Codepoint),
1316
+ /**
1317
+ * Base + variation selector sequences to map in `cmap` format 14, each to an
1318
+ * empty glyph named `name`, with the base's advance: the rules replace it.
1319
+ */
1320
+ variants: v.array(v.object({
1321
+ name: v.pipe(v.string(), v.regex(/^[A-Z_][\w.]{0,62}$/i)),
1322
+ base: Codepoint,
1323
+ selector: Codepoint
1324
+ })),
1325
+ glyphs: v.array(v.object({
1326
+ name: v.pipe(v.string(), v.regex(/^[A-Z_][\w.]{0,62}$/i)),
1327
+ /** The base characters drawn side by side; more than one makes a ligature. */
1328
+ bases: v.pipe(v.array(Codepoint), v.minLength(1)),
1329
+ /** Ruby text drawn above characters `[start, end)` of `bases`. */
1330
+ ruby: v.array(v.object({
1331
+ start: v.number(),
1332
+ end: v.number(),
1333
+ text: v.pipe(v.string(), v.nonEmpty())
1334
+ }))
1335
+ })),
1336
+ fea: v.string(),
1337
+ /** Subset the base font to these codepoints before building (live previews). */
1338
+ subset: v.optional(v.array(Codepoint)),
1339
+ /** Shaping checks the compiler runs on its output: `text` must shape to `glyphs`. */
1340
+ smoke: v.array(v.object({
1341
+ text: v.string(),
1342
+ glyphs: v.array(v.string())
1343
+ }))
1344
+ });
1345
+ //#endregion
1346
+ export { DEFAULTS, JLPT_LEVELS, MAX_READINGS, PREBUILT, PRIORITY_TAGS, PlanOptionsSchema, PlanSchema, RUBY_SCRIPTS, SELECTOR_FIRST_READING, SELECTOR_SUPPRESS, VERSION_PROBE, createFontPlanner, formatNotation, glyphName, hanRuns, isHan, isOnyomi, parseFurigana, parseNotation, resolveOptions, toHepburn, toHiragana, toKatakana, withNfWindow };