@furifont/core 0.1.2 → 0.2.0

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