How I Built HSKStory

How to Add Accurate Pinyin to Chinese Text

How to add pinyin to Chinese text with a seven-stage pipeline for segmentation, phrase readings, polyphones, validation, and HTML ruby.

AnthonyAnthonyUpdated 10 min read

Chinese has no spaces between words. That's the first problem. The second is that the same character can have completely different pronunciations depending on context. The third is that even after you get the pronunciation right, displaying it above the text breaks your layout in ways that CSS was never designed to handle.

I built a pipeline that adds accurate pinyin to every story in HSKStory's 140+ story library across HSK 1 through 9. The detailed figures in this guide are frozen March 2026 receipts, not current library totals. The first receipt covers 95 stories and 434,235 annotated segments. Production has two passes: a local CPU pipeline establishes word boundaries and base pinyin, then a separate GPU job applies g2pW corrections without changing those boundaries. Editors review the resulting text and make any remaining corrections before publication.

Why this is hard

Adding pinyin to Chinese text requires solving three problems in sequence:

  1. Word segmentation: Where do words start and end? 大学生活 could be 大学生 + (college student + live) or 大学 + 生活 (college + life). Only context tells you.
  2. Pronunciation disambiguation: Which pronunciation does this character get? alone can be de (grammatical particle), (to obtain), or děi (must). Many Chinese characters are polyphonic.
  3. Display: How do you render pinyin above characters without breaking line spacing, word boundaries, or character alignment?

Most tutorials stop at step 1 and call it done. I had to solve all three across a whole library, where every wrong syllable is a learner reading the wrong sound.

The earlier API approach

My first attempt used DeepSeek's API to segment and annotate text. Send a paragraph, get back word boundaries with pinyin. It worked sometimes.

The approach failed for three practical reasons:

  • Non-deterministic. The same input produced different segmentations on different runs. A story processed again after an edit might segment differently from the original.
  • Fragile. The API would merge curly quotes with adjacent characters, truncate long paragraphs, or return malformed JSON. I accumulated multiple retry and repair scripts to handle the inconsistencies.
  • Expensive and slow. Every new segmentation was an API call with latency and cost.

I needed a reproducible pipeline. Fixed code, models, dictionaries, configuration, and story metadata should produce the same result without depending on an inference API.

The seven-stage base pipeline

The base pipeline uses spacy-pkuseg for neural word segmentation and pypinyin for pronunciation. It preserves whitespace first, then runs seven stages for segmentation and annotation. Deterministic validation and the separate g2pW pass come afterward.

The source pipeline accepts Simplified Chinese. Direct Traditional input is not supported: the current segmenter raises an error for it. HSKStory creates Traditional story variants later from the Simplified source.

Before stage 1: Preserve whitespace

pkuseg strips whitespace, including ideographic spaces used as paragraph indentation in Chinese text. The pipeline splits on whitespace runs first and preserves them as separate tokens. Without that step, the reconstructed text can differ from the source.

Stage 1: Pre-split punctuation

pkuseg's neural model can merge curly quotes, em dashes, ellipses, middle dots, and brackets with adjacent characters. “你好” can become a single token instead of three. The pipeline isolates those marks before segmentation. It leaves fullwidth punctuation such as ,。!? alone because pkuseg already handles it.

Stage 2: Neural segmentation

spacy-pkuseg segments the cleaned text into words. I run it with no global HSK custom dictionary. A narrow per-story dictionary contains only multi-character character names.

Stage 3: Split merged suffixes

pkuseg sometimes fuses a role suffix with the following word. 理发师剪 (barber cuts) can become 理发 + 师剪, with attached to the next verb. The pipeline detects tokens that start with a role suffix character (员/师/者/家/长/生), checks that the token is absent from the HSK and jieba lookup sets, and splits it so the next stage can recombine it correctly.

Stage 4: Smart merge

Adjacent tokens are merged when they form a known compound in the HSK vocabulary or jieba's general dictionary. The merge has a special exception for role suffixes. Without it, 服务 + would not become 服务员 because both parts are individually valid HSK words.

Stage 5: Restore reviewed phrase boundaries

Some phrases can be split into smaller valid words that change the meaning or reading. A short reviewed list restores those phrase boundaries before ghost-word cleanup. This is deliberately narrow. Treating all of CC-CEDICT as a merge authority would recreate broad, greedy segmentation errors.

Stage 6: Ghost word fix

After merging, some compounds exist in jieba's frequency dictionary but not in a translation-capable dictionary. They can produce confusing groupings and empty definition cards in the reader. I call these ghost words. The pipeline splits a multi-character token that is absent from the HSK vocabulary and CC-CEDICT into known jieba parts, when every part has a definition, or into individual characters.

A frozen April 9, 2026, 104-story resegmentation receipt recorded a reduction from 7,977 unique ghost-word types to 342. The remaining types were expected character names. This is a dated result, not a current library count.

Stage 7: Batch pinyin annotation

All words are joined into a full sentence, and pypinyin annotates the sentence at once. Sentence-level annotation matters because rules such as tone sandhi for and depend on the surrounding sounds. The pipeline loads CC-CEDICT phrase readings and then applies curated neutral-tone overrides.

Base-pass context heuristics and overrides

After the seven stages, a small correction pass handles patterns that dictionaries miss. For example, “哗”地冲出来 means "rushed out with a splash," so is de, not (ground). A positional check changes an existing to de when immediately follows a closing curly quote.

Validate the artifact

Valid-looking pinyin can still be attached to the wrong source text. A paragraph is marked invalid unless its segments reconstruct the source exactly. The ruby renderer normalizes pinyin to Unicode NFC, and the alignment validator reports mismatched character and syllable counts or unrecognized syllables. Every pinyin.json also stores a SHA-256 source hash. The release contract rejects stale annotations after an edit, text drift, missing chapters, null paragraphs, and artifacts that have not received the g2pW pass.

These checks prove structural consistency, not linguistic correctness. Before publication, editors run the strict pinyin audit, review its findings, spot-check high-risk polyphonic words in context, and correct remaining errors.

The dictionary paradox

I initially loaded the entire HSK vocabulary as a custom dictionary for pkuseg. This seemed obviously correct: tell the segmenter about the words that matter to learners. The segmentation got worse.

The custom dictionary made pkuseg greedily prefer HSK words, breaking three classes of compounds:

InputExpectedWith HSK DictBug
大学生活大学 + 生活大学生 + 活Greedy match on HSK word 大学生
开开心心开开心心开 + 开心 + 心地Greedy match on HSK word 开心
服务员笑服务员 + 笑服务 + 员笑HSK word 服务 matched, suffix attached to the next word

pkuseg's neural model already knows common words. The custom dictionary did not teach it anything new. It overrode the model's contextual judgment with greedy string matching.

The AABB row describes the raw segmentation problem. The later ghost-word pass may intentionally split forms such as 开开心心 when they are absent from CC-CEDICT, so each remaining token has a usable definition.

The fix was to remove the broad HSK dictionary. The neural model segments freely, then the smart merge pass recombines tokens into known words. HSKStory still injects multi-character character names as a small per-story custom dictionary so names remain intact.

A broader custom dictionary made this segmenter worse because it replaced contextual segmentation with greedy matches. The narrow name dictionary and later merge passes keep the useful exceptions without applying that rule to every HSK word.

The polyphonic problem

pypinyin's phrase dictionaries are strong for known words, but they cannot reliably resolve every standalone polyphonic particle from grammatical context. Getting wrong, for example, means a learner practices the wrong pronunciation.

g2pW: a separate context-aware correction pass

g2pW predicts character pronunciation from sentence context. Production runs it as a separate correction script after the base pinyin.json exists. The script reads the existing word boundaries, changes pronunciation annotations only, and writes the corrected artifact back.

The first March 2026 experiment changed more than 40,000 annotations across the frozen 95-story, 434,235-segment sample. The systematic neutral-tone and rare-reading failures prompted an immediate revert.

The problem was systematic. g2pW changed neutral tones in multi-character words: 头发 tóu fa (hair) became tóu fā, and 眼睛 yǎn jing became yǎn jīng. It also predicted rare readings, such as hàn for the standalone conjunction instead of .

The hybrid fix

g2pW is useful for context-dependent single characters such as , while phrase dictionaries are more reliable for known multi-character words.

Production therefore uses a second pass that preserves the base segmentation:

  1. Run g2pW over the complete sentence for context.
  2. Replace every multi-character token with pypinyin's phrase-level result, including CC-CEDICT and neutral-tone overrides.
  3. Keep g2pW's result for single-character tokens.
  4. Apply explicit corrections for known model and dictionary errors, followed by the final context heuristic.

An explicit override wins when both systems are wrong. For example, 大夫 is dài fu for "doctor," not dà fū.

In the frozen March 15, 2026 receipt, the hybrid pass made 16,222 corrections across 434,235 annotated segments in 95 stories. None of the 13 tracked failure patterns regressed. That result only describes the tracked regression set; it is not a claim of perfect pinyin.

March 15, 2026 audit categoryCorrectionsExample
得 → de (particle)1,883跑得快 pǎo de kuài
地 → de (adverbial)360慢慢地走 mànmàn de zǒu
一 tone sandhi6,373一个 yí gè (not yī gè)
不 tone sandhi665不对 bú duì (not bù duì)

Rendering pinyin with HTML ruby

After solving segmentation and pronunciation, I still had to display pinyin above characters using HTML <ruby> tags.

Bug 1: invisible character spacing

Each character gets its own <ruby> tag for per-character pinyin annotation. The browser sizes each ruby box to whichever is wider: the base character or the pinyin above it. Pinyin like chuāng is physically wider than the character . This forces the ruby box wider, creating visible gaps between characters within the same word.

The first instinct is to hide pinyin with opacity: 0, but opacity keeps the element in layout. The invisible pinyin still widens the ruby box.

The fix for hidden pinyin is display: none on <rt> (removes it from layout entirely, zero spacing impact).

For visible pinyin, I initially collapsed the annotation with width: 0; overflow: visible on <rt> so it would not widen the ruby box. That failed because width is not a reliable control for ruby internal boxes under the CSS Ruby specification. Chromium ignored the trick, while WebKit let wide annotations spill sideways and overlap. Natural ruby sizing fixed the WebKit reproducer. The tradeoff is small gaps around wide annotations.

Bug 2: adjacent words blend together

Natural ruby sizing prevents collisions, but it does not make Chinese word boundaries visible. Rendering every character as an undifferentiated run can make adjacent pinyin words look like one long string.

HSKStory keeps one <ruby> per character, then wraps all characters from the same segmented word in a .pw span:

<span class="pw">
  <ruby data-tone="1">深<rt>shēn</rt></ruby><ruby data-tone="4">圳<rt>zhèn</rt></ruby>
</span>
rt {
  font-size: 0.5em;
  letter-spacing: 0.02em;
  white-space: nowrap;
  text-align: center;
}

.pw {
  margin-inline-end: 0.08em;
}

That 0.08em margin belongs between segmented words, never between every Chinese character. The optional data-tone attribute lets the reader color tones without giving up per-character alignment.

The rendered <rt> can also carry occurrence-level meaning. The word lookup reads the pinyin from the tapped occurrence before choosing a dictionary entry, so the second 东西 in a paragraph can resolve to dōng xi (thing) even if an earlier occurrence is dōng xī (east and west).

Bug 3: punctuation double-spacing

Chinese fullwidth punctuation (!?,。) contains glyph-internal spacing. Next to quotes or other punctuation, that can look like a double space. The reader uses the OpenType chws feature as a broad fallback:

article.prose {
  font-feature-settings: "chws" 1;
}

The text-spacing-trim syntax and browser support have changed while CSS Text Level 4 has developed. Check the current specification and the browsers you support before adding it. The more aggressive halt feature forces half-width punctuation even where full spacing is desirable.

Browser CSS is not Kindle CSS

This layout is proven in the browser engines HSKStory tests, not in every EPUB reader. Amazon documents <ruby>, <rt>, and ruby-specific CSS under Enhanced Typesetting, but conversion and device rendering can still produce different spacing. Test a minimal EPUB in Kindle Previewer and Amazon's publishing preview flow; Amazon warns that sideloading does not accurately preview Enhanced Typesetting. HSKStory's Kindle export puts pinyin in footnotes and glossaries rather than inline ruby, so I do not present the browser spacing above as a proven Kindle recipe.

Frozen audit numbers

These figures are historical receipts, not a recount of the current 140+ story library:

  • March 15, 2026, 95-story audit: 16,222 corrections across 434,235 annotated segments (3.74%)
  • April 9, 2026, 104-story resegmentation receipt: unique ghost-word types reduced from 7,977 to 342
  • March 15, 2026 regression set: none of 13 tracked failure patterns regressed

The base segmenter has no per-call API dependency and runs locally on CPU. The g2pW correction is a separate GPU workload, so it still has compute cost. With fixed code, model files, dictionaries, configuration, and story metadata, the same source produces the same word boundaries and pronunciations.


Read stories with pinyin at every level: HSK 1 · HSK 2 · HSK 3 · HSK 4 · HSK 5 · HSK 6 · HSK 7 · HSK 8 · HSK 9

Related guides:

Frequently Asked Questions

How do you add pinyin above Chinese characters?

Use a pipeline that segments Chinese text into words, looks up phrase pronunciations, resolves polyphones from context, validates character-to-syllable alignment, and renders HTML ruby. Python tools such as pypinyin, jieba, and pkuseg provide the building blocks. Deterministic checks catch structural errors, while editors review and correct the finished text before publication.

What are polyphones and why do they matter for pinyin?

Polyphones are Chinese characters whose pronunciation depends on word and sentence context. For example, 了 can be 'le' or 'liǎo', while standalone particles such as 得 and 地 require grammatical context. A good pinyin pipeline needs both word boundaries and sentence context, not character-level lookup alone.

Which tools convert Chinese text to pinyin?

pypinyin converts Chinese characters to pinyin, while tools such as jieba and pkuseg find word boundaries. HSKStory's production pipeline uses neural segmentation, dictionary-aware merge and split passes, phrase-level pronunciation dictionaries, and sentence-context disambiguation. A broad segmenter custom dictionary can make boundaries worse, so custom entries are reserved for story-specific names.

Does HTML ruby render the same in browsers and Kindle EPUBs?

No. Kindle Enhanced Typesetting supports ruby markup, but EPUB conversion and device renderers can space annotations differently from browsers. Test the converted book in Kindle Previewer and Amazon's publishing preview flow before choosing a spacing rule; sideloading does not accurately reproduce Enhanced Typesetting.