Hanzi → pinyin conversion for TypeScript, in Node and the browser. Also parses, validates and writes pinyin syllables on their own, with no dictionary.
pnpm add @kensio/pinyinjsNode 22+, or any browser. The core imports no Node built-ins, and the package is ESM only.
The dictionaries ship inside the package, which is what makes it a 4 MB
download: data/ is 10 MB of artifacts and the point of the whole thing.
Installing the package installs a pinyinjs command, which is the quickest way
to try any of this.
$ pinyinjs convert 我要去北京。
Wǒ yào qù Běijīng.
$ pinyinjs convert --notation numbers 银行
yin2hang2
$ pinyinjs slug 我想学中文。
wo3-xiang3-xue2-zhong1wen2
$ pinyinjs script 我们后来发现了头发问题 --to zh-Hant
我們後來發現了頭髮問題
$ pinyinjs check 银行 yínxíng
银行 yínháng 50%
银 yín yín correct
行 háng xíng wrong
$ pinyinjs explain 银行
银行 yínháng
yín locked
háng word xíng +24.6 héng +26.6 hàng +27.6
$ pinyinjs lookup 头发
头发 tóu fa n
$ pinyinjs match --query bjdx 北京大学 我在北京大学学中文 上海大学
[北京大学] 7.00
我在[北京大学]学中文 6.33
上海大学 no match
$ pinyinjs syllable nǐhǎo
nǐhǎo nǐ hǎo
nǐ n + i, tone 3 nǐ ni3 ni³
hǎo h + ao, tone 3 hǎo hao3 hao³
$ pinyinjs convert --system wade-giles --notation none 我要去北京。
Wo yao ch'ü Pei-ching.
$ pinyinjs transcribe běijīng
běijīng běijīng ㄅㄟˇ ㄐㄧㄥ pei³-ching¹ běijīng beeijing pei˨˩˦tɕiŋ˥| Command | Does |
|---|---|
convert |
hanzi to pinyin |
html |
the same, as HTML |
annotate |
hanzi with its pinyin above, as ruby HTML |
segment |
split text into words |
match |
filter text by a pinyin query, best first |
check |
mark typed pinyin against the text |
slug |
hanzi to a URL-safe slug |
script |
简体 ↔ 繁體 conversion |
explain |
each syllable, how settled it was, and what it beat |
lookup |
what the dictionary holds for a word |
syllable |
take written pinyin apart |
sandhi |
apply tone sandhi to written pinyin |
number |
read a number as Chinese numerals |
transcribe |
pinyin to bopomofo, Wade-Giles, Yale, GR and IPA |
info |
which dictionary is loaded, and how big it is |
Every conversion option below is a flag: --notation, --locale,
--apostrophe, --capitals, --punctuation, --no-grouping, --third-tone,
--no-sandhi. Run pinyinjs <command> --help for what a command takes.
A command given no arguments reads standard input, one text per line, so
cat article.txt | pinyinjs convert works. syllable and sandhi need no
dictionary at all and start without loading one.
At a terminal each syllable is written in its tone's colour, in
MDBG's palette of red, yellow, green, blue, and the
terminal's own colour for the neutral tone. It is off for a pipe, NO_COLOR is
honoured, --colour and --no-colour force it either way, and --json is
never coloured. See the command line.
convert writes the pinyin and nothing else, so it drops straight into a
pipeline:
$ pinyinjs convert 银行
yínhángEverything else has columns for a person to read. Add --json to any command
and it writes one JSON document per answer instead, which is what jq wants:
$ pinyinjs explain 长江大桥 --json | jq -c '.syllables[] | select(.state != "locked")'
{"text":"Cháng","state":"word","tone":2,"alternatives":[{"reading":"zhǎng","cost":24.62}]}
{"text":"Dà","state":"word","tone":4,"alternatives":[{"reading":"dài","cost":22.62}]}
$ pinyinjs lookup 垃圾 --json | jq -r .taiwanReading
lè sè
$ cat article.txt | pinyinjs convert --json | jq -r .pinyinOne document per answer rather than one array for the whole run, so the shape is the same whether you convert one word or pipe a file through.
Converting needs a dictionary, and it is a fetchable file rather than a JavaScript module, so loading it is asynchronous.
import { convert, loadDictionary } from "@kensio/pinyinjs";
import { fileSource } from "@kensio/pinyinjs/node";
const source = fileSource("node_modules/@kensio/pinyinjs/data");
const dictionary = await loadDictionary(source, "full");
convert(dictionary, "银行"); // "yínháng"In a browser, serve the package's data/ directory and fetch it:
import { convert, fetchSource, loadDictionary } from "@kensio/pinyinjs";
const dictionary = await loadDictionary(fetchSource("/data"), "standard");
convert(dictionary, "长城"); // "Chángchéng"Serve the artifacts uncompressed and let HTTP Content-Encoding: br compress
them: DecompressionStream has no brotli.
Load the dictionary once and keep it; it is immutable and safe to share.
| Tier | Entries | Download (brotli) | Contains |
|---|---|---|---|
core |
16,970 | 70 KB | single characters only |
standard |
66,970 | 377 KB | the most common words |
full |
461,555 | 2,378 KB | every word |
full is the default. The tiers are nested, so a page can load standard
first, convert with it, and reload as full arrives.
convert(dictionary, "银行"); // "yínháng"
convert(dictionary, "行长"); // "hángzhǎng"
convert(dictionary, "我要去北京。"); // "Wǒ yào qù Běijīng."
convert(dictionary, "3D银行"); // "sān D yínháng", the digit is read, the letter is notA reading the dictionary cannot settle on its own is settled by context, with typed rules over the lattice rather than tweaks to the output:
convert(dictionary, "我得走了"); // "wǒ děi zǒule", modal 得
convert(dictionary, "他跑得很快"); // "tā pǎo de hěn kuài", the particle
convert(dictionary, "那边儿"); // "nà biānr", 儿 does not stand on its ownSee converting.
convert(dictionary, text, { notation: "numbers", capitals: "none" });| Option | Default | Values |
|---|---|---|
locale |
"zh-CN" |
"zh-CN", "zh-TW" |
notation |
"marks" |
"marks", "numbers", "superscript", "none" |
apostrophe |
"always" |
"always", "standard", "never" |
capitals |
"auto" |
"auto", "proper", "none" |
punctuation |
"latin" |
"latin", "keep" |
grouping |
true |
false turns off GB/T 16159 word spacing |
numbers |
"read" |
"keep" leaves every digit as it was written |
sandhi |
{ yiBu: true, thirdTone: false } |
{ yiBu?: boolean; thirdTone?: boolean } |
convert(dictionary, "垃圾"); // "lājī"
convert(dictionary, "垃圾", { locale: "zh-TW" }); // "lèsè"
convert(dictionary, "银行", { notation: "numbers" }); // "yin2hang2"
convert(dictionary, "银行", { notation: "superscript" }); // "yin²hang²"
convert(dictionary, "银行", { notation: "none" }); // "yinhang"
convert(dictionary, "西安"); // "Xī'ān"
convert(dictionary, "海鸥", { apostrophe: "standard" }); // "hǎiōu"
convert(dictionary, "北京。", { punctuation: "keep" }); // "Běijīng。"
convert(dictionary, "北京。", { capitals: "none" }); // "běijīng."
convert(dictionary, "好好", { sandhi: { thirdTone: true } }); // "háohǎo"Capitals. Proper nouns always; the first word of a sentence only when the source is punctuated as one, since that is the only thing separating 学生 looked up as a word from 这是我的书。written as a sentence. A comma does not count.
convert(dictionary, "银行"); // "yínháng", not "Yínháng"
convert(dictionary, "我要去北京。"); // "Wǒ yào qù Běijīng."
convert(dictionary, "你好,世界"); // "nǐ hǎo, shìjiè"Apostrophes. The 隔音符号 goes before any syllable of a word that starts with
a, o or e and is not the first. apostrophe: "standard" writes it only
where leaving it out would read as something else.
convert(dictionary, "天安门"); // "Tiān'ānmén"
convert(dictionary, "女儿"); // "nǚ'ér"Punctuation. 。,、;:?! are rewritten as their Latin equivalents and take
the space the full-width glyph carried. Brackets and quotation marks are left
alone. punctuation: "keep" leaves everything as it was.
Word spacing. 分词连写 is applied to the decoded words: aspect particles attach to their verb, suffixes to their stem, and the generic half of a place name separates and capitalises. A small curated list covers words the standard writes in a way no rule reaches.
convert(dictionary, "他看了"); // "tā kànle"
convert(dictionary, "我还给你了。"); // "Wǒ huán gěi nǐ le.", sentence-final 了
convert(dictionary, "作者"); // "zuòzhě"
convert(dictionary, "南京市"); // "Nánjīng Shì"
convert(dictionary, "南京市", { grouping: false }); // "Nánjīngshì"
convert(dictionary, "不是"); // "bú shì"
convert(dictionary, "一个"); // "yí gè"
convert(dictionary, "黄河"); // "Huáng Hé"
convert(dictionary, "中国人"); // "Zhōngguórén"The list is not a complete 正词法 implementation, so some words it does not
cover are written differently: 不但 is búdàn, 大米 is dàmǐ, 青海 is
Qīnghǎi.
Reduplication takes a hyphen rather than a space, since it is one word with a boundary inside it.
convert(dictionary, "干干净净"); // "gāngān-jìngjìng"
convert(dictionary, "研究研究"); // "yánjiū-yánjiū"
convert(dictionary, "爸爸妈妈"); // "bàba māma", that shape, but two wordsA 成语 that can be read as two disyllables takes the same hyphen, from a curated list of 117; the rest are written solid, as the standard writes them.
convert(dictionary, "风平浪静"); // "fēngpíng-làngjìng"
convert(dictionary, "不亦乐乎"); // "búyìlèhū", cannot be halvedDigits are read, and the rest of a non-Han run passes through exactly as written:
convert(dictionary, "我有3个苹果。"); // "Wǒ yǒu sān gè píngguǒ."
convert(dictionary, "1988年之后"); // "yī jiǔ bā bā nián zhīhòu"
convert(dictionary, "95%的人"); // "bǎifēnzhījiǔshíwǔ de rén"
convert(dictionary, "3D打印"); // "sān D dǎyìn"
convert(dictionary, "6:30起床"); // "liù diǎn sānshí fēn qǐchuáng"
convert(dictionary, "16:9的"); // "16:9de", a ratio is not a quantitynumbers: "keep" leaves every digit alone. See numbers.
convertPieces returns the same conversion one piece at a time, with the
syllable behind each and what the decoder chose it over.
import { convertPieces, isUncertain, writeSyllable } from "@kensio/pinyinjs";
const pieces = convertPieces(dictionary, "银行");
pieces.map((piece) => piece.text); // ["yín", "háng"]
pieces[1]?.syllable; // { initial: "h", final: "ang", tone: 2 }
pieces[0]?.confidence?.isLocked; // true, nothing else can be read here
pieces[1]?.confidence?.alternatives.map((found) =>
found.reading.map((syllable) => writeSyllable(syllable)).join(""),
); // ["xíng", "héng", "hàng"]A piece with no syllable is the text between two of them: a space, or a run
that was never Han. joinPieces(pieces) gives back exactly what convert
returns.
Each syllable is in one of three states:
| State | isLocked |
isUncertain |
Meaning |
|---|---|---|---|
| locked | true |
false |
only one reading is possible here |
| backed by a word | false |
false |
other readings exist; taking one means breaking a word up |
| uncertain | false |
true |
another reading of the same characters was nearly as cheap |
const guesses = (text: string) =>
convertPieces(dictionary, text).filter(
(piece) => piece.confidence !== undefined && isUncertain(piece.confidence),
);
guesses("行").map((piece) => piece.text); // ["xíng"], nothing but a prior chose it
guesses("银行").map((piece) => piece.text); // [], the word settles both syllablesAn alternative's cost says how much more the cheapest conversion taking it
would have cost, in the decoder's own units. Treat it as a measure of how much
evidence there was, not as a probability.
import { convertToHtml } from "@kensio/pinyinjs";
convertToHtml(dictionary, "行");
// <span class="py-syllable py-tone-2 py-uncertain" lang="zh-Latn-CN-pinyin"
// data-alternatives="háng héng hàng">xíng</span>One element per syllable, with py-tone-1 to py-tone-5 (5 is the neutral
tone), and py-uncertain plus the rejected readings where the decoder was
guessing. Each one declares itself pinyin — zh-Latn-TW-pinyin for a zh-TW
conversion — so that a screen reader does not read it as the language of the
page around it. Text that is not Han is escaped, not marked up. No styles are
included, so write your own:
.py-tone-1 {
color: #c1272d;
}
.py-uncertain {
border-bottom: 1px dotted currentcolor;
}Takes any convert option, plus toneClasses: false, markUncertain: false
and lang: false. toHtml(pieces, options) renders pieces you already have.
convertToHtml writes the pinyin instead of the hanzi. To keep both — a
learner's text, a subtitle, a dictionary entry — annotate it instead:
import { convertToAnnotatedHtml } from "@kensio/pinyinjs";
convertToAnnotatedHtml(dictionary, "银行");
// <ruby lang="zh">银<rp>(</rp><rt><span class="py-syllable py-tone-2"
// lang="zh-Latn-CN-pinyin">yín</span></rt><rp>)</rp></ruby>…The markup is <ruby>, which browsers lay out natively — the reading sits
above the characters, and <rp> puts it in parentheses where it does not. Each
<rt> holds exactly what convertToHtml would have written, so tone colours
and uncertainty marking work inside an annotation as they do outside one.
A base is not always one character, and this is where per-character
annotation goes wrong. 玩儿 is two characters and the one syllable wánr, and a
read number reverses on the way, so both are annotated whole:
convertToAnnotatedHtml(dictionary, "玩儿"); // one <ruby>, 玩儿 over wánr
convertToAnnotatedHtml(dictionary, "95%"); // one <ruby>, 95% over bǎifēnzhījiǔshíwǔRuby needs no styling to work, but the reading is small by default:
ruby rt {
font-size: 0.5em;
}toAnnotatedHtml(pieces, options) renders pieces you already have, and
ConvertedPiece.source is what makes that possible: the characters each piece
reads, or undefined where it reads on into the ones before it.
Converting has to find the words before it can read them, because the unit a
reading belongs to is the word — 行 is xíng or háng and only 银行 and 行长
say which. segment returns that answer instead of throwing it away.
import { segment } from "@kensio/pinyinjs";
segment(dictionary, "南京市长江大桥").map((found) => found.text);
// ["南京市", "长江", "大桥"], not ["南京", "市长", "江大桥"]
const found = segment(dictionary, "我要去北京。");
found.map((one) => one.text); // ["我", "要", "去", "北京", "。"]
found[3]?.partOfSpeech; // "ns", jieba's tag
found[3]?.isProperNoun; // true
found[3]?.at; // 3, in code points from the start of the textEvery stretch comes back in order, including the ones that were never Han, so
the segments rejoin into exactly the text they came from — which is what makes
it safe to rebuild a document, highlight in place, or index a corpus. Filter on
isKnown for the stretches the dictionary recognised.
What it does not apply is 分词连写, the word spacing written pinyin wants: 他看了
segments as 他 / 看 / 了 and converts as tā kànle, because attaching an aspect
particle to its verb is a fact about writing pinyin rather than about where the
words are.
A search box on a Latin keyboard, filtering Chinese text. match returns where
a query landed, or undefined where it did not land at all.
import { match } from "@kensio/pinyinjs";
match(dictionary, "北京大学", "bjdx")?.ranges; // [{ at: 0, length: 4 }]
match(dictionary, "北京大学", "beijing")?.ranges; // [{ at: 0, length: 2 }]
match(dictionary, "北京大学", "nanjing"); // undefinedEvery way anybody types it: full syllables joined or spaced, beijing and
bei jing; initials, bj; the two mixed, beij and bjing; tones as digits
where they are worth writing, bei3jing1; v or u: for ü; and the r of 儿化
on the syllable it belongs to, so 玩儿 answers to wanr.
No index is built and none is needed. The haystack is Chinese, so the query is tested as a path over each character's readings rather than the text being spelled out in advance — which is why every reading of a polyphone is matchable, where a default reading table only ever offers one of them:
match(dictionary, "银行", "yh")?.score; // 7, and 银行 is yínháng
match(dictionary, "银行", "yx")?.score; // 5 — a reading 行 has, but not hereBoth match, and the decoder's own reading is what ranks them, along with
whether the match starts a word. Sort a filtered list by score, highest
first. Ranges rather than a boolean, in code points, so a caller can highlight
what matched — and there is more than one where the query stepped over
something with no reading of its own, as 北京·大学 does.
The core tier is enough, so a page that never loads a word list can still
filter. See matching.
candidates answers a pinyin query with the words it could be spelling, and
homophonesOf lists the words read exactly like one you already have. Both run
off a reverse index derived from the dictionary in memory.
import { candidates, homophonesOf, ReverseIndex } from "@kensio/pinyinjs";
const index = ReverseIndex.of(dictionary);
candidates(index, "shi", { limit: 5 }); // ["是", "时", "事", "使", "市"]
candidates(index, "yinhang"); // ["銀行", "银行", "引吭", "引航", "印航"]
candidates(index, "yínháng"); // ["銀行", "银行"], narrowed by tone
homophonesOf(index, "长城"); // ["長城", "長程", "长程", "常程"]This is the half of search with no haystack: match filters Chinese text you
already hold, and this answers a query with nothing behind it but the
dictionary. Pinyin-only lookup, a homophones section on a word page, or a
browser input method for somebody with no Chinese keyboard.
Nothing is downloaded for it. Shipping a reverse index of the full tier
would add 1,995 KB to a 2,378 KB download and could not be compressed below
about 1,474 KB, so the client derives it instead — 510 ms and 2.03 MB of heap on
full, and 4 ms on core. ReverseIndex.building drives that build a slice at
a time, and serialise hands it out of a worker, so nothing has to hold the
main thread for it.
Candidates come back likeliest first, because a posting is a dictionary position
and a position indexes the frequency table. Tones may be written or left off,
v and u: reach ü, and the r of 儿化 is optional, so 玩儿 answers to wanr
and to wan. Both scripts are dictionary keys, so pass a script preference and
the conversion tables to keep one writing of a word rather than both. See
candidates.
check marks a typed pinyin transcription against the Chinese it was written
for, syllable by syllable.
import { check } from "@kensio/pinyinjs";
const marked = check(dictionary, "银行", "yínxíng");
marked.syllables.map((one) => one.verdict); // ["correct", "wrong"]
marked.syllables[1]?.source; // "行", the character that was misread
marked.score; // 0.5Each syllable comes back as one of six verdicts, with source and at naming
the characters it reads so a mistake can be shown against the text rather than
against the answer:
| Verdict | Means |
|---|---|
correct |
right syllable, right tone |
toneless |
right syllable, no tone written |
tone |
right syllable, wrong tone |
wrong |
wrong syllable |
missing |
a syllable of the reading that was not typed |
extra |
a syllable typed that the reading does not use |
The point is being fair in the ways a string comparison cannot be. All of these are a learner being marked wrong for being right, and all of them pass:
check(dictionary, "北京", "bei3jīng").isCorrect; // true, either notation, mixed
check(dictionary, "行", "háng").isCorrect; // true, the decoder was guessing too
check(dictionary, "你好", "ní hǎo").isCorrect; // true, said with sandhi
check(dictionary, "不是", "bù shì").isCorrect; // true, written without it
check(dictionary, "海鸥", "hǎiōu").isCorrect; // true, an apostrophe is not a sound
check(dictionary, "我的书", "wǒ de shū").isCorrect; // true, pinyin marks no neutral toneA guess is only forgiven where the library was guessing: 行 alone is chosen by a
prior and nothing more, whereas 银行 settles both its syllables, so yínxíng is
a real mistake. See confidence.
Two axes an exercise may or may not be teaching yet, each reported always and each counted only when asked for.
Tones left off are toneless rather than tone, because Syllable.tone knows
the difference:
check(dictionary, "北京", "bei jing").isCorrect; // true
check(dictionary, "北京", "bei jing", { tones: "required" }).isCorrect; // falseWord spacing is graded on spacing, separately from the syllable's own verdict,
because it is a separate mistake: yín háng reads 银行 perfectly and writes it
as two words.
const split = check(dictionary, "银行", "yín háng");
split.syllables.map((one) => one.spacing); // ["correct", "split"]
split.isCorrect; // true, spacing is not counted by default
check(dictionary, "银行", "yín háng", { spacing: "required" }).isCorrect; // falseIt is split for a word written as two and joined for two written as one, and
it is tolerant in the same spirit as everything else — 分词连写 and the words the
dictionary knows are two conventions this package writes, and a learner may have
been taught either:
const graded = { spacing: "required" } as const;
check(dictionary, "他看了", "tā kànle", graded).isCorrect; // true, 分词连写
check(dictionary, "他看了", "tā kàn le", graded).isCorrect; // true, the words
check(dictionary, "干干净净", "gāngān jìngjìng", graded).isCorrect; // true, a hyphen is a boundary
check(dictionary, "我要去北京。", "wǒyàoqùběijīng", graded).isCorrect; // falseTakes every convert option besides, so an exercise that knows which sense of 长
its own sentence uses can say so with readings. More in
checking.
const entry = dictionary.lookup("头发");
entry?.reading; // [{ initial: "t", final: "ou", tone: 2 }, { initial: "f", final: "a", tone: 5 }]
entry?.isProperNoun; // false
entry?.partOfSpeech; // "n", jieba's tag
dictionary.lookup("頭髮")?.reading; // the same reading, found under 繁體
dictionary.lookup("重複")?.reading; // 重複 and 重覆 are both keys for 重复
dictionary.hasPrefix("银"); // true, does any word start with this?
dictionary.readingsOf("行"); // xíng, háng, héng, hàng, likeliest firstBoth scripts are keys in the same dictionary, so nothing is converted before a lookup.
The syllable layer needs no dictionary and no network.
import { isSyllable, readSyllable, writeSyllable } from "@kensio/pinyinjs";
readSyllable("jiù"); // { initial: "j", final: "iou", tone: 4 }
readSyllable("jiu4"); // the same, both notations parse
readSyllable("lv4"); // { initial: "l", final: "ü", tone: 4 }
readSyllable("hello"); // undefined
readSyllable("běi3"); // undefined, one notation at a time
isSyllable("wánr"); // true, 儿化 is a suffix, not a syllable of its ownInitials and finals are the underlying forms rather than the spelling, so 就 is
j + iou and 军 is j + ün. Spelling is reconstructed on demand:
const jiu = { initial: "j", final: "iou", tone: 4 } as const;
writeSyllable(jiu); // "jiù"
writeSyllable(jiu, "numbers"); // "jiu4"
writeSyllable(jiu, "superscript"); // "jiu⁴"
writeSyllable(jiu, "none"); // "jiu"Input takes either notation, the v and u: conventions for ü, and raised tone
digits. Output is standard diacritics unless asked otherwise.
Parsing answers whether a spelling is well formed, not whether Mandarin uses
it: shong parses and is not a real syllable. The attested inventory is
separate:
import { ATTESTED_SYLLABLES, DICTIONARY_SYLLABLES } from "@kensio/pinyinjs";
DICTIONARY_SYLLABLES.has("shong"); // false
DICTIONARY_SYLLABLES.has("zhuang"); // true
ATTESTED_SYLLABLES.length; // 415import { readWord, splitSyllables } from "@kensio/pinyinjs";
splitSyllables("nǐhǎo"); // ["nǐ", "hǎo"]
splitSyllables("Xī'ān"); // ["Xī", "ān"]
splitSyllables("yinhang"); // ["yin", "hang"]
splitSyllables("guórén"); // ["guó", "rén"], not ["guór", "én"]
splitSyllables("hǎiōu"); // ["hǎi", "ōu"], missing apostrophe, read anyway
readWord("yínháng"); // the same, parsed into Syllable objectsimport {
applyToneMark,
NEUTRAL_TONE,
stripToneMarks,
toneFromMarks,
} from "@kensio/pinyinjs";
applyToneMark("hao", 3); // "hǎo"
applyToneMark("hao", NEUTRAL_TONE); // "hao"
stripToneMarks("hǎo"); // "hao"
toneFromMarks("hǎo"); // 3Syllable.tone is Tone | undefined, and undefined is not the neutral tone: the
de in 我的 is neutral (5), whereas the bei in a typed beijing has no tone
written at all.
Reading a number needs no dictionary, just arithmetic and about twenty readings, so this works with nothing loaded.
import { numeralHanzi, percentHanzi, readNumeral } from "@kensio/pinyinjs";
numeralHanzi(12345); // "一万两千三百四十五"
numeralHanzi(1005); // "一千零五", a skipped place is spoken
numeralHanzi(2000); // "两千", a leading lone 2 before a big unit
numeralHanzi(2, { counts: true }); // "两", as in 两个西瓜, never 二个
percentHanzi(95); // "百分之九十五", the order reversesThe same digits are read two ways and nothing in the number says which, since 2026年 is spelled out and 2026个 is counted, so the style is the caller's:
numeralHanzi(2026); // "两千零二十六"
numeralHanzi(2026, { style: "digits" }); // "二〇二六"
readNumeral(110, { style: "digits", yao: true }); // yāo yāo língMore in numbers.
Also dictionary-free: a romanisation is a mapping over about 420 syllables, so hanzi → Wade-Giles is hanzi → pinyin → Wade-Giles.
import {
readSyllable,
readWadeGilesLoosely,
writeBopomofo,
writeGwoyeu,
writeIpa,
writeWadeGiles,
writeYale,
} from "@kensio/pinyinjs";
const jiu = readSyllable("jiù");
writeBopomofo(jiu); // "ㄐㄧㄡˋ"
writeWadeGiles(jiu); // "chiu⁴"
writeYale(jiu); // "jyòu"
writeGwoyeu(jiu); // "jiow"
writeIpa(jiu); // "tɕiou˥˩"Gwoyeu Romatzyh is the odd one, and it needs no tone mark because the tone is spelled into the syllable, which is why 陝西 is Shaanxi in English and 山西 is Shanxi:
const gr = (pinyin: string) => writeGwoyeu(readSyllable(pinyin));
[gr("shān"), gr("shán"), gr("shǎn"), gr("shàn")]; // shan, sharn, shaan, shannReading Wade-Giles back gives an array, because real text drops the apostrophes and diacritics that carry the distinctions:
readWadeGilesLoosely("chi¹"); // [jī, qī]: chi is jī, ch'i is qī
readWadeGilesLoosely("chu¹"); // [zhū, chū, jū, qū]Measured over the phrase corpus, 52.07% of written syllables have a Wade-Giles spelling that merges with another once its marks are dropped, and taking the first candidate recovers 79.05% of them. More in romanisation.
The dictionary stores underlying tones, and sandhi is applied to the syllable array, so it can be switched off and works across word boundaries.
import { applySandhi, readWord } from "@kensio/pinyinjs";
const buShi = readWord("bùshì") ?? [];
applySandhi(buShi); // bú shì, 不 flattens before a fourth tone
applySandhi(buShi, { yiBu: false }); // unchanged
const niHao = readWord("nǐhǎo") ?? [];
applySandhi(niHao); // unchanged by default
applySandhi(niHao, { thirdTone: true }); // ní hǎoThird-tone sandhi is off by default: standard orthography writes 你好 as
nǐ hǎo even though it is said ní hǎo. Turn it on for transcribing speech.
Its domain is the prosodic foot rather than the syllable string, so 展览馆 is
zhánlánguǎn and 纸老虎 zhǐláohǔ, and 老板很好 keeps the 板 that a
left-to-right scan would lower.
Three axes, not one. Taiwan writes 繁體 with zh-TW readings, but mainland
editions of classical texts use 繁體 with zh-CN readings, and Singapore uses
简体.
| Axis | Values | What differs |
|---|---|---|
| Script | Hans / Hant |
which characters are written |
| Glyph forms | TW / HK |
which 繁體 standard writes them |
| Locale | zh-CN / zh-TW |
how they are read |
Both scripts are dictionary keys, so only the locale is an option to pass. Hong Kong's 繁體 forms are recognised on the way in, so 羣眾 and 麪包 read exactly as 群眾 and 麵包 do.
toScript converts between the scripts, and the reading is what makes it
accurate. Simplification merged distinct characters; the reading un-merges them.
import { loadScriptTables, toScript } from "@kensio/pinyinjs";
const tables = await loadScriptTables(source);
toScript(dictionary, tables, "我们后来发现了头发问题", { to: "zh-Hant" });
// "我們後來發現了頭髮問題"
toScript(dictionary, tables, "头发", { to: "zh-Hant" }); // "頭髮", tóufà
toScript(dictionary, tables, "出发", { to: "zh-Hant" }); // "出發", chūfā发 is 發 or 髮 and nothing about the character says which. Every other converter works from phrase tables alone, so it is right about the words on the list and guessing past it.
It runs both ways — 乾燥 is gānzào and simplifies to 干燥, while 乾隆 is
Qiánlóng and stays 乾隆 — and it targets a region, because there is no
region-free 繁體:
toScript(dictionary, tables, "面包", { to: "zh-Hant-TW" }); // "麵包"
toScript(dictionary, tables, "面包", { to: "zh-Hant-HK" }); // "麪包"toScriptPieces reports what settled each character, and which were guesses:
下面 is a surface or a bowl of noodles, both xiàmiàn, and it says so rather
than picking silently. The tables load separately from the dictionary, so
converting hanzi to pinyin costs nothing for them.
This is orthography and not translation — 软件 becomes 軟件, never 軟體. See script conversion.
| Function | Does |
|---|---|
loadDictionary(source, tier) |
load a dictionary from fileSource/fetchSource |
convert(dictionary, text, options?) |
hanzi → pinyin |
convertPieces(dictionary, text, ...) |
the same, per syllable, with confidence |
convertToHtml(dictionary, text, ...) |
the same, as HTML |
convertToAnnotatedHtml(dictionary, text, ...) |
hanzi and pinyin together, as ruby HTML |
segment(dictionary, text) |
split text into words |
match(dictionary, haystack, query) |
where a pinyin query matches a text |
candidates(index, query, options?) |
pinyin → the hanzi it could be |
homophonesOf(index, word, options?) |
the words read exactly like this one |
ReverseIndex.of(dictionary) |
derive the reading → words index |
check(dictionary, text, typed, options?) |
mark typed pinyin against the text |
slug(dictionary, text, options?) |
hanzi → a URL-safe slug |
joinPieces(pieces) / toHtml(pieces) |
render pieces |
isUncertain(confidence) |
was this syllable a guess? |
dictionary.lookup / hasPrefix / readingsOf |
query the dictionary |
readSyllable / writeSyllable / isSyllable |
one syllable, no dictionary |
splitSyllables / readWord |
split written pinyin |
applySandhi |
一, 不 and optional third-tone sandhi |
writeBopomofo / writeWadeGiles |
one syllable, romanised |
writeYale / writeGwoyeu / writeIpa |
the same, in Yale, GR and IPA |
readBopomofo / readWadeGilesLoosely |
and back again |
readYale / readGwoyeu / readIpa |
and back from those three |
applyToneMark / stripToneMarks / toneFromMarks |
tone marks |
convertGreedily(...) |
the old longest-match decoder, kept as a baseline |
Types (Syllable, Tone, ConvertOptions, ConvertedPiece,
ReadingConfidence, HtmlOptions, Segment, PinyinMatch, MatchRange,
ReadingConfidence, HtmlOptions, Segment, PinyinCheck, CheckedSyllable,
WordEntry, Tier, Locale, Script) are exported alongside them.
pnpm install
pnpm check # format, complexity, build, typecheck, test with coverage
./pinyinjs # the CLI, straight from the sources: ./pinyinjs convert 你好
pnpm accuracy # score both decoders against the gold corpus
pnpm polyphones # score them against 20,139 hand-labelled polyphones
pnpm build:data # rebuild data/ from the upstream sourcespnpm check must pass: oxlint including the type-aware rules, tsc with
exactOptionalPropertyTypes and noUncheckedIndexedAccess, and vitest at 95%
coverage thresholds.
Every example in this README is executed by src/readme.test.ts against the
committed dictionary, so the two cannot drift apart. Change them together.
The compiled dictionaries in data/ are committed, so what ships is exactly
what was tested. pnpm build:data fetches the four sources into .cache/
(~32 MB), merges them, runs the build assertions, and rewrites data/ and
NOTICE. It fails rather than warns: no artifact is written unless 儿化 is
repaired both ways, 一 and 不 sandhi is normalised out, every syllable is one
the inventory knows, and every tier reads back exactly as it was built.
| Source | Provides | Licence |
|---|---|---|
| Unihan | character readings, polyphone priors, script variants | Unicode |
| CC-CEDICT | 简体/繁體 pairs, 儿化, neutral tones, Taiwan readings | CC BY-SA 4.0 |
| phrase-pinyin-data | the bulk of the word readings | MIT |
| jieba | word frequencies and part-of-speech tags | MIT |
NOTICE is generated from the same table the pipeline fetches from.
Apache-2.0 AND CC-BY-SA-4.0, because the package is two things: the code is
Apache-2.0, and the compiled dictionaries in data/ are share-alike, CC-CEDICT
being CC BY-SA 4.0. See LICENSE and NOTICE, which is
generated from the sources the pipeline actually fetched.