@furifont/core 0.1.2 → 0.3.0

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