@furifont/core 0.4.2 → 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 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 rubyScript: "hiragana";
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 rubyScript: "hiragana";
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 rubyScript: "hepburn";
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 rubyScript: "hiragana";
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 rubyScript: "hepburn";
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 RUBY_SCRIPTS: readonly ["hiragana", "katakanaOn", "hepburn"];
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 RubyScript = typeof RUBY_SCRIPTS[number];
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,7 +121,7 @@ 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 rubyScript: v.OptionalSchema<v.PicklistSchema<readonly ["hiragana", "katakanaOn", "hepburn"], undefined>, "hiragana">;
124
+ readonly furiganaScript: v.OptionalSchema<v.PicklistSchema<readonly ["hiragana", "katakanaOn", "hepburn"], undefined>, "hiragana">;
125
125
  /** The sources whose words get ruby. */
126
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. */
@@ -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): (options: PlanOptions, coverage: ReadonlySet<number>, previewText?: string) => PlanResult;
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, RubyScript, RubySpan, Source, WordSource };
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
- rubyScript: "hiragana",
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
- rubyScript: "hiragana"
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
- rubyScript: "hepburn"
45
+ furiganaScript: "hepburn"
46
46
  },
47
47
  {
48
48
  family: "Furifont Serif",
49
49
  base: "Source Han Serif JP",
50
50
  face: "serif",
51
- rubyScript: "hiragana"
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
- rubyScript: "hepburn"
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 RUBY_SCRIPTS = [
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
- rubyScript: v.optional(v.picklist(RUBY_SCRIPTS), DEFAULTS.plan.rubyScript),
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
- /** How common a run's reading is, by the most telling of its sources. */
458
- function runScore(evidence) {
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.rubyScript === "hepburn") return toHepburn(span.ruby, run.spans.slice(index + 1).map((s) => s.ruby).join(""));
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.rubyScript === "katakanaOn" && kanji.length === 1 && isOnyomi(span.ruby, dictionary.onyomi[kanji[0]] ?? [])) return toKatakana(span.ruby);
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 taken = new Set(byScore.slice(ctx.options.skip).filter((item) => item.word).slice(0, ctx.options.take).map((item) => item.text));
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
- const suffixes = {
1231
+ if (!ctx.options.isolated) return {
1175
1232
  readings: /* @__PURE__ */ new Map(),
1176
1233
  final: /* @__PURE__ */ new Set()
1177
1234
  };
1178
- if (!ctx.options.isolated) return suffixes;
1179
- const evidence = /* @__PURE__ */ new Map();
1180
- const count = (counts, key) => void counts.set(key, (counts.get(key) ?? 0) + 1);
1181
- const starts = /* @__PURE__ */ new Map();
1182
- const ends = /* @__PURE__ */ new Map();
1183
- for (const [text, readings] of index.jmdict) {
1184
- const chars = [...text];
1185
- count(starts, chars[0]);
1186
- count(ends, chars.at(-1));
1187
- for (const { spans } of readings) {
1188
- const span = spans.at(-1);
1189
- if (span.end - span.start === 1) count(getOrCreate(evidence, chars.at(-1), () => /* @__PURE__ */ new Map()), span.ruby);
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
- return function planFont(options, coverage, previewText) {
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, RUBY_SCRIPTS, SCORE, SELECTOR_FIRST_READING, SELECTOR_SUPPRESS, SOURCES, VERSION_PROBE, WORD_SOURCES, createFontPlanner, formatDictionary, formatFurigana, formatNotation, fuseDictionary, glyphName, hanRuns, isHan, isOnyomi, parseFurigana, parseNotation, resolveOptions, scoreOf, sourceBit, sourcesIn, toHepburn, toHiragana, toKatakana, weightOf };
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@furifont/core",
3
3
  "type": "module",
4
- "version": "0.4.2",
4
+ "version": "0.5.0",
5
5
  "description": "Plan furigana glyphs and OpenType rules from a Japanese dictionary",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",