DEV Community

Joe Lin for BeGoodTool.com

Posted on

Why simplified Chinese's 干 becomes three different traditional characters (and how a converter has to guess right)

When I added a Traditional/Simplified Chinese converter to my little text-tools site, I assumed it'd be the easy one — swap a few thousand characters for their counterparts and call it done, same idea as the Unicode font-swap trick behind my Instagram font generator. Then I typed a single character, 干, into the input box and watched it turn into three completely different traditional characters depending on which word it was sitting inside. That's when I actually sat down and read how the conversion library I'd pulled in works, instead of assuming "it's just a lookup table."

It's not a character map — it's a trie walking two dictionaries at once

The tool uses opencc-js, the JS port of the Open Chinese Convert project, and Converter({ from, to }) doesn't hand you one flat map. It picks named dictionary groups — for converting from simplified Chinese, that's a character-level dictionary followed by a phrase-level one, chained in order:

// opencc-js/src/data-config.js
export const variants2standard = {
  cn: ['STCharacters', 'STPhrases'],
  hk: ['HKVariantsRev', 'HKVariantsRevPhrases'],
  tw: ['TWVariantsRev', 'TWVariantsRevPhrases'],
  // ...
};
Enter fullscreen mode Exit fullscreen mode

Each dictionary gets loaded into its own trie, and the tries run in sequence over the text:

// opencc-js/src/main.js
export function ConverterFactory(...dictGroups) {
  const trieArr = dictGroups.map(grp => {
    const t = new Trie();
    t.loadDictGroup(grp);
    return t;
  });
  function convert(s) {
    return trieArr.reduce((res, t) => t.convert(res), s);
  }
  return convert;
}
Enter fullscreen mode Exit fullscreen mode

Inside a single trie, the walk isn't "replace this character" — it's "find the longest matching key starting here, and remember it as you go deeper":

convert(s) {
  const t = this.map;
  const n = s.length, arr = [];
  for (let i = 0; i < n;) {
    let t_curr = t, k = 0, v;
    for (let j = i; j < n;) {
      const t_next = t_curr.get(s.codePointAt(j));
      if (typeof t_next === 'undefined') break;
      t_curr = t_next;
      if (typeof t_curr.trie_val !== 'undefined') { k = j; v = t_curr.trie_val; }
      j += /* advance by 1 or 2 for surrogate pairs */ 1;
    }
    // k holds the end of the longest match found so far — apply it, or fall through
  }
}
Enter fullscreen mode Exit fullscreen mode

So a four-character phrase entry beats a two-character entry, which beats a bare single character, at the same starting position. That's the whole mechanism that makes context-sensitive-looking behavior possible without anything resembling grammar — it's just "prefer the longest known phrase."

The 干 problem, straight from the dictionary files

Here's why I noticed this at all. The single-character dictionary (STCharacters) has exactly one entry for 干:

干 幹
Enter fullscreen mode Exit fullscreen mode

If that were the whole story, every 干 in your text would become 幹 — the "do/work" character. But STPhrases (the phrase-level dictionary loaded right after it) contains lines like these, pulled straight from the bundled data file:

不干 不幹      // "didn't do it" — matches the default
不干净 不乾淨   // "not clean" — 干 → 乾 (dry/clean), not 幹
不干涉 不干涉   // "doesn't interfere" — 干 stays 干, unconverted
不相干 不相干   // "irrelevant" — also stays 干
一干二净 一乾二淨 // "spick and span" — 干 → 乾 again
乳臭未干 乳臭未乾 // idiom, "still wet behind the ears" — 干 → 乾
Enter fullscreen mode Exit fullscreen mode

Three different outcomes for the same input character, entirely dependent on which word it's part of. This isn't the converter "understanding" Chinese — it's a few hundred manually enumerated exceptions layered on top of the single-character default, each one long enough to win the longest-match check before the plain 干→幹 rule ever gets a chance to fire. It's real disambiguation, but it's disambiguation by memorized word list, not by parsing meaning.

Why each button in the UI chains three converters instead of one

The other thing that surprised me reading the component: none of the four conversion buttons call a single direct converter. They chain several:

const toTW = () => {
  let hk = converterHK(converterJP_CN(inputArea.value));
  let res = converterHK_TW(hk);
  outputArea.value = res;
};
const toCN = () => {
  let tw = converterJP_CN(converterHK_TW(inputArea.value));
  let res = converterCN(tw);
  outputArea.value = res;
};
Enter fullscreen mode Exit fullscreen mode

Instead of one dedicated dictionary per every possible (source, target) pair, each button routes the input through a chain of converters that funnel it toward a common intermediate form before pushing it to the actual target. That only works because a trie-based converter is a safe no-op on text it doesn't recognize: if you run already-simplified text through a dictionary whose keys are Hong-Kong-specific traditional variants, none of those keys match, so nothing gets replaced and the text passes straight through. That's what makes it safe to shove text of unknown origin — simplified, Taiwan traditional, Hong Kong traditional, or Japanese kanji — through the same multi-hop pipeline without asking the user what they pasted in the first place.

Where it still gets it wrong

The phrase table is a finite, hand-maintained list, not a model of the language. A rare compound word, a proper name, or a sentence structure nobody added to the dictionary just falls back to the plain per-character rule — so 干 quietly becomes 幹 again in any phrase the maintainers never saw. And because the UI chains converters (as above), a wrong substitution at the first hop becomes the literal input to the next hop; there's no later stage that can notice and correct it. The tool's own intro text is upfront about a related gap too — it flags that Taiwan/Hong Kong vocabulary differences (自行車 vs 腳踏車, "bicycle") aren't fully catalogued and suggests double-checking anything that matters. That admission is a good tell for what this really is: solid dictionary coverage, not linguistic understanding.

I ended up trusting the phrase-dictionary approach more than I expected going in — for everyday text it resolves ambiguous characters correctly far more often than a naive character swap ever could. I still wouldn't paste a contract through it without a native speaker checking the result, though. If you want to try it on your own text, I turned this into a small free tool: Traditional and Simplified Chinese Converter. No sign-up, paste and convert.


Available in other languages

Top comments (0)