@furifont/core 0.1.2 → 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.d.mts +18 -4
- package/dist/index.mjs +143 -30
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -15,6 +15,8 @@ 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;
|
|
19
21
|
readonly isolated: true;
|
|
20
22
|
readonly isolatedMinShare: 0.8;
|
|
@@ -23,7 +25,7 @@ export declare const DEFAULTS: {
|
|
|
23
25
|
readonly rubyScale: 0.5;
|
|
24
26
|
readonly rubyGap: 0.05;
|
|
25
27
|
readonly compressionFloor: 0.8;
|
|
26
|
-
readonly maxRubyWidth: 1.
|
|
28
|
+
readonly maxRubyWidth: 1.5;
|
|
27
29
|
};
|
|
28
30
|
};
|
|
29
31
|
/** What the CLI adds to a plan: where the glyphs are drawn, and what is written. */
|
|
@@ -99,6 +101,8 @@ interface DictionaryEntry {
|
|
|
99
101
|
conjugatesReading?: boolean;
|
|
100
102
|
/** The JLPT level whose word list has this pair: 5 for N5 down to 1 for N1. */
|
|
101
103
|
jlpt?: number;
|
|
104
|
+
/** How many times Tatoeba's example sentences use the pair, spelled this way. */
|
|
105
|
+
tatoeba?: number;
|
|
102
106
|
}
|
|
103
107
|
interface Dictionary {
|
|
104
108
|
entries: DictionaryEntry[];
|
|
@@ -161,7 +165,7 @@ declare const RubyMetricsSchema: v.ObjectSchema<{
|
|
|
161
165
|
/** Ruby is squeezed horizontally down to this scale before it may overhang. */
|
|
162
166
|
readonly compressionFloor: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0.3, undefined>, v.MaxValueAction<number, 1, undefined>]>, 0.8>;
|
|
163
167
|
/** 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.
|
|
168
|
+
readonly maxRubyWidth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 3, undefined>]>, 1.5>;
|
|
165
169
|
}, undefined>;
|
|
166
170
|
type RubyMetrics = v.InferOutput<typeof RubyMetricsSchema>;
|
|
167
171
|
/** `schema`, checking that its frequency window `nfMin..nfMax` holds a bucket; for schemas that extend the options too. */
|
|
@@ -184,11 +188,19 @@ export declare const PlanOptionsSchema: v.SchemaWithPipe<readonly [v.ObjectSchem
|
|
|
184
188
|
* `spec1` catches words like 日本 that the newspaper ranking misses.
|
|
185
189
|
*/
|
|
186
190
|
readonly selectTags: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<readonly ["ichi1", "ichi2", "news1", "news2", "spec1", "spec2", "gai1", "gai2"], undefined>, undefined>, () => ("ichi1" | "news1" | "spec1" | "gai1")[]>;
|
|
191
|
+
/**
|
|
192
|
+
* Words of two or more kanji that Tatoeba's example sentences use at least
|
|
193
|
+
* this many times are annotated whatever their bucket: everyday words that
|
|
194
|
+
* no list tags, such as 留守中, which would otherwise be bare or read as a
|
|
195
|
+
* place name (国中 くになか, not くにじゅう). The sentences also decide how a
|
|
196
|
+
* lone kanji reads, where they use it alone often enough. 0 turns them off.
|
|
197
|
+
*/
|
|
198
|
+
readonly minTatoeba: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, 3>;
|
|
187
199
|
/** Words on the JLPT lists of this level and every easier one stay bare. */
|
|
188
200
|
readonly skipJlpt: v.OptionalSchema<v.PicklistSchema<readonly ["N5", "N4", "N3", "N2", "N1"], undefined>, undefined>;
|
|
189
201
|
/** Annotate place names from the dictionary where no common word claims the run. */
|
|
190
202
|
readonly places: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
191
|
-
/** Annotate a kanji alone in its run; when off, only selectors give
|
|
203
|
+
/** 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
204
|
readonly isolated: v.OptionalSchema<v.BooleanSchema<undefined>, true>;
|
|
193
205
|
/** A kanji alone in its run is annotated only when one reading has this share of the evidence. */
|
|
194
206
|
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 +220,7 @@ export declare const PlanOptionsSchema: v.SchemaWithPipe<readonly [v.ObjectSchem
|
|
|
208
220
|
/** Ruby is squeezed horizontally down to this scale before it may overhang. */
|
|
209
221
|
readonly compressionFloor: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 0.3, undefined>, v.MaxValueAction<number, 1, undefined>]>, 0.8>;
|
|
210
222
|
/** 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.
|
|
223
|
+
readonly maxRubyWidth: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.MinValueAction<number, 1, undefined>, v.MaxValueAction<number, 3, undefined>]>, 1.5>;
|
|
212
224
|
}, undefined>, {}>;
|
|
213
225
|
}, undefined>, v.CheckAction<{
|
|
214
226
|
family: string;
|
|
@@ -216,6 +228,7 @@ export declare const PlanOptionsSchema: v.SchemaWithPipe<readonly [v.ObjectSchem
|
|
|
216
228
|
nfMin: number;
|
|
217
229
|
nfMax: number;
|
|
218
230
|
selectTags: ("ichi1" | "news1" | "spec1" | "gai1" | "ichi2" | "news2" | "spec2" | "gai2")[];
|
|
231
|
+
minTatoeba: number;
|
|
219
232
|
skipJlpt?: "N5" | "N4" | "N3" | "N2" | "N1" | undefined;
|
|
220
233
|
places: boolean;
|
|
221
234
|
isolated: boolean;
|
|
@@ -318,6 +331,7 @@ interface PlanReport {
|
|
|
318
331
|
places: number;
|
|
319
332
|
ambiguous: number;
|
|
320
333
|
isolated: number;
|
|
334
|
+
suffixes: number;
|
|
321
335
|
protectedAnnotated: number;
|
|
322
336
|
protectedBare: number;
|
|
323
337
|
glyphs: number;
|
package/dist/index.mjs
CHANGED
|
@@ -20,6 +20,8 @@ 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,
|
|
24
26
|
isolated: true,
|
|
25
27
|
isolatedMinShare: .8,
|
|
@@ -28,7 +30,7 @@ const DEFAULTS = {
|
|
|
28
30
|
rubyScale: .5,
|
|
29
31
|
rubyGap: .05,
|
|
30
32
|
compressionFloor: .8,
|
|
31
|
-
maxRubyWidth: 1.
|
|
33
|
+
maxRubyWidth: 1.5
|
|
32
34
|
}
|
|
33
35
|
},
|
|
34
36
|
/** What the CLI adds to a plan: where the glyphs are drawn, and what is written. */
|
|
@@ -439,21 +441,30 @@ const RULES_PER_SUBTABLE = 600;
|
|
|
439
441
|
function block(keyword, name, body) {
|
|
440
442
|
return `${keyword} ${name}${keyword === "lookup" ? " useExtension" : ""} {\n${body.map((l) => ` ${l}`).join("\n")}\n} ${name};\n`;
|
|
441
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
|
+
}
|
|
442
448
|
function chainRule(rule) {
|
|
443
449
|
if (rule.ignore) return `ignore sub ${rule.input.map((g) => `${g}'`).join(" ")};`;
|
|
444
450
|
return `sub ${rule.input.map((g, i) => `${g}'${rule.lookups[i] ? ` lookup ${rule.lookups[i]}` : ""}`).join(" ")};`;
|
|
445
451
|
}
|
|
446
|
-
|
|
452
|
+
const glyphClass = (substitutions) => `[${substitutions.map((s) => s.input).join(" ")}]`;
|
|
453
|
+
/** The chain rules, longest first, then the suffix and lone-kanji class rules. */
|
|
447
454
|
function mainRules(input) {
|
|
448
455
|
const words = input.rules.flatMap((rule, i) => i && i % RULES_PER_SUBTABLE === 0 ? ["subtable;", chainRule(rule)] : [chainRule(rule)]);
|
|
449
|
-
|
|
450
|
-
const
|
|
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
|
+
}
|
|
451
464
|
return [
|
|
452
465
|
...words,
|
|
453
|
-
...words.length ? ["subtable;"] : [],
|
|
454
|
-
|
|
455
|
-
`ignore sub ${kanji}' @han;`,
|
|
456
|
-
`sub ${kanji}' lookup isolated;`
|
|
466
|
+
...words.length && classes.length ? ["subtable;"] : [],
|
|
467
|
+
...classes
|
|
457
468
|
];
|
|
458
469
|
}
|
|
459
470
|
/**
|
|
@@ -468,7 +479,7 @@ function emitFea(input) {
|
|
|
468
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};`)));
|
|
469
480
|
const byLigatureSlot = Map.groupBy(input.ligatures, (l) => l.lookup);
|
|
470
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};`)));
|
|
471
|
-
|
|
482
|
+
out.push(...singleLookup("isolated", input.isolated), ...singleLookup("suffixes", input.suffixes));
|
|
472
483
|
const salt = {
|
|
473
484
|
lookup: [],
|
|
474
485
|
feature: []
|
|
@@ -485,8 +496,7 @@ function emitFea(input) {
|
|
|
485
496
|
out.push(...salt.lookup);
|
|
486
497
|
const main = mainRules(input);
|
|
487
498
|
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};`)));
|
|
499
|
+
out.push(...singleLookup("variant_readings", input.variantReadings), ...singleLookup("variants_off", input.variantsOff));
|
|
490
500
|
const singles = input.rubyOff.filter((r) => r.bases.length === 1);
|
|
491
501
|
const ligatures = input.rubyOff.filter((r) => r.bases.length > 1);
|
|
492
502
|
const off = [];
|
|
@@ -515,6 +525,11 @@ const SELECTOR_FIRST_READING = 917985;
|
|
|
515
525
|
const MAX_READINGS = 15;
|
|
516
526
|
/** A lone kanji's reading needs this share of the evidence to be offered through a selector. */
|
|
517
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;
|
|
518
533
|
/** JMdict orders readings within a form; a run read inside a longer form ranks after the form's own readings. */
|
|
519
534
|
const OTHER_FORM_ORDER = 1e3;
|
|
520
535
|
const length = (text) => [...text].length;
|
|
@@ -554,7 +569,8 @@ function indexRun(index, run, entry) {
|
|
|
554
569
|
order,
|
|
555
570
|
nf: entry.priority ? entry.nf : 0,
|
|
556
571
|
jlpt: entry.jlpt ?? 0,
|
|
557
|
-
tags: priorityTags(entry)
|
|
572
|
+
tags: priorityTags(entry),
|
|
573
|
+
tatoeba: entry.tatoeba ?? 0
|
|
558
574
|
};
|
|
559
575
|
const readings = getOrCreate(index.runs, run.text, () => /* @__PURE__ */ new Map());
|
|
560
576
|
const current = readings.get(spanKey(run.spans));
|
|
@@ -565,6 +581,7 @@ function indexRun(index, run, entry) {
|
|
|
565
581
|
keepBest(current, reading);
|
|
566
582
|
current.tagged ||= reading.tagged;
|
|
567
583
|
current.order = Math.min(current.order, reading.order);
|
|
584
|
+
current.tatoeba = Math.max(current.tatoeba ?? 0, reading.tatoeba);
|
|
568
585
|
}
|
|
569
586
|
function indexSingle(index, run, entry) {
|
|
570
587
|
const ruby = run.spans[0].ruby;
|
|
@@ -587,9 +604,18 @@ function indexSingle(index, run, entry) {
|
|
|
587
604
|
}
|
|
588
605
|
const OKURIGANA = /[\u3041-\u3096]/;
|
|
589
606
|
function indexEntry(index, entry) {
|
|
590
|
-
for (const run of hanRuns(entry.form, parseFurigana(entry.furigana)))
|
|
591
|
-
|
|
592
|
-
|
|
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
|
+
}
|
|
593
619
|
}
|
|
594
620
|
function createContext(dictionary, options, coverage) {
|
|
595
621
|
const covered = (text) => codepoints(text).every((cp) => coverage.has(cp));
|
|
@@ -605,7 +631,7 @@ function createContext(dictionary, options, coverage) {
|
|
|
605
631
|
options,
|
|
606
632
|
removed: /* @__PURE__ */ new Set([...dictionary.bare ?? [], ...options.words.remove]),
|
|
607
633
|
covered,
|
|
608
|
-
selected: (r) => r.nf >= options.nfMin && r.nf <= options.nfMax || options.selectTags.some((t) => r.tags.has(t)),
|
|
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,
|
|
609
635
|
skipped: (r) => r.nf > 0 && r.nf < options.nfMin || r.jlpt >= known,
|
|
610
636
|
render,
|
|
611
637
|
renderable: (run) => covered(run.text) && run.spans.every((_, i) => covered(render(run, i)))
|
|
@@ -703,6 +729,24 @@ function loneReadings(kanji, readings, ctx) {
|
|
|
703
729
|
share: r.weight / total
|
|
704
730
|
})).sort((a, b) => b.weight - a.weight);
|
|
705
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
|
+
}
|
|
706
750
|
function addWordEvidence(patterns, add) {
|
|
707
751
|
for (const [text, readings] of patterns) {
|
|
708
752
|
const chars = [...text];
|
|
@@ -740,8 +784,9 @@ function chooseLoneKanji(index, patterns, lonePins, ctx) {
|
|
|
740
784
|
if (ctx.removed.has(kanji)) continue;
|
|
741
785
|
const ranked = loneReadings(kanji, readings, ctx).filter((r) => r.share >= MIN_ALTERNATE_SHARE);
|
|
742
786
|
const top = ranked[0];
|
|
787
|
+
const chosen = usedReading(kanji, index.loneUses.get(kanji), ctx) ?? top;
|
|
743
788
|
if (top && !lonePins.has(kanji) && ctx.skipped(readings.get(top.ruby))) skipped.add(kanji);
|
|
744
|
-
else if (
|
|
789
|
+
else if (chosen && chosen.share >= ctx.options.isolatedMinShare && ctx.options.isolated && !isolated.has(kanji)) isolated.set(kanji, chosen.ruby);
|
|
745
790
|
for (const r of ranked) add(kanji, r.ruby, r.weight);
|
|
746
791
|
}
|
|
747
792
|
addWordEvidence(patterns, add);
|
|
@@ -757,14 +802,59 @@ function chooseLoneKanji(index, patterns, lonePins, ctx) {
|
|
|
757
802
|
kanjiReadings
|
|
758
803
|
};
|
|
759
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
|
+
}
|
|
760
845
|
/** Per character, the span reading it; keys are relative so they compare at any offset. */
|
|
761
846
|
function assignment(spans, size) {
|
|
762
847
|
const out = Array.from({ length: size });
|
|
763
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}`;
|
|
764
849
|
return out;
|
|
765
850
|
}
|
|
766
|
-
/**
|
|
767
|
-
function
|
|
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) {
|
|
768
858
|
for (let p = 0; p < chars.length;) {
|
|
769
859
|
let size = Math.min(longest, chars.length - p);
|
|
770
860
|
for (; size >= 2; size--) {
|
|
@@ -774,6 +864,8 @@ function misreads(chars, expected, known, longest) {
|
|
|
774
864
|
break;
|
|
775
865
|
}
|
|
776
866
|
}
|
|
867
|
+
const suffix = size < 2 ? suffixAt(chars, p, suffixes) : void 0;
|
|
868
|
+
if (suffix !== void 0 && expected[p] !== `0:1:${suffix}`) return true;
|
|
777
869
|
p += Math.max(size, 1);
|
|
778
870
|
}
|
|
779
871
|
return false;
|
|
@@ -784,7 +876,7 @@ function misreads(chars, expected, known, longest) {
|
|
|
784
876
|
* takes readings the font already has, left bare otherwise. Skipped words are
|
|
785
877
|
* always left bare.
|
|
786
878
|
*/
|
|
787
|
-
function findMisreadRuns(index, patterns, kanjiReadings, skipped, ctx) {
|
|
879
|
+
function findMisreadRuns(index, patterns, kanjiReadings, suffixes, skipped, ctx) {
|
|
788
880
|
const known = new Map([...patterns].map(([text, readings]) => [text, assignment(readings[0].spans, length(text))]));
|
|
789
881
|
const protectedRuns = /* @__PURE__ */ new Map();
|
|
790
882
|
for (const text of [...ctx.removed].filter((t) => length(t) > 1)) {
|
|
@@ -795,7 +887,7 @@ function findMisreadRuns(index, patterns, kanjiReadings, skipped, ctx) {
|
|
|
795
887
|
const chars = [...text];
|
|
796
888
|
const candidates = [...new Set([...readings.values()].map((r) => assignment(r.spans, chars.length).join("|")))];
|
|
797
889
|
const expected = [...readings.values()].map((r) => assignment(r.spans, chars.length));
|
|
798
|
-
if (!expected.some((e) => misreads(chars, e, known, longest))) return false;
|
|
890
|
+
if (!expected.some((e) => misreads(chars, e, known, longest, suffixes))) return false;
|
|
799
891
|
const truth = [...readings.values()].sort(rank)[0];
|
|
800
892
|
const run = {
|
|
801
893
|
text,
|
|
@@ -973,7 +1065,8 @@ function createFontPlanner(dictionary) {
|
|
|
973
1065
|
const index = {
|
|
974
1066
|
runs: /* @__PURE__ */ new Map(),
|
|
975
1067
|
singles: /* @__PURE__ */ new Map(),
|
|
976
|
-
inflecting: /* @__PURE__ */ new Map()
|
|
1068
|
+
inflecting: /* @__PURE__ */ new Map(),
|
|
1069
|
+
loneUses: /* @__PURE__ */ new Map()
|
|
977
1070
|
};
|
|
978
1071
|
for (const entry of dictionary.entries) indexEntry(index, entry);
|
|
979
1072
|
const sources = Object.entries(dictionary.sources).map(([name, version]) => `${name} ${version}`).join(", ");
|
|
@@ -982,9 +1075,14 @@ function createFontPlanner(dictionary) {
|
|
|
982
1075
|
const ctx = createContext(dictionary, options, coverage);
|
|
983
1076
|
const { patterns, lonePins, skipped } = selectWords(index, dictionary, ctx);
|
|
984
1077
|
const { isolated, kanjiReadings } = chooseLoneKanji(index, patterns, lonePins, ctx);
|
|
985
|
-
const
|
|
1078
|
+
const suffixes = chooseSuffixes(index, ctx);
|
|
1079
|
+
const protectedRuns = findMisreadRuns(index, patterns, kanjiReadings, suffixes, skipped, ctx);
|
|
986
1080
|
const glyphs = createGlyphRegistry(ctx.render);
|
|
987
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
|
+
}));
|
|
988
1086
|
return {
|
|
989
1087
|
patterns,
|
|
990
1088
|
isolated,
|
|
@@ -998,10 +1096,9 @@ function createFontPlanner(dictionary) {
|
|
|
998
1096
|
variantReadings,
|
|
999
1097
|
alternates,
|
|
1000
1098
|
ivsWords,
|
|
1001
|
-
lone:
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}))
|
|
1099
|
+
lone: loneGlyphs(isolated),
|
|
1100
|
+
suffixes: loneGlyphs(suffixes.readings),
|
|
1101
|
+
finalSuffixes: new Set([...suffixes.final].map((k) => glyphName(k.codePointAt(0))))
|
|
1005
1102
|
};
|
|
1006
1103
|
}
|
|
1007
1104
|
let last;
|
|
@@ -1016,11 +1113,12 @@ function createFontPlanner(dictionary) {
|
|
|
1016
1113
|
}
|
|
1017
1114
|
/** Plans a font; with `previewText`, keeps only what that text can trigger. */
|
|
1018
1115
|
return function planFont(options, coverage, previewText) {
|
|
1019
|
-
const { patterns, isolated, kanjiReadings, protectedRuns, glyphs, rules, ligatures, singles, variants, variantReadings, alternates, ivsWords, lone } = analysisFor(options, coverage);
|
|
1116
|
+
const { patterns, isolated, kanjiReadings, protectedRuns, glyphs, rules, ligatures, singles, variants, variantReadings, alternates, ivsWords, lone, suffixes, finalSuffixes } = analysisFor(options, coverage);
|
|
1020
1117
|
const sample = previewText === void 0 ? void 0 : new Set(codepoints(previewText));
|
|
1021
1118
|
const inSample = (cps) => !sample || cps.every((cp) => sample.has(cp));
|
|
1022
1119
|
const keptRules = rules.filter((r) => inSample(r.codepoints));
|
|
1023
1120
|
const keptLone = lone.filter((l) => inSample(l.glyph.bases));
|
|
1121
|
+
const keptSuffixes = suffixes.filter((l) => inSample(l.glyph.bases));
|
|
1024
1122
|
const keptVariants = variants.filter((v) => inSample([v.base, v.selector]));
|
|
1025
1123
|
const keptVariantReadings = variantReadings.filter((v) => inSample([...v.glyph.bases, v.selector]));
|
|
1026
1124
|
const keptAlternates = sample ? [] : alternates;
|
|
@@ -1031,6 +1129,7 @@ function createFontPlanner(dictionary) {
|
|
|
1031
1129
|
...keptSingles.map((s) => s.output),
|
|
1032
1130
|
...keptLigatures.map((l) => l.glyph),
|
|
1033
1131
|
...keptLone.map((l) => l.glyph.name),
|
|
1132
|
+
...keptSuffixes.map((l) => l.glyph.name),
|
|
1034
1133
|
...keptVariantReadings.map((v) => v.glyph.name)
|
|
1035
1134
|
]);
|
|
1036
1135
|
const keptGlyphs = glyphs.all().filter((g) => keptNames.has(g.name));
|
|
@@ -1047,6 +1146,11 @@ function createFontPlanner(dictionary) {
|
|
|
1047
1146
|
input: l.base,
|
|
1048
1147
|
output: l.glyph.name
|
|
1049
1148
|
})),
|
|
1149
|
+
suffixes: keptSuffixes.map((l) => ({
|
|
1150
|
+
input: l.base,
|
|
1151
|
+
output: l.glyph.name,
|
|
1152
|
+
final: finalSuffixes.has(l.base)
|
|
1153
|
+
})),
|
|
1050
1154
|
rules: keptRules,
|
|
1051
1155
|
variantReadings: keptVariantReadings.map((v) => ({
|
|
1052
1156
|
input: v.input,
|
|
@@ -1072,7 +1176,7 @@ function createFontPlanner(dictionary) {
|
|
|
1072
1176
|
version: 1,
|
|
1073
1177
|
names: {
|
|
1074
1178
|
family: options.family,
|
|
1075
|
-
description: `Furigana rules compiled from ${sources} (CC BY-SA 4.0: EDRDG, JmdictFurigana, stephenmk after Jonathan Waller).`
|
|
1179
|
+
description: `Furigana rules compiled from ${sources} (CC BY-SA 4.0: EDRDG, JmdictFurigana, stephenmk after Jonathan Waller; CC BY 2.0 FR: Tatoeba).`
|
|
1076
1180
|
},
|
|
1077
1181
|
metrics: options.metrics,
|
|
1078
1182
|
codepoints: [...named].sort((a, b) => a - b),
|
|
@@ -1088,6 +1192,7 @@ function createFontPlanner(dictionary) {
|
|
|
1088
1192
|
places: [...patterns.values()].filter((r) => r[0].place).length,
|
|
1089
1193
|
ambiguous: Object.keys(ivsWords).length,
|
|
1090
1194
|
isolated: isolated.size,
|
|
1195
|
+
suffixes: suffixes.length,
|
|
1091
1196
|
protectedAnnotated: [...protectedRuns.values()].filter(Boolean).length,
|
|
1092
1197
|
protectedBare: [...protectedRuns.values()].filter((s) => !s).length,
|
|
1093
1198
|
glyphs: keptGlyphs.length,
|
|
@@ -1159,11 +1264,19 @@ const PlanOptionsSchema = withNfWindow(v.object({
|
|
|
1159
1264
|
* `spec1` catches words like 日本 that the newspaper ranking misses.
|
|
1160
1265
|
*/
|
|
1161
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),
|
|
1162
1275
|
/** Words on the JLPT lists of this level and every easier one stay bare. */
|
|
1163
1276
|
skipJlpt: v.optional(v.picklist(JLPT_LEVELS)),
|
|
1164
1277
|
/** Annotate place names from the dictionary where no common word claims the run. */
|
|
1165
1278
|
places: v.optional(v.boolean(), DEFAULTS.plan.places),
|
|
1166
|
-
/** Annotate a kanji alone in its run; when off, only selectors give
|
|
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. */
|
|
1167
1280
|
isolated: v.optional(v.boolean(), DEFAULTS.plan.isolated),
|
|
1168
1281
|
/** A kanji alone in its run is annotated only when one reading has this share of the evidence. */
|
|
1169
1282
|
isolatedMinShare: v.optional(v.pipe(v.number(), v.minValue(.5), v.maxValue(1)), DEFAULTS.plan.isolatedMinShare),
|
package/package.json
CHANGED