@furifont/core 0.4.1 → 0.5.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 +45 -11
- package/dist/index.mjs +266 -58
- package/package.json +1 -1
package/dist/index.d.mts
CHANGED
|
@@ -9,7 +9,7 @@ export declare const DEFAULTS: {
|
|
|
9
9
|
/** What a plan does when a config leaves an option out. */
|
|
10
10
|
readonly plan: {
|
|
11
11
|
readonly family: "Furifont Sans";
|
|
12
|
-
readonly
|
|
12
|
+
readonly furiganaScript: "hiragana";
|
|
13
13
|
/** No word is left bare for being common; every word of every source gets ruby. */
|
|
14
14
|
readonly skip: 0;
|
|
15
15
|
readonly isolated: true;
|
|
@@ -35,22 +35,22 @@ export declare const PREBUILT: {
|
|
|
35
35
|
readonly family: "Furifont Sans";
|
|
36
36
|
readonly base: "Source Han Sans JP";
|
|
37
37
|
readonly face: "sans";
|
|
38
|
-
readonly
|
|
38
|
+
readonly furiganaScript: "hiragana";
|
|
39
39
|
}, {
|
|
40
40
|
readonly family: "Furifont Sans Romaji";
|
|
41
41
|
readonly base: "Source Han Sans JP";
|
|
42
42
|
readonly face: "sans";
|
|
43
|
-
readonly
|
|
43
|
+
readonly furiganaScript: "hepburn";
|
|
44
44
|
}, {
|
|
45
45
|
readonly family: "Furifont Serif";
|
|
46
46
|
readonly base: "Source Han Serif JP";
|
|
47
47
|
readonly face: "serif";
|
|
48
|
-
readonly
|
|
48
|
+
readonly furiganaScript: "hiragana";
|
|
49
49
|
}, {
|
|
50
50
|
readonly family: "Furifont Serif Romaji";
|
|
51
51
|
readonly base: "Source Han Serif JP";
|
|
52
52
|
readonly face: "serif";
|
|
53
|
-
readonly
|
|
53
|
+
readonly furiganaScript: "hepburn";
|
|
54
54
|
}];
|
|
55
55
|
/** The static weights of the release, each of the base's weights by name. Bold is the variable font's. */
|
|
56
56
|
readonly weights: readonly [{
|
|
@@ -82,7 +82,7 @@ export declare const VERSION_PROBE: {
|
|
|
82
82
|
};
|
|
83
83
|
//#endregion
|
|
84
84
|
//#region src/schema.d.ts
|
|
85
|
-
export declare const
|
|
85
|
+
export declare const FURIGANA_SCRIPTS: readonly ["hiragana", "katakanaOn", "hepburn"];
|
|
86
86
|
/** The dictionary's manually maintained sources. */
|
|
87
87
|
export declare const MANUAL_SOURCES: readonly ["mahjong-yaku", "jr-stations", "yojijukugo", "japan-post"];
|
|
88
88
|
type ManualSource = typeof MANUAL_SOURCES[number];
|
|
@@ -106,7 +106,7 @@ type Source = typeof SOURCES[number];
|
|
|
106
106
|
export declare const sourceBit: (source: Source) => number;
|
|
107
107
|
/** The sources a mask of `SOURCES` bits names. */
|
|
108
108
|
export declare const sourcesIn: (mask: number) => Source[];
|
|
109
|
-
type
|
|
109
|
+
type FuriganaScript = typeof FURIGANA_SCRIPTS[number];
|
|
110
110
|
/** Sizes in em, relative to the base font. */
|
|
111
111
|
declare const RubyMetricsSchema: v.ObjectSchema<{
|
|
112
112
|
/** Ruby size relative to the base characters. */
|
|
@@ -121,9 +121,9 @@ declare const RubyMetricsSchema: v.ObjectSchema<{
|
|
|
121
121
|
type RubyMetrics = v.InferOutput<typeof RubyMetricsSchema>;
|
|
122
122
|
export declare const PlanOptionsSchema: v.ObjectSchema<{
|
|
123
123
|
readonly family: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.TrimAction, v.NonEmptyAction<string, undefined>, v.CheckAction<string, "Source is a reserved font name of the base font">]>, "Furifont Sans">;
|
|
124
|
-
readonly
|
|
124
|
+
readonly furiganaScript: v.OptionalSchema<v.PicklistSchema<readonly ["hiragana", "katakanaOn", "hepburn"], undefined>, "hiragana">;
|
|
125
125
|
/** The sources whose words get ruby. */
|
|
126
|
-
readonly sources: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<readonly ["jmdict", "ichi", "news", "spec", "gai", "nf", "tatoeba", "place", "mahjong-yaku", "jr-stations", "yojijukugo", "japan-post"], undefined>, undefined>, () => ("
|
|
126
|
+
readonly sources: v.OptionalSchema<v.ArraySchema<v.PicklistSchema<readonly ["jmdict", "ichi", "news", "spec", "gai", "nf", "tatoeba", "place", "mahjong-yaku", "jr-stations", "yojijukugo", "japan-post"], undefined>, undefined>, () => ("jmdict" | "ichi" | "news" | "spec" | "gai" | "nf" | "tatoeba" | "place" | "mahjong-yaku" | "jr-stations" | "yojijukugo" | "japan-post")[]>;
|
|
127
127
|
/** The most common words left bare, by score: a reader knows them. Lone kanji count too. */
|
|
128
128
|
readonly skip: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, 0>;
|
|
129
129
|
/** How many words get ruby after those skipped, by score; all of them when left out. */
|
|
@@ -357,6 +357,8 @@ export declare const SELECTOR_FIRST_READING = 917985;
|
|
|
357
357
|
export declare const MAX_READINGS = 15;
|
|
358
358
|
interface PlanReport {
|
|
359
359
|
stats: {
|
|
360
|
+
/** Words the sources offer, the skipped and the left bare among them: the range of `skip` and `take`. */
|
|
361
|
+
candidates: number;
|
|
360
362
|
patterns: number;
|
|
361
363
|
places: number;
|
|
362
364
|
ambiguous: number;
|
|
@@ -385,11 +387,43 @@ interface PlanResult {
|
|
|
385
387
|
plan: Plan;
|
|
386
388
|
report: PlanReport;
|
|
387
389
|
}
|
|
390
|
+
/** The words the sources hold, most common first, in three runs: the skipped (the most common, and those the reader knows), the taken, and the rest the font leaves bare. */
|
|
391
|
+
interface WordBuckets {
|
|
392
|
+
skip: string[];
|
|
393
|
+
take: string[];
|
|
394
|
+
rest: string[];
|
|
395
|
+
}
|
|
396
|
+
interface FontPlanner {
|
|
397
|
+
(options: PlanOptions, coverage: ReadonlySet<number>, previewText?: string): PlanResult;
|
|
398
|
+
/** The words of the dictionary by what the font does with them, for the options. */
|
|
399
|
+
wordBuckets: (options: PlanOptions, coverage: ReadonlySet<number>) => WordBuckets;
|
|
400
|
+
}
|
|
388
401
|
/**
|
|
389
402
|
* Indexes the compiled dictionary once, then plans fonts from it. Every
|
|
390
403
|
* decision about readings happens here: which words get ruby, which reading
|
|
391
404
|
* is the default, which longer words must be protected from a shorter match.
|
|
392
405
|
*/
|
|
393
|
-
export declare function createFontPlanner(dictionary: Dictionary):
|
|
406
|
+
export declare function createFontPlanner(dictionary: Dictionary): FontPlanner;
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/prune.d.ts
|
|
409
|
+
/**
|
|
410
|
+
* Drops the runs the font reads right without them: a run of one reading that
|
|
411
|
+
* the longest-match rules split into other words, which together write the
|
|
412
|
+
* run's reading, as 公認会計士 is 公認 and 会計士. Such a run only adds rules.
|
|
413
|
+
*
|
|
414
|
+
* The runs are judged shortest first, each against the words kept, as the
|
|
415
|
+
* font would choose them, so that a dropped run never takes away a part
|
|
416
|
+
* another run needs. A run stays when any plan could read it differently:
|
|
417
|
+
* with another reading, a JLPT level or a curated default.
|
|
418
|
+
*
|
|
419
|
+
* The parts take over what made the run common: its score, so that any `take`
|
|
420
|
+
* or `skip` that reaches the run reaches them, and its tags. A plan that
|
|
421
|
+
* narrows `sources` to where the run comes from but not the parts (a place
|
|
422
|
+
* name of `place`, whose parts are JMdict's words) reads the parts alone.
|
|
423
|
+
*/
|
|
424
|
+
export declare function pruneRuns(dictionary: Dictionary): {
|
|
425
|
+
dictionary: Dictionary;
|
|
426
|
+
pruned: string[];
|
|
427
|
+
};
|
|
394
428
|
//#endregion
|
|
395
|
-
export type { Dictionary, DictionaryEntry, DictionarySources, HanRun, ManualSource, Plan, PlanGlyph, PlanOptions, PlanOptionsInput, PlanReport, PlanResult, Reading, RubyMetrics,
|
|
429
|
+
export type { Dictionary, DictionaryEntry, DictionarySources, FuriganaScript, HanRun, ManualSource, Plan, PlanGlyph, PlanOptions, PlanOptionsInput, PlanReport, PlanResult, Reading, RubyMetrics, RubySpan, Source, WordBuckets, WordSource };
|
package/dist/index.mjs
CHANGED
|
@@ -9,7 +9,7 @@ const DEFAULTS = {
|
|
|
9
9
|
/** What a plan does when a config leaves an option out. */
|
|
10
10
|
plan: {
|
|
11
11
|
family: "Furifont Sans",
|
|
12
|
-
|
|
12
|
+
furiganaScript: "hiragana",
|
|
13
13
|
/** No word is left bare for being common; every word of every source gets ruby. */
|
|
14
14
|
skip: 0,
|
|
15
15
|
isolated: true,
|
|
@@ -36,25 +36,25 @@ const PREBUILT = {
|
|
|
36
36
|
family: "Furifont Sans",
|
|
37
37
|
base: "Source Han Sans JP",
|
|
38
38
|
face: "sans",
|
|
39
|
-
|
|
39
|
+
furiganaScript: "hiragana"
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
family: "Furifont Sans Romaji",
|
|
43
43
|
base: "Source Han Sans JP",
|
|
44
44
|
face: "sans",
|
|
45
|
-
|
|
45
|
+
furiganaScript: "hepburn"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
family: "Furifont Serif",
|
|
49
49
|
base: "Source Han Serif JP",
|
|
50
50
|
face: "serif",
|
|
51
|
-
|
|
51
|
+
furiganaScript: "hiragana"
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
54
|
family: "Furifont Serif Romaji",
|
|
55
55
|
base: "Source Han Serif JP",
|
|
56
56
|
face: "serif",
|
|
57
|
-
|
|
57
|
+
furiganaScript: "hepburn"
|
|
58
58
|
}
|
|
59
59
|
],
|
|
60
60
|
/** The static weights of the release, each of the base's weights by name. Bold is the variable font's. */
|
|
@@ -180,7 +180,7 @@ function getOrCreate(map, key, create) {
|
|
|
180
180
|
}
|
|
181
181
|
//#endregion
|
|
182
182
|
//#region src/schema.ts
|
|
183
|
-
const
|
|
183
|
+
const FURIGANA_SCRIPTS = [
|
|
184
184
|
"hiragana",
|
|
185
185
|
"katakanaOn",
|
|
186
186
|
"hepburn"
|
|
@@ -245,7 +245,7 @@ const RubyMetricsSchema = v.object({
|
|
|
245
245
|
const Count = v.pipe(v.number(), v.integer(), v.minValue(0));
|
|
246
246
|
const PlanOptionsSchema = v.object({
|
|
247
247
|
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),
|
|
248
|
-
|
|
248
|
+
furiganaScript: v.optional(v.picklist(FURIGANA_SCRIPTS), DEFAULTS.plan.furiganaScript),
|
|
249
249
|
/** The sources whose words get ruby. */
|
|
250
250
|
sources: v.optional(v.array(v.picklist(WORD_SOURCES)), () => [...WORD_SOURCES]),
|
|
251
251
|
/** The most common words left bare, by score: a reader knows them. Lone kanji count too. */
|
|
@@ -454,8 +454,17 @@ function sourcesOf(evidence) {
|
|
|
454
454
|
const level = evidence.jlpt && JLPT_LEVELS[5 - evidence.jlpt];
|
|
455
455
|
return evidence.sources | (level ? sourceBit(level) : 0) | ((evidence.uses ?? 0) >= MIN_USES ? sourceBit("tatoeba") : 0);
|
|
456
456
|
}
|
|
457
|
-
/**
|
|
458
|
-
|
|
457
|
+
/** Kanji up to this many are scored as the sources say; each one more costs {@link LONG_PENALTY} points. */
|
|
458
|
+
const LONG_RUN = 4;
|
|
459
|
+
const LONG_PENALTY = 2;
|
|
460
|
+
/** The lowest score still in the tier of `score`: a long run scores lower, but never as another kind of word. */
|
|
461
|
+
const tierFloor = (score) => score > SCORE.tagged ? SCORE.tagged + 1 : score > SCORE.used ? SCORE.used + 1 : score > SCORE.listed ? SCORE.listed + 1 : score;
|
|
462
|
+
/** How common a run's reading is, by the most telling of its sources, less for each kanji past {@link LONG_RUN}: a long word is rarer than its parts. */
|
|
463
|
+
function runScore(evidence, size) {
|
|
464
|
+
const score = sourceScore(evidence);
|
|
465
|
+
return Math.max(tierFloor(score), score - Math.max(0, size - LONG_RUN) * LONG_PENALTY);
|
|
466
|
+
}
|
|
467
|
+
function sourceScore(evidence) {
|
|
459
468
|
const uses = evidence.uses ?? 0;
|
|
460
469
|
return Math.max(evidence.rank ? scoreOf(1 / evidence.rank) : 0, uses >= MIN_USES ? SCORE.used + Math.min(uses, SCORE.tagged - SCORE.used) : 0, evidence.sources & MANUAL_BITS ? 15 : 0, evidence.sources & sourceBit("place") ? 10 : 0, evidence.order === void 0 ? 0 : SCORE.listed);
|
|
461
470
|
}
|
|
@@ -471,14 +480,14 @@ const tier = (score) => [
|
|
|
471
480
|
* kana are one, under the alignment that splits them most: 倉本 くら・もと
|
|
472
481
|
* rather than くらもと.
|
|
473
482
|
*/
|
|
474
|
-
function rankReadings(readings) {
|
|
483
|
+
function rankReadings(readings, size) {
|
|
475
484
|
const byKana = /* @__PURE__ */ new Map();
|
|
476
485
|
for (const [key, evidence] of readings) {
|
|
477
486
|
const spans = parseFurigana(key);
|
|
478
487
|
const reading = {
|
|
479
488
|
key,
|
|
480
489
|
spans,
|
|
481
|
-
score: runScore(evidence),
|
|
490
|
+
score: runScore(evidence, size),
|
|
482
491
|
sources: sourcesOf(evidence),
|
|
483
492
|
order: evidence.order ?? Infinity
|
|
484
493
|
};
|
|
@@ -563,7 +572,7 @@ function fuseDictionary(sources) {
|
|
|
563
572
|
for (const entry of sources.entries) for (const run of hanRuns(entry.form, parseFurigana(entry.furigana))) if ([...run.text].length > 1) addRun(runs, run, entry);
|
|
564
573
|
else addKanji(kanji, run, entry);
|
|
565
574
|
for (const [run, name] of listedRuns(sources)) evidenceOf(runs, run.text, formatFurigana(run.spans)).sources |= sourceBit(name);
|
|
566
|
-
const ranked = new Map([...runs].map(([text, readings]) => [text, new Map(rankReadings(readings))]));
|
|
575
|
+
const ranked = new Map([...runs].map(([text, readings]) => [text, new Map(rankReadings(readings, [...text].length))]));
|
|
567
576
|
const lone = kanjiReadings(kanji);
|
|
568
577
|
for (const run of sources.defaults.flatMap(notationRuns$1)) {
|
|
569
578
|
const [table, key] = [...run.text].length > 1 ? [ranked, formatFurigana(run.spans)] : [lone, run.spans[0].ruby];
|
|
@@ -918,6 +927,42 @@ function emitFea(input) {
|
|
|
918
927
|
return out.join("\n");
|
|
919
928
|
}
|
|
920
929
|
//#endregion
|
|
930
|
+
//#region src/suffixes.ts
|
|
931
|
+
/** A suffix reading is the one a kanji takes in this share of the runs it ends, out of at least `SUFFIX_MIN_RUNS`. */
|
|
932
|
+
const SUFFIX_MIN_SHARE = .9;
|
|
933
|
+
const SUFFIX_MIN_RUNS = 10;
|
|
934
|
+
/**
|
|
935
|
+
* The kanji that read one way ending the runs, given each run with the spans
|
|
936
|
+
* of its JMdict readings: 的 てき, 化 か; not 人, にん or じん.
|
|
937
|
+
*/
|
|
938
|
+
function pickSuffixes(runs) {
|
|
939
|
+
const suffixes = {
|
|
940
|
+
readings: /* @__PURE__ */ new Map(),
|
|
941
|
+
final: /* @__PURE__ */ new Set()
|
|
942
|
+
};
|
|
943
|
+
const evidence = /* @__PURE__ */ new Map();
|
|
944
|
+
const count = (counts, key) => void counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
945
|
+
const starts = /* @__PURE__ */ new Map();
|
|
946
|
+
const ends = /* @__PURE__ */ new Map();
|
|
947
|
+
for (const [text, readings] of runs) {
|
|
948
|
+
const chars = [...text];
|
|
949
|
+
count(starts, chars[0]);
|
|
950
|
+
count(ends, chars.at(-1));
|
|
951
|
+
for (const spans of readings) {
|
|
952
|
+
const span = spans.at(-1);
|
|
953
|
+
if (span.end - span.start === 1) count(getOrCreate(evidence, chars.at(-1), () => /* @__PURE__ */ new Map()), span.ruby);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
for (const [kanji, counts] of evidence) {
|
|
957
|
+
const total = [...counts.values()].reduce((a, b) => a + b, 0);
|
|
958
|
+
const [ruby, top] = [...counts].sort((a, b) => b[1] - a[1])[0];
|
|
959
|
+
if (total < SUFFIX_MIN_RUNS || top / total < SUFFIX_MIN_SHARE) continue;
|
|
960
|
+
suffixes.readings.set(kanji, ruby);
|
|
961
|
+
if ((starts.get(kanji) ?? 0) > ends.get(kanji)) suffixes.final.add(kanji);
|
|
962
|
+
}
|
|
963
|
+
return suffixes;
|
|
964
|
+
}
|
|
965
|
+
//#endregion
|
|
921
966
|
//#region src/planner.ts
|
|
922
967
|
/** VS225: shows the kanji before it without ruby. */
|
|
923
968
|
const SELECTOR_SUPPRESS = 917984;
|
|
@@ -927,9 +972,6 @@ const SELECTOR_FIRST_READING = 917985;
|
|
|
927
972
|
const MAX_READINGS = 15;
|
|
928
973
|
/** A lone kanji's reading needs this share of the evidence to be offered through a selector. */
|
|
929
974
|
const MIN_ALTERNATE_SHARE = .02;
|
|
930
|
-
/** A suffix reading is the one a kanji takes in this share of the runs it ends, out of at least `SUFFIX_MIN_RUNS`. */
|
|
931
|
-
const SUFFIX_MIN_SHARE = .9;
|
|
932
|
-
const SUFFIX_MIN_RUNS = 10;
|
|
933
975
|
const length = (text) => [...text].length;
|
|
934
976
|
function spanKey(spans) {
|
|
935
977
|
return spans.map((s) => `${s.start}-${s.end}:${s.ruby}`).join(";");
|
|
@@ -939,17 +981,17 @@ function rank(a, b) {
|
|
|
939
981
|
return (b.pinned ?? 0) - (a.pinned ?? 0) || Number(a.place ?? false) - Number(b.place ?? false) || a.order - b.order;
|
|
940
982
|
}
|
|
941
983
|
const isJmdict = (reading) => !!(reading.sources & sourceBit("jmdict"));
|
|
942
|
-
const PIN = sourceBit("default");
|
|
984
|
+
const PIN$1 = sourceBit("default");
|
|
943
985
|
function indexRuns(index, runs) {
|
|
944
986
|
for (const [text, readings] of Object.entries(runs)) {
|
|
945
987
|
const ranked = [];
|
|
946
988
|
for (const [furigana, [score, sources]] of Object.entries(readings)) {
|
|
947
989
|
const spans = parseFurigana(furigana);
|
|
948
|
-
if (sources & PIN) index.defaults.push({
|
|
990
|
+
if (sources & PIN$1) index.defaults.push({
|
|
949
991
|
text,
|
|
950
992
|
spans
|
|
951
993
|
});
|
|
952
|
-
if (sources & ~PIN) ranked.push({
|
|
994
|
+
if (sources & ~PIN$1) ranked.push({
|
|
953
995
|
spans,
|
|
954
996
|
weight: weightOf(score),
|
|
955
997
|
score,
|
|
@@ -969,7 +1011,7 @@ function indexKanji(index, kanji) {
|
|
|
969
1011
|
sources
|
|
970
1012
|
});
|
|
971
1013
|
if (sources & sourceBit("inflecting")) getOrCreate(index.inflecting, text, () => /* @__PURE__ */ new Set()).add(ruby);
|
|
972
|
-
if (sources & PIN) index.defaults.push({
|
|
1014
|
+
if (sources & PIN$1) index.defaults.push({
|
|
973
1015
|
text,
|
|
974
1016
|
spans: [{
|
|
975
1017
|
start: 0,
|
|
@@ -996,9 +1038,9 @@ function createContext(dictionary, options, coverage) {
|
|
|
996
1038
|
const covered = (text) => codepoints(text).every((cp) => coverage.has(cp));
|
|
997
1039
|
const render = (run, index) => {
|
|
998
1040
|
const span = run.spans[index];
|
|
999
|
-
if (options.
|
|
1041
|
+
if (options.furiganaScript === "hepburn") return toHepburn(span.ruby, run.spans.slice(index + 1).map((s) => s.ruby).join(""));
|
|
1000
1042
|
const kanji = [...run.text].slice(span.start, span.end);
|
|
1001
|
-
if (options.
|
|
1043
|
+
if (options.furiganaScript === "katakanaOn" && kanji.length === 1 && isOnyomi(span.ruby, dictionary.onyomi[kanji[0]] ?? [])) return toKatakana(span.ruby);
|
|
1002
1044
|
return span.ruby;
|
|
1003
1045
|
};
|
|
1004
1046
|
const known = options.skipJlpt ? JLPT_LEVELS.slice(0, JLPT_LEVELS.indexOf(options.skipJlpt) + 1).reduce((mask, level) => mask | sourceBit(level), 0) : 0;
|
|
@@ -1058,10 +1100,15 @@ function selectWords(index, ctx) {
|
|
|
1058
1100
|
const skipped = /* @__PURE__ */ new Set();
|
|
1059
1101
|
const skippedKanji = /* @__PURE__ */ new Set();
|
|
1060
1102
|
const words = [];
|
|
1103
|
+
const known = [];
|
|
1061
1104
|
for (const [text, readings] of index.runs) {
|
|
1062
1105
|
if (ctx.removed.has(text)) continue;
|
|
1063
1106
|
if (readings.some((r) => ctx.known(r.sources))) {
|
|
1064
1107
|
skipped.add(text);
|
|
1108
|
+
known.push({
|
|
1109
|
+
text,
|
|
1110
|
+
score: Math.max(...readings.map((r) => r.score))
|
|
1111
|
+
});
|
|
1065
1112
|
continue;
|
|
1066
1113
|
}
|
|
1067
1114
|
const chosen = wordReadings(text, readings, wanted, ctx);
|
|
@@ -1079,7 +1126,16 @@ function selectWords(index, ctx) {
|
|
|
1079
1126
|
word: false
|
|
1080
1127
|
}))].sort((a, b) => b.score - a.score);
|
|
1081
1128
|
for (const { text, word } of byScore.slice(0, ctx.options.skip)) (word ? skipped : skippedKanji).add(text);
|
|
1082
|
-
const
|
|
1129
|
+
const buckets = {
|
|
1130
|
+
skip: [...known, ...byScore.slice(0, ctx.options.skip).filter((item) => item.word)].sort((a, b) => b.score - a.score).map((item) => item.text),
|
|
1131
|
+
take: [],
|
|
1132
|
+
rest: []
|
|
1133
|
+
};
|
|
1134
|
+
const after = byScore.slice(ctx.options.skip).filter((item) => item.word).map((item) => item.text);
|
|
1135
|
+
const take = ctx.options.take ?? after.length;
|
|
1136
|
+
buckets.take = after.slice(0, take);
|
|
1137
|
+
buckets.rest = after.slice(take);
|
|
1138
|
+
const taken = new Set(buckets.take);
|
|
1083
1139
|
const patterns = new Map(words.filter(([text]) => taken.has(text)));
|
|
1084
1140
|
const lonePins = /* @__PURE__ */ new Map();
|
|
1085
1141
|
addCustomRuns(patterns, lonePins, ctx.options.words.add.flatMap(notationRuns), 0, ctx);
|
|
@@ -1090,7 +1146,8 @@ function selectWords(index, ctx) {
|
|
|
1090
1146
|
patterns,
|
|
1091
1147
|
lonePins,
|
|
1092
1148
|
skipped,
|
|
1093
|
-
skippedKanji
|
|
1149
|
+
skippedKanji,
|
|
1150
|
+
buckets
|
|
1094
1151
|
};
|
|
1095
1152
|
}
|
|
1096
1153
|
/** Readings of a lone kanji the font can draw, heaviest first, with their share of the evidence. */
|
|
@@ -1171,39 +1228,24 @@ function chooseLoneKanji(index, { patterns, lonePins, skippedKanji }, ctx) {
|
|
|
1171
1228
|
* clearly dominates there: 的 てき, 化 か; not 人, にん or じん.
|
|
1172
1229
|
*/
|
|
1173
1230
|
function chooseSuffixes(index, ctx) {
|
|
1174
|
-
|
|
1231
|
+
if (!ctx.options.isolated) return {
|
|
1175
1232
|
readings: /* @__PURE__ */ new Map(),
|
|
1176
1233
|
final: /* @__PURE__ */ new Set()
|
|
1177
1234
|
};
|
|
1178
|
-
|
|
1179
|
-
const
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
}
|
|
1192
|
-
for (const [kanji, counts] of evidence) {
|
|
1193
|
-
const total = [...counts.values()].reduce((a, b) => a + b, 0);
|
|
1194
|
-
const [ruby, top] = [...counts].sort((a, b) => b[1] - a[1])[0];
|
|
1195
|
-
if (total < SUFFIX_MIN_RUNS || top / total < SUFFIX_MIN_SHARE || ctx.removed.has(kanji) || !ctx.renderable({
|
|
1196
|
-
text: kanji,
|
|
1197
|
-
spans: [{
|
|
1198
|
-
start: 0,
|
|
1199
|
-
end: 1,
|
|
1200
|
-
ruby
|
|
1201
|
-
}]
|
|
1202
|
-
})) continue;
|
|
1203
|
-
suffixes.readings.set(kanji, ruby);
|
|
1204
|
-
if ((starts.get(kanji) ?? 0) > ends.get(kanji)) suffixes.final.add(kanji);
|
|
1205
|
-
}
|
|
1206
|
-
return suffixes;
|
|
1235
|
+
const { readings, final } = pickSuffixes([...index.jmdict].map(([text, rs]) => [text, rs.map((r) => r.spans)]));
|
|
1236
|
+
const usable = ([kanji, ruby]) => !ctx.removed.has(kanji) && ctx.renderable({
|
|
1237
|
+
text: kanji,
|
|
1238
|
+
spans: [{
|
|
1239
|
+
start: 0,
|
|
1240
|
+
end: 1,
|
|
1241
|
+
ruby
|
|
1242
|
+
}]
|
|
1243
|
+
});
|
|
1244
|
+
const kept = new Map([...readings].filter(usable));
|
|
1245
|
+
return {
|
|
1246
|
+
readings: kept,
|
|
1247
|
+
final: new Set([...final].filter((kanji) => kept.has(kanji)))
|
|
1248
|
+
};
|
|
1207
1249
|
}
|
|
1208
1250
|
/** Per character, the span reading it; keys are relative so they compare at any offset. */
|
|
1209
1251
|
function assignment(spans, size) {
|
|
@@ -1212,7 +1254,7 @@ function assignment(spans, size) {
|
|
|
1212
1254
|
return out;
|
|
1213
1255
|
}
|
|
1214
1256
|
/** The suffix reading of the kanji at `p` of a run when no word claimed it. */
|
|
1215
|
-
function suffixAt(chars, p, suffixes) {
|
|
1257
|
+
function suffixAt$1(chars, p, suffixes) {
|
|
1216
1258
|
const kanji = chars[p];
|
|
1217
1259
|
return p > 0 && (p === chars.length - 1 || !suffixes.final.has(kanji)) ? suffixes.readings.get(kanji) : void 0;
|
|
1218
1260
|
}
|
|
@@ -1227,7 +1269,7 @@ function misreads(chars, expected, known, longest, suffixes) {
|
|
|
1227
1269
|
break;
|
|
1228
1270
|
}
|
|
1229
1271
|
}
|
|
1230
|
-
const suffix = size < 2 ? suffixAt(chars, p, suffixes) : void 0;
|
|
1272
|
+
const suffix = size < 2 ? suffixAt$1(chars, p, suffixes) : void 0;
|
|
1231
1273
|
if (suffix !== void 0 && expected[p] !== `0:1:${suffix}`) return true;
|
|
1232
1274
|
p += Math.max(size, 1);
|
|
1233
1275
|
}
|
|
@@ -1443,6 +1485,7 @@ function createFontPlanner(dictionary) {
|
|
|
1443
1485
|
}));
|
|
1444
1486
|
return {
|
|
1445
1487
|
patterns,
|
|
1488
|
+
buckets: selection.buckets,
|
|
1446
1489
|
isolated,
|
|
1447
1490
|
kanjiReadings,
|
|
1448
1491
|
protectedRuns,
|
|
@@ -1470,8 +1513,8 @@ function createFontPlanner(dictionary) {
|
|
|
1470
1513
|
return last.analysis;
|
|
1471
1514
|
}
|
|
1472
1515
|
/** Plans a font; with `previewText`, keeps only what that text can trigger. */
|
|
1473
|
-
|
|
1474
|
-
const { patterns, isolated, kanjiReadings, protectedRuns, glyphs, rules, ligatures, singles, variants, variantReadings, alternates, ivsWords, lone, suffixes, finalSuffixes } = analysisFor(options, coverage);
|
|
1516
|
+
function planFont(options, coverage, previewText) {
|
|
1517
|
+
const { patterns, buckets, isolated, kanjiReadings, protectedRuns, glyphs, rules, ligatures, singles, variants, variantReadings, alternates, ivsWords, lone, suffixes, finalSuffixes } = analysisFor(options, coverage);
|
|
1475
1518
|
const sample = previewText === void 0 ? void 0 : new Set(codepoints(previewText));
|
|
1476
1519
|
const inSample = (cps) => !sample || cps.every((cp) => sample.has(cp));
|
|
1477
1520
|
const keptRules = rules.filter((r) => inSample(r.codepoints));
|
|
@@ -1546,6 +1589,7 @@ function createFontPlanner(dictionary) {
|
|
|
1546
1589
|
},
|
|
1547
1590
|
report: {
|
|
1548
1591
|
stats: {
|
|
1592
|
+
candidates: buckets.skip.length + buckets.take.length + buckets.rest.length,
|
|
1549
1593
|
patterns: patterns.size,
|
|
1550
1594
|
places: [...patterns.values()].filter((r) => r[0].place).length,
|
|
1551
1595
|
ambiguous: Object.keys(ivsWords).length,
|
|
@@ -1564,7 +1608,171 @@ function createFontPlanner(dictionary) {
|
|
|
1564
1608
|
}
|
|
1565
1609
|
}
|
|
1566
1610
|
};
|
|
1611
|
+
}
|
|
1612
|
+
/** The words of the dictionary by what the font does with them, for the options. */
|
|
1613
|
+
function wordBuckets(options, coverage) {
|
|
1614
|
+
return analysisFor(options, coverage).buckets;
|
|
1615
|
+
}
|
|
1616
|
+
return Object.assign(planFont, { wordBuckets });
|
|
1617
|
+
}
|
|
1618
|
+
//#endregion
|
|
1619
|
+
//#region src/prune.ts
|
|
1620
|
+
const maskOf = (sources) => sources.reduce((mask, source) => mask | sourceBit(source), 0);
|
|
1621
|
+
const PIN = sourceBit("default");
|
|
1622
|
+
const JMDICT = sourceBit("jmdict");
|
|
1623
|
+
/** What marks a word as common, which the parts of a dropped run take over. */
|
|
1624
|
+
const TAG_MASK = maskOf([
|
|
1625
|
+
"ichi",
|
|
1626
|
+
"news",
|
|
1627
|
+
"spec",
|
|
1628
|
+
"gai",
|
|
1629
|
+
"nf",
|
|
1630
|
+
"tatoeba"
|
|
1631
|
+
]);
|
|
1632
|
+
/** What a plan reads a word by besides its sources, so that a word with any of these stays: the JLPT levels a reader skips, the curated default. */
|
|
1633
|
+
const KEEP_MASK = maskOf([...JLPT_LEVELS, "default"]);
|
|
1634
|
+
/** `parseFurigana` of the readings each pass looks at again. */
|
|
1635
|
+
const spansCache = /* @__PURE__ */ new Map();
|
|
1636
|
+
function spansOf(key) {
|
|
1637
|
+
let spans = spansCache.get(key);
|
|
1638
|
+
if (!spans) spansCache.set(key, spans = parseFurigana(key));
|
|
1639
|
+
return spans;
|
|
1640
|
+
}
|
|
1641
|
+
function knownOf(dictionary, text) {
|
|
1642
|
+
const entries = Object.entries(dictionary.runs[text]);
|
|
1643
|
+
const [key, [score, sources]] = entries.find(([, [, sources]]) => sources & PIN) ?? entries[0];
|
|
1644
|
+
return {
|
|
1645
|
+
known: {
|
|
1646
|
+
key,
|
|
1647
|
+
kana: spansOf(key).map((s) => s.ruby).join(""),
|
|
1648
|
+
score,
|
|
1649
|
+
sources
|
|
1650
|
+
},
|
|
1651
|
+
droppable: entries.length === 1 && !(sources & KEEP_MASK) && !dictionary.bare.includes(text)
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
/** The suffix reading of the kanji at `p` of a run when no word claimed it, as the font applies it. */
|
|
1655
|
+
function suffixAt(chars, p, suffixes) {
|
|
1656
|
+
const kanji = chars[p];
|
|
1657
|
+
return p > 0 && (p === chars.length - 1 || !suffixes.final.has(kanji)) ? suffixes.readings.get(kanji) : void 0;
|
|
1658
|
+
}
|
|
1659
|
+
/** The words of `known`, and the suffixes, that the longest-match rules choose over `text`, when together they write `run`'s reading. */
|
|
1660
|
+
function partsOf(text, run, known, suffixes) {
|
|
1661
|
+
const chars = [...text];
|
|
1662
|
+
const parts = {
|
|
1663
|
+
words: [],
|
|
1664
|
+
suffixes: []
|
|
1665
|
+
};
|
|
1666
|
+
let kana = "";
|
|
1667
|
+
for (let p = 0; p < chars.length;) {
|
|
1668
|
+
let size = p === 0 ? chars.length - 1 : chars.length - p;
|
|
1669
|
+
for (; size >= 2; size--) {
|
|
1670
|
+
const word = chars.slice(p, p + size).join("");
|
|
1671
|
+
const part = known.get(word);
|
|
1672
|
+
if (part) {
|
|
1673
|
+
parts.words.push({
|
|
1674
|
+
text: word,
|
|
1675
|
+
part
|
|
1676
|
+
});
|
|
1677
|
+
kana += part.kana;
|
|
1678
|
+
break;
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
if (size < 2) {
|
|
1682
|
+
const suffix = suffixAt(chars, p, suffixes);
|
|
1683
|
+
if (suffix === void 0) return void 0;
|
|
1684
|
+
parts.suffixes.push(chars[p]);
|
|
1685
|
+
kana += suffix;
|
|
1686
|
+
size = 1;
|
|
1687
|
+
}
|
|
1688
|
+
p += size;
|
|
1689
|
+
}
|
|
1690
|
+
return kana === run.kana ? parts : void 0;
|
|
1691
|
+
}
|
|
1692
|
+
function prunePass(dictionary, suffixes) {
|
|
1693
|
+
const byLength = /* @__PURE__ */ new Map();
|
|
1694
|
+
for (const text of Object.keys(dictionary.runs)) {
|
|
1695
|
+
const length = [...text].length;
|
|
1696
|
+
getOrCreate(byLength, length, () => []).push(text);
|
|
1697
|
+
}
|
|
1698
|
+
const known = /* @__PURE__ */ new Map();
|
|
1699
|
+
/** The runs dropped, with the suffixes each one counts on. */
|
|
1700
|
+
const pruned = /* @__PURE__ */ new Map();
|
|
1701
|
+
/** What each part gains: the best score of the runs it stands in for, and their tags. */
|
|
1702
|
+
const gains = /* @__PURE__ */ new Map();
|
|
1703
|
+
for (const length of [...byLength.keys()].sort((a, b) => a - b)) {
|
|
1704
|
+
const kept = [];
|
|
1705
|
+
for (const text of byLength.get(length)) {
|
|
1706
|
+
const { known: run, droppable } = knownOf(dictionary, text);
|
|
1707
|
+
const parts = droppable ? partsOf(text, run, known, suffixes) : void 0;
|
|
1708
|
+
if (!parts) {
|
|
1709
|
+
kept.push([text, run]);
|
|
1710
|
+
continue;
|
|
1711
|
+
}
|
|
1712
|
+
pruned.set(text, parts.suffixes);
|
|
1713
|
+
for (const { text: partText } of parts.words) {
|
|
1714
|
+
const [score, tags] = gains.get(partText) ?? [0, 0];
|
|
1715
|
+
gains.set(partText, [Math.max(score, run.score), tags | run.sources & TAG_MASK]);
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
for (const [text, run] of kept) known.set(text, run);
|
|
1719
|
+
}
|
|
1720
|
+
const runs = Object.fromEntries(Object.entries(dictionary.runs).filter(([text]) => !pruned.has(text)).map(([text, readings]) => {
|
|
1721
|
+
const gain = gains.get(text);
|
|
1722
|
+
const key = known.get(text)?.key;
|
|
1723
|
+
return [text, gain ? Object.fromEntries(Object.entries(readings).map(([k, [score, sources]]) => k === key ? [k, [Math.max(score, gain[0]), sources | gain[1]]] : [k, [score, sources]])) : readings];
|
|
1724
|
+
}));
|
|
1725
|
+
return {
|
|
1726
|
+
dictionary: {
|
|
1727
|
+
...dictionary,
|
|
1728
|
+
runs
|
|
1729
|
+
},
|
|
1730
|
+
pruned
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
/** The suffixes of the dictionary's JMdict words, as the planner picks them. */
|
|
1734
|
+
function suffixesOf(dictionary) {
|
|
1735
|
+
return pickSuffixes(Object.entries(dictionary.runs).flatMap(([text, readings]) => {
|
|
1736
|
+
const jmdict = Object.entries(readings).filter(([, [, sources]]) => sources & JMDICT).map(([key]) => spansOf(key));
|
|
1737
|
+
return jmdict.length ? [[text, jmdict]] : [];
|
|
1738
|
+
}));
|
|
1739
|
+
}
|
|
1740
|
+
/**
|
|
1741
|
+
* Drops the runs the font reads right without them: a run of one reading that
|
|
1742
|
+
* the longest-match rules split into other words, which together write the
|
|
1743
|
+
* run's reading, as 公認会計士 is 公認 and 会計士. Such a run only adds rules.
|
|
1744
|
+
*
|
|
1745
|
+
* The runs are judged shortest first, each against the words kept, as the
|
|
1746
|
+
* font would choose them, so that a dropped run never takes away a part
|
|
1747
|
+
* another run needs. A run stays when any plan could read it differently:
|
|
1748
|
+
* with another reading, a JLPT level or a curated default.
|
|
1749
|
+
*
|
|
1750
|
+
* The parts take over what made the run common: its score, so that any `take`
|
|
1751
|
+
* or `skip` that reaches the run reaches them, and its tags. A plan that
|
|
1752
|
+
* narrows `sources` to where the run comes from but not the parts (a place
|
|
1753
|
+
* name of `place`, whose parts are JMdict's words) reads the parts alone.
|
|
1754
|
+
*/
|
|
1755
|
+
function pruneRuns(dictionary) {
|
|
1756
|
+
const banned = /* @__PURE__ */ new Set();
|
|
1757
|
+
const usable = (suffixes) => ({
|
|
1758
|
+
readings: new Map([...suffixes.readings].filter(([k]) => !banned.has(k))),
|
|
1759
|
+
final: suffixes.final
|
|
1760
|
+
});
|
|
1761
|
+
let suffixes = usable(suffixesOf(dictionary));
|
|
1762
|
+
let result = prunePass(dictionary, suffixes);
|
|
1763
|
+
for (let pass = 0; pass < 20; pass++) {
|
|
1764
|
+
const next = suffixesOf(result.dictionary);
|
|
1765
|
+
const holds = (kanji) => next.readings.get(kanji) === suffixes.readings.get(kanji) && next.final.has(kanji) === suffixes.final.has(kanji);
|
|
1766
|
+
const stale = new Set([...result.pruned.values()].flat().filter((kanji) => !holds(kanji)));
|
|
1767
|
+
if (!stale.size) break;
|
|
1768
|
+
for (const kanji of stale) banned.add(kanji);
|
|
1769
|
+
suffixes = usable(next);
|
|
1770
|
+
result = prunePass(dictionary, suffixes);
|
|
1771
|
+
}
|
|
1772
|
+
return {
|
|
1773
|
+
dictionary: result.dictionary,
|
|
1774
|
+
pruned: [...result.pruned.keys()]
|
|
1567
1775
|
};
|
|
1568
1776
|
}
|
|
1569
1777
|
//#endregion
|
|
1570
|
-
export { DEFAULTS, DictionarySchema, JLPT_LEVELS, MANUAL_SOURCES, MAX_READINGS, PREBUILT, PlanOptionsSchema, PlanSchema,
|
|
1778
|
+
export { DEFAULTS, DictionarySchema, FURIGANA_SCRIPTS, JLPT_LEVELS, MANUAL_SOURCES, MAX_READINGS, PREBUILT, PlanOptionsSchema, PlanSchema, SCORE, SELECTOR_FIRST_READING, SELECTOR_SUPPRESS, SOURCES, VERSION_PROBE, WORD_SOURCES, createFontPlanner, formatDictionary, formatFurigana, formatNotation, fuseDictionary, glyphName, hanRuns, isHan, isOnyomi, parseFurigana, parseNotation, pruneRuns, resolveOptions, scoreOf, sourceBit, sourcesIn, toHepburn, toHiragana, toKatakana, weightOf };
|
package/package.json
CHANGED