@kensio/pinyinjs 1.5.3 → 1.7.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.
Files changed (112) hide show
  1. package/README.md +252 -5
  2. package/data/core.entries +462 -460
  3. package/data/core.freq +0 -0
  4. package/data/core.keys +2 -0
  5. package/data/full.entries +13732 -13734
  6. package/data/full.freq +0 -0
  7. package/data/full.keys +0 -2
  8. package/data/manifest.json +15 -15
  9. package/data/script.map +34 -55
  10. package/data/standard.entries +1354 -1352
  11. package/data/standard.freq +0 -0
  12. package/data/standard.keys +2 -0
  13. package/dist/cli/arguments.d.ts +38 -0
  14. package/dist/cli/arguments.d.ts.map +1 -1
  15. package/dist/cli/arguments.js +46 -0
  16. package/dist/cli/arguments.js.map +1 -1
  17. package/dist/cli/commands.d.ts.map +1 -1
  18. package/dist/cli/commands.js +229 -2
  19. package/dist/cli/commands.js.map +1 -1
  20. package/dist/cli/help.d.ts.map +1 -1
  21. package/dist/cli/help.js +5 -0
  22. package/dist/cli/help.js.map +1 -1
  23. package/dist/decode/convert.d.ts +58 -0
  24. package/dist/decode/convert.d.ts.map +1 -1
  25. package/dist/decode/convert.js +135 -37
  26. package/dist/decode/convert.js.map +1 -1
  27. package/dist/decode/decode.d.ts +3 -2
  28. package/dist/decode/decode.d.ts.map +1 -1
  29. package/dist/decode/decode.js +15 -10
  30. package/dist/decode/decode.js.map +1 -1
  31. package/dist/decode/hints.d.ts +138 -0
  32. package/dist/decode/hints.d.ts.map +1 -0
  33. package/dist/decode/hints.js +214 -0
  34. package/dist/decode/hints.js.map +1 -0
  35. package/dist/decode/lattice.d.ts +9 -1
  36. package/dist/decode/lattice.d.ts.map +1 -1
  37. package/dist/decode/lattice.js +5 -3
  38. package/dist/decode/lattice.js.map +1 -1
  39. package/dist/decode/reading-rules.d.ts +28 -0
  40. package/dist/decode/reading-rules.d.ts.map +1 -1
  41. package/dist/decode/reading-rules.js +157 -0
  42. package/dist/decode/reading-rules.js.map +1 -1
  43. package/dist/decode/runs.d.ts +11 -0
  44. package/dist/decode/runs.d.ts.map +1 -1
  45. package/dist/decode/runs.js +19 -0
  46. package/dist/decode/runs.js.map +1 -1
  47. package/dist/decode/segment.d.ts +66 -0
  48. package/dist/decode/segment.d.ts.map +1 -0
  49. package/dist/decode/segment.js +79 -0
  50. package/dist/decode/segment.js.map +1 -0
  51. package/dist/dictionary/artifact.d.ts +10 -0
  52. package/dist/dictionary/artifact.d.ts.map +1 -1
  53. package/dist/dictionary/artifact.js +17 -0
  54. package/dist/dictionary/artifact.js.map +1 -1
  55. package/dist/dictionary/assertions.d.ts.map +1 -1
  56. package/dist/dictionary/assertions.js +8 -0
  57. package/dist/dictionary/assertions.js.map +1 -1
  58. package/dist/dictionary/corpus-mass.d.ts +68 -0
  59. package/dist/dictionary/corpus-mass.d.ts.map +1 -0
  60. package/dist/dictionary/corpus-mass.js +123 -0
  61. package/dist/dictionary/corpus-mass.js.map +1 -0
  62. package/dist/dictionary/dictionary.d.ts +56 -0
  63. package/dist/dictionary/dictionary.d.ts.map +1 -1
  64. package/dist/dictionary/dictionary.js +116 -1
  65. package/dist/dictionary/dictionary.js.map +1 -1
  66. package/dist/dictionary/erhua.d.ts +4 -0
  67. package/dist/dictionary/erhua.d.ts.map +1 -1
  68. package/dist/dictionary/erhua.js +6 -0
  69. package/dist/dictionary/erhua.js.map +1 -1
  70. package/dist/dictionary/frequency-table.js +1 -1
  71. package/dist/dictionary/locale.js +2 -2
  72. package/dist/dictionary/merge.d.ts.map +1 -1
  73. package/dist/dictionary/merge.js +46 -9
  74. package/dist/dictionary/merge.js.map +1 -1
  75. package/dist/dictionary/tiers.d.ts +2 -1
  76. package/dist/dictionary/tiers.d.ts.map +1 -1
  77. package/dist/dictionary/tiers.js +77 -11
  78. package/dist/dictionary/tiers.js.map +1 -1
  79. package/dist/format/html.d.ts +32 -0
  80. package/dist/format/html.d.ts.map +1 -1
  81. package/dist/format/html.js +144 -0
  82. package/dist/format/html.js.map +1 -1
  83. package/dist/grade/check.d.ts +156 -0
  84. package/dist/grade/check.d.ts.map +1 -0
  85. package/dist/grade/check.js +356 -0
  86. package/dist/grade/check.js.map +1 -0
  87. package/dist/index.d.ts +14 -3
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +7 -2
  90. package/dist/index.js.map +1 -1
  91. package/dist/search/candidates.d.ts +84 -0
  92. package/dist/search/candidates.d.ts.map +1 -0
  93. package/dist/search/candidates.js +290 -0
  94. package/dist/search/candidates.js.map +1 -0
  95. package/dist/search/match.d.ts +72 -0
  96. package/dist/search/match.d.ts.map +1 -0
  97. package/dist/search/match.js +419 -0
  98. package/dist/search/match.js.map +1 -0
  99. package/dist/search/query.d.ts +49 -0
  100. package/dist/search/query.d.ts.map +1 -0
  101. package/dist/search/query.js +115 -0
  102. package/dist/search/query.js.map +1 -0
  103. package/dist/search/reverse-index.d.ts +141 -0
  104. package/dist/search/reverse-index.d.ts.map +1 -0
  105. package/dist/search/reverse-index.js +356 -0
  106. package/dist/search/reverse-index.js.map +1 -0
  107. package/dist/sources/unihan.d.ts +22 -4
  108. package/dist/sources/unihan.d.ts.map +1 -1
  109. package/dist/sources/unihan.js +74 -13
  110. package/dist/sources/unihan.js.map +1 -1
  111. package/package.json +2 -3
  112. package/CHANGELOG.md +0 -485
package/README.md CHANGED
@@ -35,6 +35,11 @@ wo3-xiang3-xue2-zhong1wen2
35
35
  $ pinyinjs script 我们后来发现了头发问题 --to zh-Hant
36
36
  我們後來發現了頭髮問題
37
37
 
38
+ $ pinyinjs check 银行 yínxíng
39
+ 银行 yínháng 50%
40
+ 银 yín yín correct
41
+ 行 háng xíng wrong
42
+
38
43
  $ pinyinjs explain 银行
39
44
  银行 yínháng
40
45
  yín locked
@@ -43,6 +48,11 @@ $ pinyinjs explain 银行
43
48
  $ pinyinjs lookup 头发
44
49
  头发 tóu fa n
45
50
 
51
+ $ pinyinjs match --query bjdx 北京大学 我在北京大学学中文 上海大学
52
+ [北京大学] 7.00
53
+ 我在[北京大学]学中文 6.33
54
+ 上海大学 no match
55
+
46
56
  $ pinyinjs syllable nǐhǎo
47
57
  nǐhǎo nǐ hǎo
48
58
  nǐ n + i, tone 3 nǐ ni3 ni³
@@ -59,6 +69,10 @@ běijīng běijīng ㄅㄟˇ ㄐㄧㄥ pei³-ching¹ běijīng beeij
59
69
  | ------------ | --------------------------------------------------- |
60
70
  | `convert` | hanzi to pinyin |
61
71
  | `html` | the same, as HTML |
72
+ | `annotate` | hanzi with its pinyin above, as ruby HTML |
73
+ | `segment` | split text into words |
74
+ | `match` | filter text by a pinyin query, best first |
75
+ | `check` | mark typed pinyin against the text |
62
76
  | `slug` | hanzi to a URL-safe slug |
63
77
  | `script` | 简体 ↔ 繁體 conversion |
64
78
  | `explain` | each syllable, how settled it was, and what it beat |
@@ -143,9 +157,9 @@ Load the dictionary once and keep it; it is immutable and safe to share.
143
157
 
144
158
  | Tier | Entries | Download (brotli) | Contains |
145
159
  | ---------- | ------: | ----------------: | ---------------------- |
146
- | `core` | 16,730 | 70 KB | single characters only |
147
- | `standard` | 66,730 | 376 KB | the most common words |
148
- | `full` | 461,623 | 2,381 KB | every word |
160
+ | `core` | 16,970 | 70 KB | single characters only |
161
+ | `standard` | 66,970 | 377 KB | the most common words |
162
+ | `full` | 461,555 | 2,378 KB | every word |
149
163
 
150
164
  `full` is the default. The tiers are nested, so a page can load `standard`
151
165
  first, convert with it, and reload as `full` arrives.
@@ -349,6 +363,231 @@ included, so write your own:
349
363
  Takes any `convert` option, plus `toneClasses: false`, `markUncertain: false`
350
364
  and `lang: false`. `toHtml(pieces, options)` renders pieces you already have.
351
365
 
366
+ ## Annotated output: hanzi and pinyin together
367
+
368
+ `convertToHtml` writes the pinyin _instead of_ the hanzi. To keep both — a
369
+ learner's text, a subtitle, a dictionary entry — annotate it instead:
370
+
371
+ ```ts
372
+ import { convertToAnnotatedHtml } from "@kensio/pinyinjs";
373
+
374
+ convertToAnnotatedHtml(dictionary, "银行");
375
+ // <ruby lang="zh">银<rp>(</rp><rt><span class="py-syllable py-tone-2"
376
+ // lang="zh-Latn-CN-pinyin">yín</span></rt><rp>)</rp></ruby>…
377
+ ```
378
+
379
+ The markup is `<ruby>`, which browsers lay out natively — the reading sits
380
+ above the characters, and `<rp>` puts it in parentheses where it does not. Each
381
+ `<rt>` holds exactly what `convertToHtml` would have written, so tone colours
382
+ and uncertainty marking work inside an annotation as they do outside one.
383
+
384
+ **A base is not always one character**, and this is where per-character
385
+ annotation goes wrong. 玩儿 is two characters and the one syllable `wánr`, and a
386
+ read number reverses on the way, so both are annotated whole:
387
+
388
+ ```ts
389
+ convertToAnnotatedHtml(dictionary, "玩儿"); // one <ruby>, 玩儿 over wánr
390
+ convertToAnnotatedHtml(dictionary, "95%"); // one <ruby>, 95% over bǎifēnzhījiǔshíwǔ
391
+ ```
392
+
393
+ Ruby needs no styling to work, but the reading is small by default:
394
+
395
+ ```css
396
+ ruby rt {
397
+ font-size: 0.5em;
398
+ }
399
+ ```
400
+
401
+ `toAnnotatedHtml(pieces, options)` renders pieces you already have, and
402
+ `ConvertedPiece.source` is what makes that possible: the characters each piece
403
+ reads, or undefined where it reads on into the ones before it.
404
+
405
+ ## Segment
406
+
407
+ Converting has to find the words before it can read them, because the unit a
408
+ reading belongs to is the word — 行 is `xíng` or `háng` and only 银行 and 行长
409
+ say which. `segment` returns that answer instead of throwing it away.
410
+
411
+ ```ts
412
+ import { segment } from "@kensio/pinyinjs";
413
+
414
+ segment(dictionary, "南京市长江大桥").map((found) => found.text);
415
+ // ["南京市", "长江", "大桥"], not ["南京", "市长", "江大桥"]
416
+
417
+ const found = segment(dictionary, "我要去北京。");
418
+ found.map((one) => one.text); // ["我", "要", "去", "北京", "。"]
419
+ found[3]?.partOfSpeech; // "ns", jieba's tag
420
+ found[3]?.isProperNoun; // true
421
+ found[3]?.at; // 3, in code points from the start of the text
422
+ ```
423
+
424
+ Every stretch comes back in order, including the ones that were never Han, so
425
+ the segments rejoin into exactly the text they came from — which is what makes
426
+ it safe to rebuild a document, highlight in place, or index a corpus. Filter on
427
+ `isKnown` for the stretches the dictionary recognised.
428
+
429
+ What it does not apply is 分词连写, the word spacing written pinyin wants: 他看了
430
+ segments as 他 / 看 / 了 and converts as `tā kànle`, because attaching an aspect
431
+ particle to its verb is a fact about writing pinyin rather than about where the
432
+ words are.
433
+
434
+ ## Match a pinyin query
435
+
436
+ A search box on a Latin keyboard, filtering Chinese text. `match` returns where
437
+ a query landed, or undefined where it did not land at all.
438
+
439
+ ```ts
440
+ import { match } from "@kensio/pinyinjs";
441
+
442
+ match(dictionary, "北京大学", "bjdx")?.ranges; // [{ at: 0, length: 4 }]
443
+ match(dictionary, "北京大学", "beijing")?.ranges; // [{ at: 0, length: 2 }]
444
+ match(dictionary, "北京大学", "nanjing"); // undefined
445
+ ```
446
+
447
+ Every way anybody types it: full syllables joined or spaced, `beijing` and
448
+ `bei jing`; initials, `bj`; the two mixed, `beij` and `bjing`; tones as digits
449
+ where they are worth writing, `bei3jing1`; `v` or `u:` for ü; and the r of 儿化
450
+ on the syllable it belongs to, so 玩儿 answers to `wanr`.
451
+
452
+ **No index is built and none is needed.** The haystack is Chinese, so the query
453
+ is tested as a path over each character's readings rather than the text being
454
+ spelled out in advance — which is why every reading of a polyphone is
455
+ matchable, where a default reading table only ever offers one of them:
456
+
457
+ ```ts
458
+ match(dictionary, "银行", "yh")?.score; // 7, and 银行 is yínháng
459
+ match(dictionary, "银行", "yx")?.score; // 5 — a reading 行 has, but not here
460
+ ```
461
+
462
+ Both match, and the decoder's own reading is what ranks them, along with
463
+ whether the match starts a word. Sort a filtered list by `score`, highest
464
+ first. Ranges rather than a boolean, in code points, so a caller can highlight
465
+ what matched — and there is more than one where the query stepped over
466
+ something with no reading of its own, as 北京·大学 does.
467
+
468
+ The `core` tier is enough, so a page that never loads a word list can still
469
+ filter. See [matching](docs/matching/).
470
+
471
+ ## Go the other way: pinyin to hanzi
472
+
473
+ `candidates` answers a pinyin query with the words it could be spelling, and
474
+ `homophonesOf` lists the words read exactly like one you already have. Both run
475
+ off a reverse index derived from the dictionary in memory.
476
+
477
+ ```ts
478
+ import { candidates, homophonesOf, ReverseIndex } from "@kensio/pinyinjs";
479
+
480
+ const index = ReverseIndex.of(dictionary);
481
+
482
+ candidates(index, "shi", { limit: 5 }); // ["是", "时", "事", "使", "市"]
483
+ candidates(index, "yinhang"); // ["銀行", "银行", "引吭", "引航", "印航"]
484
+ candidates(index, "yínháng"); // ["銀行", "银行"], narrowed by tone
485
+ homophonesOf(index, "长城"); // ["長城", "長程", "长程", "常程"]
486
+ ```
487
+
488
+ This is the half of search with no haystack: `match` filters Chinese text you
489
+ already hold, and this answers a query with nothing behind it but the
490
+ dictionary. Pinyin-only lookup, a homophones section on a word page, or a
491
+ browser input method for somebody with no Chinese keyboard.
492
+
493
+ **Nothing is downloaded for it.** Shipping a reverse index of the `full` tier
494
+ would add 1,995 KB to a 2,378 KB download and could not be compressed below
495
+ about 1,474 KB, so the client derives it instead — 510 ms and 2.03 MB of heap on
496
+ `full`, and 4 ms on `core`. `ReverseIndex.building` drives that build a slice at
497
+ a time, and `serialise` hands it out of a worker, so nothing has to hold the
498
+ main thread for it.
499
+
500
+ Candidates come back likeliest first, because a posting is a dictionary position
501
+ and a position indexes the frequency table. Tones may be written or left off,
502
+ `v` and `u:` reach ü, and the r of 儿化 is optional, so 玩儿 answers to `wanr`
503
+ and to `wan`. Both scripts are dictionary keys, so pass a script preference and
504
+ the conversion tables to keep one writing of a word rather than both. See
505
+ [candidates](docs/candidates/).
506
+
507
+ ## Check what somebody typed
508
+
509
+ `check` marks a typed pinyin transcription against the Chinese it was written
510
+ for, syllable by syllable.
511
+
512
+ ```ts
513
+ import { check } from "@kensio/pinyinjs";
514
+
515
+ const marked = check(dictionary, "银行", "yínxíng");
516
+ marked.syllables.map((one) => one.verdict); // ["correct", "wrong"]
517
+ marked.syllables[1]?.source; // "行", the character that was misread
518
+ marked.score; // 0.5
519
+ ```
520
+
521
+ Each syllable comes back as one of six verdicts, with `source` and `at` naming
522
+ the characters it reads so a mistake can be shown against the text rather than
523
+ against the answer:
524
+
525
+ | Verdict | Means |
526
+ | ---------- | ---------------------------------------------- |
527
+ | `correct` | right syllable, right tone |
528
+ | `toneless` | right syllable, no tone written |
529
+ | `tone` | right syllable, wrong tone |
530
+ | `wrong` | wrong syllable |
531
+ | `missing` | a syllable of the reading that was not typed |
532
+ | `extra` | a syllable typed that the reading does not use |
533
+
534
+ **The point is being fair in the ways a string comparison cannot be.** All of
535
+ these are a learner being marked wrong for being right, and all of them pass:
536
+
537
+ ```ts
538
+ check(dictionary, "北京", "bei3jīng").isCorrect; // true, either notation, mixed
539
+ check(dictionary, "行", "háng").isCorrect; // true, the decoder was guessing too
540
+ check(dictionary, "你好", "ní hǎo").isCorrect; // true, said with sandhi
541
+ check(dictionary, "不是", "bù shì").isCorrect; // true, written without it
542
+ check(dictionary, "海鸥", "hǎiōu").isCorrect; // true, an apostrophe is not a sound
543
+ check(dictionary, "我的书", "wǒ de shū").isCorrect; // true, pinyin marks no neutral tone
544
+ ```
545
+
546
+ A guess is only forgiven where the library was guessing: 行 alone is chosen by a
547
+ prior and nothing more, whereas 银行 settles both its syllables, so `yínxíng` is
548
+ a real mistake. See [confidence](docs/confidence/).
549
+
550
+ ### Tones and word spacing
551
+
552
+ Two axes an exercise may or may not be teaching yet, each reported always and
553
+ each counted only when asked for.
554
+
555
+ Tones left off are `toneless` rather than `tone`, because `Syllable.tone` knows
556
+ the difference:
557
+
558
+ ```ts
559
+ check(dictionary, "北京", "bei jing").isCorrect; // true
560
+ check(dictionary, "北京", "bei jing", { tones: "required" }).isCorrect; // false
561
+ ```
562
+
563
+ Word spacing is graded on `spacing`, separately from the syllable's own verdict,
564
+ because it is a separate mistake: `yín háng` reads 银行 perfectly and writes it
565
+ as two words.
566
+
567
+ ```ts
568
+ const split = check(dictionary, "银行", "yín háng");
569
+ split.syllables.map((one) => one.spacing); // ["correct", "split"]
570
+ split.isCorrect; // true, spacing is not counted by default
571
+ check(dictionary, "银行", "yín háng", { spacing: "required" }).isCorrect; // false
572
+ ```
573
+
574
+ It is `split` for a word written as two and `joined` for two written as one, and
575
+ it is tolerant in the same spirit as everything else — 分词连写 and the words the
576
+ dictionary knows are two conventions this package writes, and a learner may have
577
+ been taught either:
578
+
579
+ ```ts
580
+ const graded = { spacing: "required" } as const;
581
+ check(dictionary, "他看了", "tā kànle", graded).isCorrect; // true, 分词连写
582
+ check(dictionary, "他看了", "tā kàn le", graded).isCorrect; // true, the words
583
+ check(dictionary, "干干净净", "gāngān jìngjìng", graded).isCorrect; // true, a hyphen is a boundary
584
+ check(dictionary, "我要去北京。", "wǒyàoqùběijīng", graded).isCorrect; // false
585
+ ```
586
+
587
+ Takes every `convert` option besides, so an exercise that knows which sense of 长
588
+ its own sentence uses can say so with `readings`. More in
589
+ [checking](docs/checking/).
590
+
352
591
  ## Look words up
353
592
 
354
593
  ```ts
@@ -598,6 +837,13 @@ This is orthography and not translation — 软件 becomes 軟件, never 軟體.
598
837
  | `convert(dictionary, text, options?)` | hanzi → pinyin |
599
838
  | `convertPieces(dictionary, text, ...)` | the same, per syllable, with confidence |
600
839
  | `convertToHtml(dictionary, text, ...)` | the same, as HTML |
840
+ | `convertToAnnotatedHtml(dictionary, text, ...)` | hanzi and pinyin together, as ruby HTML |
841
+ | `segment(dictionary, text)` | split text into words |
842
+ | `match(dictionary, haystack, query)` | where a pinyin query matches a text |
843
+ | `candidates(index, query, options?)` | pinyin → the hanzi it could be |
844
+ | `homophonesOf(index, word, options?)` | the words read exactly like this one |
845
+ | `ReverseIndex.of(dictionary)` | derive the reading → words index |
846
+ | `check(dictionary, text, typed, options?)` | mark typed pinyin against the text |
601
847
  | `slug(dictionary, text, options?)` | hanzi → a URL-safe slug |
602
848
  | `joinPieces(pieces)` / `toHtml(pieces)` | render pieces |
603
849
  | `isUncertain(confidence)` | was this syllable a guess? |
@@ -613,8 +859,9 @@ This is orthography and not translation — 软件 becomes 軟件, never 軟體.
613
859
  | `convertGreedily(...)` | the old longest-match decoder, kept as a baseline |
614
860
 
615
861
  Types (`Syllable`, `Tone`, `ConvertOptions`, `ConvertedPiece`,
616
- `ReadingConfidence`, `HtmlOptions`, `WordEntry`, `Tier`, `Locale`, `Script`) are
617
- exported alongside them.
862
+ `ReadingConfidence`, `HtmlOptions`, `Segment`, `PinyinMatch`, `MatchRange`,
863
+ `ReadingConfidence`, `HtmlOptions`, `Segment`, `PinyinCheck`, `CheckedSyllable`,
864
+ `WordEntry`, `Tier`, `Locale`, `Script`) are exported alongside them.
618
865
 
619
866
  ## Development
620
867