Skip to content

Repository files navigation

pinyinjs

Hanzi → pinyin conversion for TypeScript, in Node and the browser. Also parses, validates and writes pinyin syllables on their own, with no dictionary.

https://pinyinjs.dev

Install

pnpm add @kensio/pinyinjs

Node 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.

Command line

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.

Calling it from something else

convert writes the pinyin and nothing else, so it drops straight into a pipeline:

$ pinyinjs convert 银行
yínháng

Everything 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 .pinyin

One 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.

Load a dictionary

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.

Tiers

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

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 not

A 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 own

See converting.

Options

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"

Spacing, capitals and punctuation

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 words

A 成语 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 halved

Digits 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 quantity

numbers: "keep" leaves every digit alone. See numbers.

Syllable by syllable

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 syllables

An 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.

HTML output

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.

Annotated output: hanzi and pinyin together

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.

Segment

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 text

Every 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.

Match a pinyin query

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"); // undefined

Every 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 here

Both 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.

Go the other way: pinyin to hanzi

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 what somebody typed

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.5

Each 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 tone

A 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.

Tones and word spacing

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; // false

Word 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; // false

It 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; // false

Takes every convert option besides, so an exercise that knows which sense of 长 its own sentence uses can say so with readings. More in checking.

Look words up

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 first

Both scripts are keys in the same dictionary, so nothing is converted before a lookup.

Syllables

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 own

Initials 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; // 415

Splitting written pinyin

import { 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 objects

Tones

import {
  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"); // 3

Syllable.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.

Numbers

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 reverses

The 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íng

More in numbers.

Bopomofo, Wade-Giles, Yale, Gwoyeu Romatzyh and IPA

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, shann

Reading 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.

Sandhi

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ǎo

Third-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.

Scripts and locales

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.

Simplified and traditional

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.

API

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.

Development

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 sources

pnpm 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.

Data sources

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.

Licence

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.

About

Chinese hanzi to pinyin conversion in TypeScript

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages