@furifont/core 0.1.2 → 0.3.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.d.mts +29 -4
- package/dist/index.mjs +191 -31
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -15,7 +15,10 @@ export declare const DEFAULTS: {
|
|
|
15
15
|
readonly nfMax: 48;
|
|
16
16
|
/** JMdict priority tags that select a word whatever its bucket: the first tier of each list, which JMdict marks as common. */
|
|
17
17
|
readonly selectTags: readonly ["ichi1", "news1", "spec1", "gai1"];
|
|
18
|
+
/** Uses in Tatoeba's example sentences that select a word whatever its bucket: about 1,000 more words, and 50 glyphs. */
|
|
19
|
+
readonly minTatoeba: 3;
|
|
18
20
|
readonly places: true;
|
|
21
|
+
readonly untagged: true;
|
|
19
22
|
readonly isolated: true;
|
|
20
23
|
readonly isolatedMinShare: 0.8;
|
|
21
24
|
/** Sizes in em, relative to the base font. */
|
|
@@ -23,7 +26,7 @@ export declare const DEFAULTS: {
|
|
|
23
26
|
readonly rubyScale: 0.5;
|
|
24
27
|
readonly rubyGap: 0.05;
|
|
25
28
|
readonly compressionFloor: 0.8;
|
|
26
|
-
readonly maxRubyWidth: 1.
|
|
29
|
+
readonly maxRubyWidth: 1.5;
|
|
27
30
|
};
|
|
28
31
|
};
|
|
29
32
|
/** What the CLI adds to a plan: where the glyphs are drawn, and what is written. */
|
|
@@ -99,6 +102,8 @@ interface DictionaryEntry {
|
|
|
99
102
|
conjugatesReading?: boolean;
|
|
100
103
|
/** The JLPT level whose word list has this pair: 5 for N5 down to 1 for N1. */
|
|
101
104
|
jlpt?: number;
|
|
105
|
+
/** How many times Tatoeba's example sentences use the pair, spelled this way. */
|
|
106
|
+
tatoeba?: number;
|
|
102
107
|
}
|
|
103
108
|
interface Dictionary {
|
|
104
109
|
entries: DictionaryEntry[];
|
|
@@ -115,6 +120,8 @@ interface Dictionary {
|
|
|
115
120
|
form: string;
|
|
116
121
|
furigana: string;
|
|
117
122
|
}[];
|
|
123
|
+
/** Word lists kept by hand, by source name, in inline notation; like place names, they fill runs no common word claims. */
|
|
124
|
+
manual?: Record<string, string[]>;
|
|
118
125
|
}
|
|
119
126
|
/** A ruby annotation over characters `[start, end)` of a run. */
|
|
120
127
|
interface RubySpan {
|
|
@@ -150,6 +157,8 @@ export declare function formatNotation(text: string, spans: readonly RubySpan[])
|
|
|
150
157
|
export declare const RUBY_SCRIPTS: readonly ["hiragana", "katakanaOn", "hepburn"];
|
|
151
158
|
export declare const PRIORITY_TAGS: readonly ["ichi1", "ichi2", "news1", "news2", "spec1", "spec2", "gai1", "gai2"];
|
|
152
159
|
/** Easiest first. */
|
|
160
|
+
/** The dictionary's manually maintained sources, which a plan may include by name. */
|
|
161
|
+
export declare const MANUAL_SOURCES: readonly ["mahjong-yaku", "jr-stations", "yojijukugo"];
|
|
153
162
|
export declare const JLPT_LEVELS: readonly ["N5", "N4", "N3", "N2", "N1"];
|
|
154
163
|
type RubyScript = typeof RUBY_SCRIPTS[number];
|
|
155
164
|
/** Sizes in em, relative to the base font. */
|
|
@@ -161,7 +170,7 @@ declare const RubyMetricsSchema: v.ObjectSchema<{
|
|
|
161
170
|
/** Ruby is squeezed horizontally down to this scale before it may overhang. */
|
|
162
171
|
readonly compressionFloor: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0.3, undefined>, v.MaxValueAction<number, 1, undefined>]>, 0.8>;
|
|
163
172
|
/** Widest a ruby may get, overhanging its base symmetrically. */
|
|
164
|
-
readonly maxRubyWidth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 3, undefined>]>, 1.
|
|
173
|
+
readonly maxRubyWidth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 3, undefined>]>, 1.5>;
|
|
165
174
|
}, undefined>;
|
|
166
175
|
type RubyMetrics = v.InferOutput<typeof RubyMetricsSchema>;
|
|
167
176
|
/** `schema`, checking that its frequency window `nfMin..nfMax` holds a bucket; for schemas that extend the options too. */
|
|
@@ -184,11 +193,23 @@ export declare const PlanOptionsSchema: v.SchemaWithPipe<readonly [v.ObjectSchem
|
|
|
184
193
|
* `spec1` catches words like 日本 that the newspaper ranking misses.
|
|
185
194
|
*/
|
|
186
195
|
readonly selectTags: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<readonly ["ichi1", "ichi2", "news1", "news2", "spec1", "spec2", "gai1", "gai2"], undefined>, undefined>, () => ("ichi1" | "news1" | "spec1" | "gai1")[]>;
|
|
196
|
+
/**
|
|
197
|
+
* Words of two or more kanji that Tatoeba's example sentences use at least
|
|
198
|
+
* this many times are annotated whatever their bucket: everyday words that
|
|
199
|
+
* no list tags, such as 留守中, which would otherwise be bare or read as a
|
|
200
|
+
* place name (国中 くになか, not くにじゅう). The sentences also decide how a
|
|
201
|
+
* lone kanji reads, where they use it alone often enough. 0 turns them off.
|
|
202
|
+
*/
|
|
203
|
+
readonly minTatoeba: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, 3>;
|
|
187
204
|
/** Words on the JLPT lists of this level and every easier one stay bare. */
|
|
188
205
|
readonly skipJlpt: v.OptionalSchema<v.PicklistSchema<readonly ["N5", "N4", "N3", "N2", "N1"], undefined>, undefined>;
|
|
189
206
|
/** Annotate place names from the dictionary where no common word claims the run. */
|
|
190
207
|
readonly places: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
191
|
-
/** Annotate
|
|
208
|
+
/** Annotate the words of JMdict that no list ranks or tags (such as 四字熟語), where no common word, manual list or place name claims the run; each gets its first reading. */
|
|
209
|
+
readonly untagged: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
210
|
+
/** The dictionary's manually maintained sources (such as `mahjong-yaku`) to annotate; all of them when left out. */
|
|
211
|
+
readonly manual: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<readonly ["mahjong-yaku", "jr-stations", "yojijukugo"], undefined>, undefined>, undefined>;
|
|
212
|
+
/** Annotate a kanji alone in its run, or one no word claims after another kanji (a suffix); when off, only selectors give them ruby. */
|
|
192
213
|
readonly isolated: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
193
214
|
/** A kanji alone in its run is annotated only when one reading has this share of the evidence. */
|
|
194
215
|
readonly isolatedMinShare: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0.5, undefined>, v.MaxValueAction<number, 1, undefined>]>, 0.8>;
|
|
@@ -208,7 +229,7 @@ export declare const PlanOptionsSchema: v.SchemaWithPipe<readonly [v.ObjectSchem
|
|
|
208
229
|
/** Ruby is squeezed horizontally down to this scale before it may overhang. */
|
|
209
230
|
readonly compressionFloor: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0.3, undefined>, v.MaxValueAction<number, 1, undefined>]>, 0.8>;
|
|
210
231
|
/** Widest a ruby may get, overhanging its base symmetrically. */
|
|
211
|
-
readonly maxRubyWidth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 3, undefined>]>, 1.
|
|
232
|
+
readonly maxRubyWidth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 3, undefined>]>, 1.5>;
|
|
212
233
|
}, undefined>, {}>;
|
|
213
234
|
}, undefined>, v.CheckAction<{
|
|
214
235
|
family: string;
|
|
@@ -216,8 +237,11 @@ export declare const PlanOptionsSchema: v.SchemaWithPipe<readonly [v.ObjectSchem
|
|
|
216
237
|
nfMin: number;
|
|
217
238
|
nfMax: number;
|
|
218
239
|
selectTags: ("ichi1" | "news1" | "spec1" | "gai1" | "ichi2" | "news2" | "spec2" | "gai2")[];
|
|
240
|
+
minTatoeba: number;
|
|
219
241
|
skipJlpt?: "N5" | "N4" | "N3" | "N2" | "N1" | undefined;
|
|
220
242
|
places: boolean;
|
|
243
|
+
untagged: boolean;
|
|
244
|
+
manual?: ("mahjong-yaku" | "jr-stations" | "yojijukugo")[] | undefined;
|
|
221
245
|
isolated: boolean;
|
|
222
246
|
isolatedMinShare: number;
|
|
223
247
|
words: {
|
|
@@ -318,6 +342,7 @@ interface PlanReport {
|
|
|
318
342
|
places: number;
|
|
319
343
|
ambiguous: number;
|
|
320
344
|
isolated: number;
|
|
345
|
+
suffixes: number;
|
|
321
346
|
protectedAnnotated: number;
|
|
322
347
|
protectedBare: number;
|
|
323
348
|
glyphs: number;
|
package/dist/index.mjs
CHANGED
|
@@ -20,7 +20,10 @@ const DEFAULTS = {
|
|
|
20
20
|
"spec1",
|
|
21
21
|
"gai1"
|
|
22
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,
|
|
23
25
|
places: true,
|
|
26
|
+
untagged: true,
|
|
24
27
|
isolated: true,
|
|
25
28
|
isolatedMinShare: .8,
|
|
26
29
|
/** Sizes in em, relative to the base font. */
|
|
@@ -28,7 +31,7 @@ const DEFAULTS = {
|
|
|
28
31
|
rubyScale: .5,
|
|
29
32
|
rubyGap: .05,
|
|
30
33
|
compressionFloor: .8,
|
|
31
|
-
maxRubyWidth: 1.
|
|
34
|
+
maxRubyWidth: 1.5
|
|
32
35
|
}
|
|
33
36
|
},
|
|
34
37
|
/** What the CLI adds to a plan: where the glyphs are drawn, and what is written. */
|
|
@@ -439,21 +442,30 @@ const RULES_PER_SUBTABLE = 600;
|
|
|
439
442
|
function block(keyword, name, body) {
|
|
440
443
|
return `${keyword} ${name}${keyword === "lookup" ? " useExtension" : ""} {\n${body.map((l) => ` ${l}`).join("\n")}\n} ${name};\n`;
|
|
441
444
|
}
|
|
445
|
+
/** A lookup of single substitutions, or none when there are none. */
|
|
446
|
+
function singleLookup(name, substitutions) {
|
|
447
|
+
return substitutions.length ? [block("lookup", name, substitutions.map((s) => `sub ${s.input} by ${s.output};`))] : [];
|
|
448
|
+
}
|
|
442
449
|
function chainRule(rule) {
|
|
443
450
|
if (rule.ignore) return `ignore sub ${rule.input.map((g) => `${g}'`).join(" ")};`;
|
|
444
451
|
return `sub ${rule.input.map((g, i) => `${g}'${rule.lookups[i] ? ` lookup ${rule.lookups[i]}` : ""}`).join(" ")};`;
|
|
445
452
|
}
|
|
446
|
-
|
|
453
|
+
const glyphClass = (substitutions) => `[${substitutions.map((s) => s.input).join(" ")}]`;
|
|
454
|
+
/** The chain rules, longest first, then the suffix and lone-kanji class rules. */
|
|
447
455
|
function mainRules(input) {
|
|
448
456
|
const words = input.rules.flatMap((rule, i) => i && i % RULES_PER_SUBTABLE === 0 ? ["subtable;", chainRule(rule)] : [chainRule(rule)]);
|
|
449
|
-
|
|
450
|
-
const
|
|
457
|
+
const classes = [];
|
|
458
|
+
const final = input.suffixes.filter((s) => s.final);
|
|
459
|
+
if (final.length) classes.push(`ignore sub @han ${glyphClass(final)}' @han;`);
|
|
460
|
+
if (input.suffixes.length) classes.push(`sub @han ${glyphClass(input.suffixes)}' lookup suffixes;`);
|
|
461
|
+
if (input.isolated.length) {
|
|
462
|
+
const kanji = glyphClass(input.isolated);
|
|
463
|
+
classes.push(`ignore sub @han ${kanji}';`, `ignore sub ${kanji}' @han;`, `sub ${kanji}' lookup isolated;`);
|
|
464
|
+
}
|
|
451
465
|
return [
|
|
452
466
|
...words,
|
|
453
|
-
...words.length ? ["subtable;"] : [],
|
|
454
|
-
|
|
455
|
-
`ignore sub ${kanji}' @han;`,
|
|
456
|
-
`sub ${kanji}' lookup isolated;`
|
|
467
|
+
...words.length && classes.length ? ["subtable;"] : [],
|
|
468
|
+
...classes
|
|
457
469
|
];
|
|
458
470
|
}
|
|
459
471
|
/**
|
|
@@ -468,7 +480,7 @@ function emitFea(input) {
|
|
|
468
480
|
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
481
|
const byLigatureSlot = Map.groupBy(input.ligatures, (l) => l.lookup);
|
|
470
482
|
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
|
-
|
|
483
|
+
out.push(...singleLookup("isolated", input.isolated), ...singleLookup("suffixes", input.suffixes));
|
|
472
484
|
const salt = {
|
|
473
485
|
lookup: [],
|
|
474
486
|
feature: []
|
|
@@ -485,8 +497,7 @@ function emitFea(input) {
|
|
|
485
497
|
out.push(...salt.lookup);
|
|
486
498
|
const main = mainRules(input);
|
|
487
499
|
if (main.length) out.push(block("lookup", "furigana", main));
|
|
488
|
-
|
|
489
|
-
if (input.variantsOff.length) out.push(block("lookup", "variants_off", input.variantsOff.map((v) => `sub ${v.input} by ${v.output};`)));
|
|
500
|
+
out.push(...singleLookup("variant_readings", input.variantReadings), ...singleLookup("variants_off", input.variantsOff));
|
|
490
501
|
const singles = input.rubyOff.filter((r) => r.bases.length === 1);
|
|
491
502
|
const ligatures = input.rubyOff.filter((r) => r.bases.length > 1);
|
|
492
503
|
const off = [];
|
|
@@ -515,6 +526,11 @@ const SELECTOR_FIRST_READING = 917985;
|
|
|
515
526
|
const MAX_READINGS = 15;
|
|
516
527
|
/** A lone kanji's reading needs this share of the evidence to be offered through a selector. */
|
|
517
528
|
const MIN_ALTERNATE_SHARE = .02;
|
|
529
|
+
/** A suffix reading is the one a kanji takes in this share of the runs it ends, out of at least `SUFFIX_MIN_RUNS`. */
|
|
530
|
+
const SUFFIX_MIN_SHARE = .9;
|
|
531
|
+
const SUFFIX_MIN_RUNS = 10;
|
|
532
|
+
/** With this many uses alone in the example sentences, they decide a lone kanji rather than the word lists. */
|
|
533
|
+
const LONE_MIN_USES = 20;
|
|
518
534
|
/** JMdict orders readings within a form; a run read inside a longer form ranks after the form's own readings. */
|
|
519
535
|
const OTHER_FORM_ORDER = 1e3;
|
|
520
536
|
const length = (text) => [...text].length;
|
|
@@ -554,7 +570,8 @@ function indexRun(index, run, entry) {
|
|
|
554
570
|
order,
|
|
555
571
|
nf: entry.priority ? entry.nf : 0,
|
|
556
572
|
jlpt: entry.jlpt ?? 0,
|
|
557
|
-
tags: priorityTags(entry)
|
|
573
|
+
tags: priorityTags(entry),
|
|
574
|
+
tatoeba: entry.tatoeba ?? 0
|
|
558
575
|
};
|
|
559
576
|
const readings = getOrCreate(index.runs, run.text, () => /* @__PURE__ */ new Map());
|
|
560
577
|
const current = readings.get(spanKey(run.spans));
|
|
@@ -565,6 +582,7 @@ function indexRun(index, run, entry) {
|
|
|
565
582
|
keepBest(current, reading);
|
|
566
583
|
current.tagged ||= reading.tagged;
|
|
567
584
|
current.order = Math.min(current.order, reading.order);
|
|
585
|
+
current.tatoeba = Math.max(current.tatoeba ?? 0, reading.tatoeba);
|
|
568
586
|
}
|
|
569
587
|
function indexSingle(index, run, entry) {
|
|
570
588
|
const ruby = run.spans[0].ruby;
|
|
@@ -587,9 +605,18 @@ function indexSingle(index, run, entry) {
|
|
|
587
605
|
}
|
|
588
606
|
const OKURIGANA = /[\u3041-\u3096]/;
|
|
589
607
|
function indexEntry(index, entry) {
|
|
590
|
-
for (const run of hanRuns(entry.form, parseFurigana(entry.furigana)))
|
|
591
|
-
|
|
592
|
-
|
|
608
|
+
for (const run of hanRuns(entry.form, parseFurigana(entry.furigana))) {
|
|
609
|
+
if (length(run.text) > 1) {
|
|
610
|
+
indexRun(index, run, entry);
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (entry.tatoeba && !entry.conjugatesReading) {
|
|
614
|
+
const uses = getOrCreate(index.loneUses, run.text, () => /* @__PURE__ */ new Map());
|
|
615
|
+
uses.set(run.spans[0].ruby, (uses.get(run.spans[0].ruby) ?? 0) + entry.tatoeba);
|
|
616
|
+
}
|
|
617
|
+
if (entry.priority) indexSingle(index, run, entry);
|
|
618
|
+
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);
|
|
619
|
+
}
|
|
593
620
|
}
|
|
594
621
|
function createContext(dictionary, options, coverage) {
|
|
595
622
|
const covered = (text) => codepoints(text).every((cp) => coverage.has(cp));
|
|
@@ -605,7 +632,7 @@ function createContext(dictionary, options, coverage) {
|
|
|
605
632
|
options,
|
|
606
633
|
removed: /* @__PURE__ */ new Set([...dictionary.bare ?? [], ...options.words.remove]),
|
|
607
634
|
covered,
|
|
608
|
-
selected: (r) => r.nf >= options.nfMin && r.nf <= options.nfMax || options.selectTags.some((t) => r.tags.has(t)),
|
|
635
|
+
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,
|
|
609
636
|
skipped: (r) => r.nf > 0 && r.nf < options.nfMin || r.jlpt >= known,
|
|
610
637
|
render,
|
|
611
638
|
renderable: (run) => covered(run.text) && run.spans.every((_, i) => covered(render(run, i)))
|
|
@@ -654,6 +681,40 @@ function addPlaces(patterns, places, skipped, ctx) {
|
|
|
654
681
|
}]);
|
|
655
682
|
}
|
|
656
683
|
}
|
|
684
|
+
/** Words of the manual sources fill the runs no common word claims, nor a skipped one; they come before place names. */
|
|
685
|
+
function addManual(patterns, manual, skipped, ctx) {
|
|
686
|
+
const names = ctx.options.manual ?? Object.keys(manual);
|
|
687
|
+
for (const notation of names.flatMap((name) => manual[name] ?? [])) {
|
|
688
|
+
const { form, spans } = parseNotation(notation);
|
|
689
|
+
for (const run of hanRuns(form, spans).filter(ctx.renderable)) {
|
|
690
|
+
if (length(run.text) === 1 || patterns.has(run.text) || skipped.has(run.text) || ctx.removed.has(run.text)) continue;
|
|
691
|
+
patterns.set(run.text, [{
|
|
692
|
+
spans: run.spans,
|
|
693
|
+
weight: 0,
|
|
694
|
+
tagged: false,
|
|
695
|
+
order: 0,
|
|
696
|
+
nf: 0,
|
|
697
|
+
jlpt: 0,
|
|
698
|
+
tags: /* @__PURE__ */ new Set(),
|
|
699
|
+
place: true
|
|
700
|
+
}]);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/** Words no list ranks fill the runs nothing else claims, each with its first reading: they are no evidence for the kanji's own. */
|
|
705
|
+
function addUntagged(patterns, index, skipped, ctx) {
|
|
706
|
+
for (const [text, readings] of index.runs) {
|
|
707
|
+
if (patterns.has(text) || skipped.has(text) || ctx.removed.has(text)) continue;
|
|
708
|
+
const first = [...readings.values()].filter((r) => ctx.renderable({
|
|
709
|
+
text,
|
|
710
|
+
spans: r.spans
|
|
711
|
+
})).sort((a, b) => a.order - b.order)[0];
|
|
712
|
+
if (first) patterns.set(text, [{
|
|
713
|
+
...first,
|
|
714
|
+
place: true
|
|
715
|
+
}]);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
657
718
|
/**
|
|
658
719
|
* Runs of common words, then place names and custom words; pinned readings of
|
|
659
720
|
* lone kanji are returned apart. A word is skipped whole: were only its known
|
|
@@ -674,7 +735,9 @@ function selectWords(index, dictionary, ctx) {
|
|
|
674
735
|
}));
|
|
675
736
|
if (common.length) patterns.set(text, common.map((r) => ({ ...r })));
|
|
676
737
|
}
|
|
738
|
+
if (dictionary.manual) addManual(patterns, dictionary.manual, skipped, ctx);
|
|
677
739
|
if (ctx.options.places) addPlaces(patterns, dictionary.places, skipped, ctx);
|
|
740
|
+
if (ctx.options.untagged) addUntagged(patterns, index, skipped, ctx);
|
|
678
741
|
const lonePins = /* @__PURE__ */ new Map();
|
|
679
742
|
for (const notation of ctx.options.words.add) addCustomWord(patterns, lonePins, notation, 0, ctx);
|
|
680
743
|
for (const notation of dictionary.defaults ?? []) addCustomWord(patterns, lonePins, notation, 1, ctx);
|
|
@@ -703,6 +766,24 @@ function loneReadings(kanji, readings, ctx) {
|
|
|
703
766
|
share: r.weight / total
|
|
704
767
|
})).sort((a, b) => b.weight - a.weight);
|
|
705
768
|
}
|
|
769
|
+
/** 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. */
|
|
770
|
+
function usedReading(kanji, uses, ctx) {
|
|
771
|
+
const drawable = [...uses ?? []].filter(([ruby]) => ctx.renderable({
|
|
772
|
+
text: kanji,
|
|
773
|
+
spans: [{
|
|
774
|
+
start: 0,
|
|
775
|
+
end: 1,
|
|
776
|
+
ruby
|
|
777
|
+
}]
|
|
778
|
+
}));
|
|
779
|
+
const total = drawable.reduce((sum, [, n]) => sum + n, 0);
|
|
780
|
+
if (ctx.options.minTatoeba === 0 || total < LONE_MIN_USES) return;
|
|
781
|
+
const [ruby, n] = drawable.sort((a, b) => b[1] - a[1])[0];
|
|
782
|
+
return {
|
|
783
|
+
ruby,
|
|
784
|
+
share: n / total
|
|
785
|
+
};
|
|
786
|
+
}
|
|
706
787
|
function addWordEvidence(patterns, add) {
|
|
707
788
|
for (const [text, readings] of patterns) {
|
|
708
789
|
const chars = [...text];
|
|
@@ -740,8 +821,9 @@ function chooseLoneKanji(index, patterns, lonePins, ctx) {
|
|
|
740
821
|
if (ctx.removed.has(kanji)) continue;
|
|
741
822
|
const ranked = loneReadings(kanji, readings, ctx).filter((r) => r.share >= MIN_ALTERNATE_SHARE);
|
|
742
823
|
const top = ranked[0];
|
|
824
|
+
const chosen = usedReading(kanji, index.loneUses.get(kanji), ctx) ?? top;
|
|
743
825
|
if (top && !lonePins.has(kanji) && ctx.skipped(readings.get(top.ruby))) skipped.add(kanji);
|
|
744
|
-
else if (
|
|
826
|
+
else if (chosen && chosen.share >= ctx.options.isolatedMinShare && ctx.options.isolated && !isolated.has(kanji)) isolated.set(kanji, chosen.ruby);
|
|
745
827
|
for (const r of ranked) add(kanji, r.ruby, r.weight);
|
|
746
828
|
}
|
|
747
829
|
addWordEvidence(patterns, add);
|
|
@@ -757,14 +839,59 @@ function chooseLoneKanji(index, patterns, lonePins, ctx) {
|
|
|
757
839
|
kanjiReadings
|
|
758
840
|
};
|
|
759
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* A kanji no word claimed after another kanji, as 的 in 平均的 or 者 in
|
|
844
|
+
* 被災者支援, reads as it reads ending the dictionary's runs, when one reading
|
|
845
|
+
* clearly dominates there: 的 てき, 化 か; not 人, にん or じん.
|
|
846
|
+
*/
|
|
847
|
+
function chooseSuffixes(index, ctx) {
|
|
848
|
+
const suffixes = {
|
|
849
|
+
readings: /* @__PURE__ */ new Map(),
|
|
850
|
+
final: /* @__PURE__ */ new Set()
|
|
851
|
+
};
|
|
852
|
+
if (!ctx.options.isolated) return suffixes;
|
|
853
|
+
const evidence = /* @__PURE__ */ new Map();
|
|
854
|
+
const count = (counts, key) => void counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
855
|
+
const starts = /* @__PURE__ */ new Map();
|
|
856
|
+
const ends = /* @__PURE__ */ new Map();
|
|
857
|
+
for (const [text, readings] of index.runs) {
|
|
858
|
+
const chars = [...text];
|
|
859
|
+
count(starts, chars[0]);
|
|
860
|
+
count(ends, chars.at(-1));
|
|
861
|
+
for (const { spans } of readings.values()) {
|
|
862
|
+
const span = spans.at(-1);
|
|
863
|
+
if (span.end - span.start === 1) count(getOrCreate(evidence, chars.at(-1), () => /* @__PURE__ */ new Map()), span.ruby);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
for (const [kanji, counts] of evidence) {
|
|
867
|
+
const total = [...counts.values()].reduce((a, b) => a + b, 0);
|
|
868
|
+
const [ruby, top] = [...counts].sort((a, b) => b[1] - a[1])[0];
|
|
869
|
+
if (total < SUFFIX_MIN_RUNS || top / total < SUFFIX_MIN_SHARE || ctx.removed.has(kanji) || !ctx.renderable({
|
|
870
|
+
text: kanji,
|
|
871
|
+
spans: [{
|
|
872
|
+
start: 0,
|
|
873
|
+
end: 1,
|
|
874
|
+
ruby
|
|
875
|
+
}]
|
|
876
|
+
})) continue;
|
|
877
|
+
suffixes.readings.set(kanji, ruby);
|
|
878
|
+
if ((starts.get(kanji) ?? 0) > ends.get(kanji)) suffixes.final.add(kanji);
|
|
879
|
+
}
|
|
880
|
+
return suffixes;
|
|
881
|
+
}
|
|
760
882
|
/** Per character, the span reading it; keys are relative so they compare at any offset. */
|
|
761
883
|
function assignment(spans, size) {
|
|
762
884
|
const out = Array.from({ length: size });
|
|
763
885
|
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
886
|
return out;
|
|
765
887
|
}
|
|
766
|
-
/**
|
|
767
|
-
function
|
|
888
|
+
/** The suffix reading of the kanji at `p` of a run when no word claimed it. */
|
|
889
|
+
function suffixAt(chars, p, suffixes) {
|
|
890
|
+
const kanji = chars[p];
|
|
891
|
+
return p > 0 && (p === chars.length - 1 || !suffixes.final.has(kanji)) ? suffixes.readings.get(kanji) : void 0;
|
|
892
|
+
}
|
|
893
|
+
/** Runs the longest-match rules, then the suffix ones, over `chars` and reports whether any kanji gets a reading other than `expected`. */
|
|
894
|
+
function misreads(chars, expected, known, longest, suffixes) {
|
|
768
895
|
for (let p = 0; p < chars.length;) {
|
|
769
896
|
let size = Math.min(longest, chars.length - p);
|
|
770
897
|
for (; size >= 2; size--) {
|
|
@@ -774,6 +901,8 @@ function misreads(chars, expected, known, longest) {
|
|
|
774
901
|
break;
|
|
775
902
|
}
|
|
776
903
|
}
|
|
904
|
+
const suffix = size < 2 ? suffixAt(chars, p, suffixes) : void 0;
|
|
905
|
+
if (suffix !== void 0 && expected[p] !== `0:1:${suffix}`) return true;
|
|
777
906
|
p += Math.max(size, 1);
|
|
778
907
|
}
|
|
779
908
|
return false;
|
|
@@ -784,7 +913,7 @@ function misreads(chars, expected, known, longest) {
|
|
|
784
913
|
* takes readings the font already has, left bare otherwise. Skipped words are
|
|
785
914
|
* always left bare.
|
|
786
915
|
*/
|
|
787
|
-
function findMisreadRuns(index, patterns, kanjiReadings, skipped, ctx) {
|
|
916
|
+
function findMisreadRuns(index, patterns, kanjiReadings, suffixes, skipped, ctx) {
|
|
788
917
|
const known = new Map([...patterns].map(([text, readings]) => [text, assignment(readings[0].spans, length(text))]));
|
|
789
918
|
const protectedRuns = /* @__PURE__ */ new Map();
|
|
790
919
|
for (const text of [...ctx.removed].filter((t) => length(t) > 1)) {
|
|
@@ -795,7 +924,7 @@ function findMisreadRuns(index, patterns, kanjiReadings, skipped, ctx) {
|
|
|
795
924
|
const chars = [...text];
|
|
796
925
|
const candidates = [...new Set([...readings.values()].map((r) => assignment(r.spans, chars.length).join("|")))];
|
|
797
926
|
const expected = [...readings.values()].map((r) => assignment(r.spans, chars.length));
|
|
798
|
-
if (!expected.some((e) => misreads(chars, e, known, longest))) return false;
|
|
927
|
+
if (!expected.some((e) => misreads(chars, e, known, longest, suffixes))) return false;
|
|
799
928
|
const truth = [...readings.values()].sort(rank)[0];
|
|
800
929
|
const run = {
|
|
801
930
|
text,
|
|
@@ -973,7 +1102,8 @@ function createFontPlanner(dictionary) {
|
|
|
973
1102
|
const index = {
|
|
974
1103
|
runs: /* @__PURE__ */ new Map(),
|
|
975
1104
|
singles: /* @__PURE__ */ new Map(),
|
|
976
|
-
inflecting: /* @__PURE__ */ new Map()
|
|
1105
|
+
inflecting: /* @__PURE__ */ new Map(),
|
|
1106
|
+
loneUses: /* @__PURE__ */ new Map()
|
|
977
1107
|
};
|
|
978
1108
|
for (const entry of dictionary.entries) indexEntry(index, entry);
|
|
979
1109
|
const sources = Object.entries(dictionary.sources).map(([name, version]) => `${name} ${version}`).join(", ");
|
|
@@ -982,9 +1112,14 @@ function createFontPlanner(dictionary) {
|
|
|
982
1112
|
const ctx = createContext(dictionary, options, coverage);
|
|
983
1113
|
const { patterns, lonePins, skipped } = selectWords(index, dictionary, ctx);
|
|
984
1114
|
const { isolated, kanjiReadings } = chooseLoneKanji(index, patterns, lonePins, ctx);
|
|
985
|
-
const
|
|
1115
|
+
const suffixes = chooseSuffixes(index, ctx);
|
|
1116
|
+
const protectedRuns = findMisreadRuns(index, patterns, kanjiReadings, suffixes, skipped, ctx);
|
|
986
1117
|
const glyphs = createGlyphRegistry(ctx.render);
|
|
987
1118
|
const { rules, ligatures, singles, variants, variantReadings, alternates, ivsWords } = buildRules(patterns, kanjiReadings, protectedRuns, glyphs);
|
|
1119
|
+
const loneGlyphs = (readings) => [...readings].map(([kanji, ruby]) => ({
|
|
1120
|
+
base: glyphName(kanji.codePointAt(0)),
|
|
1121
|
+
glyph: glyphs.lone(kanji, ruby)
|
|
1122
|
+
}));
|
|
988
1123
|
return {
|
|
989
1124
|
patterns,
|
|
990
1125
|
isolated,
|
|
@@ -998,10 +1133,9 @@ function createFontPlanner(dictionary) {
|
|
|
998
1133
|
variantReadings,
|
|
999
1134
|
alternates,
|
|
1000
1135
|
ivsWords,
|
|
1001
|
-
lone:
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}))
|
|
1136
|
+
lone: loneGlyphs(isolated),
|
|
1137
|
+
suffixes: loneGlyphs(suffixes.readings),
|
|
1138
|
+
finalSuffixes: new Set([...suffixes.final].map((k) => glyphName(k.codePointAt(0))))
|
|
1005
1139
|
};
|
|
1006
1140
|
}
|
|
1007
1141
|
let last;
|
|
@@ -1016,11 +1150,12 @@ function createFontPlanner(dictionary) {
|
|
|
1016
1150
|
}
|
|
1017
1151
|
/** Plans a font; with `previewText`, keeps only what that text can trigger. */
|
|
1018
1152
|
return function planFont(options, coverage, previewText) {
|
|
1019
|
-
const { patterns, isolated, kanjiReadings, protectedRuns, glyphs, rules, ligatures, singles, variants, variantReadings, alternates, ivsWords, lone } = analysisFor(options, coverage);
|
|
1153
|
+
const { patterns, isolated, kanjiReadings, protectedRuns, glyphs, rules, ligatures, singles, variants, variantReadings, alternates, ivsWords, lone, suffixes, finalSuffixes } = analysisFor(options, coverage);
|
|
1020
1154
|
const sample = previewText === void 0 ? void 0 : new Set(codepoints(previewText));
|
|
1021
1155
|
const inSample = (cps) => !sample || cps.every((cp) => sample.has(cp));
|
|
1022
1156
|
const keptRules = rules.filter((r) => inSample(r.codepoints));
|
|
1023
1157
|
const keptLone = lone.filter((l) => inSample(l.glyph.bases));
|
|
1158
|
+
const keptSuffixes = suffixes.filter((l) => inSample(l.glyph.bases));
|
|
1024
1159
|
const keptVariants = variants.filter((v) => inSample([v.base, v.selector]));
|
|
1025
1160
|
const keptVariantReadings = variantReadings.filter((v) => inSample([...v.glyph.bases, v.selector]));
|
|
1026
1161
|
const keptAlternates = sample ? [] : alternates;
|
|
@@ -1031,6 +1166,7 @@ function createFontPlanner(dictionary) {
|
|
|
1031
1166
|
...keptSingles.map((s) => s.output),
|
|
1032
1167
|
...keptLigatures.map((l) => l.glyph),
|
|
1033
1168
|
...keptLone.map((l) => l.glyph.name),
|
|
1169
|
+
...keptSuffixes.map((l) => l.glyph.name),
|
|
1034
1170
|
...keptVariantReadings.map((v) => v.glyph.name)
|
|
1035
1171
|
]);
|
|
1036
1172
|
const keptGlyphs = glyphs.all().filter((g) => keptNames.has(g.name));
|
|
@@ -1047,6 +1183,11 @@ function createFontPlanner(dictionary) {
|
|
|
1047
1183
|
input: l.base,
|
|
1048
1184
|
output: l.glyph.name
|
|
1049
1185
|
})),
|
|
1186
|
+
suffixes: keptSuffixes.map((l) => ({
|
|
1187
|
+
input: l.base,
|
|
1188
|
+
output: l.glyph.name,
|
|
1189
|
+
final: finalSuffixes.has(l.base)
|
|
1190
|
+
})),
|
|
1050
1191
|
rules: keptRules,
|
|
1051
1192
|
variantReadings: keptVariantReadings.map((v) => ({
|
|
1052
1193
|
input: v.input,
|
|
@@ -1072,7 +1213,7 @@ function createFontPlanner(dictionary) {
|
|
|
1072
1213
|
version: 1,
|
|
1073
1214
|
names: {
|
|
1074
1215
|
family: options.family,
|
|
1075
|
-
description: `Furigana rules compiled from ${sources} (CC BY-SA 4.0: EDRDG, JmdictFurigana, stephenmk after Jonathan Waller).`
|
|
1216
|
+
description: `Furigana rules compiled from ${sources} (CC BY-SA 4.0: EDRDG, JmdictFurigana, stephenmk after Jonathan Waller; CC BY 2.0 FR: Tatoeba).`
|
|
1076
1217
|
},
|
|
1077
1218
|
metrics: options.metrics,
|
|
1078
1219
|
codepoints: [...named].sort((a, b) => a - b),
|
|
@@ -1088,6 +1229,7 @@ function createFontPlanner(dictionary) {
|
|
|
1088
1229
|
places: [...patterns.values()].filter((r) => r[0].place).length,
|
|
1089
1230
|
ambiguous: Object.keys(ivsWords).length,
|
|
1090
1231
|
isolated: isolated.size,
|
|
1232
|
+
suffixes: suffixes.length,
|
|
1091
1233
|
protectedAnnotated: [...protectedRuns.values()].filter(Boolean).length,
|
|
1092
1234
|
protectedBare: [...protectedRuns.values()].filter((s) => !s).length,
|
|
1093
1235
|
glyphs: keptGlyphs.length,
|
|
@@ -1121,6 +1263,12 @@ const PRIORITY_TAGS = [
|
|
|
1121
1263
|
"gai2"
|
|
1122
1264
|
];
|
|
1123
1265
|
/** Easiest first. */
|
|
1266
|
+
/** The dictionary's manually maintained sources, which a plan may include by name. */
|
|
1267
|
+
const MANUAL_SOURCES = [
|
|
1268
|
+
"mahjong-yaku",
|
|
1269
|
+
"jr-stations",
|
|
1270
|
+
"yojijukugo"
|
|
1271
|
+
];
|
|
1124
1272
|
const JLPT_LEVELS = [
|
|
1125
1273
|
"N5",
|
|
1126
1274
|
"N4",
|
|
@@ -1159,11 +1307,23 @@ const PlanOptionsSchema = withNfWindow(v.object({
|
|
|
1159
1307
|
* `spec1` catches words like 日本 that the newspaper ranking misses.
|
|
1160
1308
|
*/
|
|
1161
1309
|
selectTags: v.optional(v.array(v.picklist(PRIORITY_TAGS)), () => [...DEFAULTS.plan.selectTags]),
|
|
1310
|
+
/**
|
|
1311
|
+
* Words of two or more kanji that Tatoeba's example sentences use at least
|
|
1312
|
+
* this many times are annotated whatever their bucket: everyday words that
|
|
1313
|
+
* no list tags, such as 留守中, which would otherwise be bare or read as a
|
|
1314
|
+
* place name (国中 くになか, not くにじゅう). The sentences also decide how a
|
|
1315
|
+
* lone kanji reads, where they use it alone often enough. 0 turns them off.
|
|
1316
|
+
*/
|
|
1317
|
+
minTatoeba: v.optional(v.pipe(v.number(), v.integer(), v.minValue(0)), DEFAULTS.plan.minTatoeba),
|
|
1162
1318
|
/** Words on the JLPT lists of this level and every easier one stay bare. */
|
|
1163
1319
|
skipJlpt: v.optional(v.picklist(JLPT_LEVELS)),
|
|
1164
1320
|
/** Annotate place names from the dictionary where no common word claims the run. */
|
|
1165
1321
|
places: v.optional(v.boolean(), DEFAULTS.plan.places),
|
|
1166
|
-
/** Annotate
|
|
1322
|
+
/** Annotate the words of JMdict that no list ranks or tags (such as 四字熟語), where no common word, manual list or place name claims the run; each gets its first reading. */
|
|
1323
|
+
untagged: v.optional(v.boolean(), DEFAULTS.plan.untagged),
|
|
1324
|
+
/** The dictionary's manually maintained sources (such as `mahjong-yaku`) to annotate; all of them when left out. */
|
|
1325
|
+
manual: v.optional(v.array(v.picklist(MANUAL_SOURCES))),
|
|
1326
|
+
/** Annotate a kanji alone in its run, or one no word claims after another kanji (a suffix); when off, only selectors give them ruby. */
|
|
1167
1327
|
isolated: v.optional(v.boolean(), DEFAULTS.plan.isolated),
|
|
1168
1328
|
/** A kanji alone in its run is annotated only when one reading has this share of the evidence. */
|
|
1169
1329
|
isolatedMinShare: v.optional(v.pipe(v.number(), v.minValue(.5), v.maxValue(1)), DEFAULTS.plan.isolatedMinShare),
|
|
@@ -1230,4 +1390,4 @@ const PlanSchema = v.object({
|
|
|
1230
1390
|
}))
|
|
1231
1391
|
});
|
|
1232
1392
|
//#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 };
|
|
1393
|
+
export { DEFAULTS, JLPT_LEVELS, MANUAL_SOURCES, 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 };
|
package/package.json
CHANGED