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