Skip to contents

Four conversions, four different levels of trust

This vignette covers the transforms that change what characters are there rather than how wide they are. Three of them share an implementation, ICU’s transliterators via stringi (Unicode Consortium 2024; Gagolewski 2022), and the jamo pair applies the Unicode arithmetic directly. They should not be trusted equally.

Conversion Reversible? Uses context? Trust
cjk_jamo() / cjk_compose_jamo() yes, exactly no complete
to_hiragana() / to_katakana() within one syllabary no high
cjk_simplify() no yes good
cjk_traditionalize() no yes good on characters, blind to regional vocabulary
cjk_romanize() no no a key for Chinese; wrong for Japanese kanji

The rest of this vignette is why.

Hangul jamo: exact

A modern Hangul syllable is built from a leading consonant, a vowel and an optional trailing consonant, and Unicode encodes all 11,172 combinations precomposed. The relationship is arithmetic, SIndex = (LIndex * 21 + VIndex) * 28 + TIndex, so the decomposition is exact rather than tabulated: no table can be out of date and no syllable is missed.

cjk_jamo("한글")
#> [[1]]
#> [1] "ᄒ" "ᅡ"   "ᆫ"   "ᄀ" "ᅳ"   "ᆯ"

cjk_jamo() returns a list, one vector of jamo per string, so join each element before composing it back:

back <- vapply(cjk_jamo("한글"), paste, character(1), collapse = "")
cjk_compose_jamo(back)
#> [1] "한글"

The round trip is exact, because both verbs apply the Hangul arithmetic and nothing else. Text that is not Hangul passes through untouched, which is the difference from Unicode normalisation: NFD would split the accented letter below, and NFD and NFC alike would replace the compatibility ideograph with its unified form, which in a Korean column means rewriting Hanja.

cjk_jamo("café")
#> [[1]]
#> [1] "c" "a" "f" "é"
cjk_compose_jamo("豈") == "豈"
#> [1] TRUE

Jamo are the right unit for questions the syllable hides: which initial consonants a corpus favours, or whether two spellings differ only in a final consonant. The same syllable counts three different ways, each right for a different question. 한 is one character to nchar(), two terminal columns to cjk_width(), and three jamo here:

nchar("한")
#> [1] 1
cjk_width("한")
#> [1] 2
lengths(cjk_jamo("한"))
#> [1] 3

Kana: safe, with two things to know

Hiragana and katakana encode the same sounds, so the mapping never depends on the surrounding words.

to_hiragana("カタカナ")
#> [1] "かたかな"
to_katakana("ひらがな")
#> [1] "ヒラガナ"

to_hiragana(to_katakana("ひらがな"))
#> [1] "ひらがな"

That round trip is exact because the input was in one syllabary. On mixed text it is not, and cannot be: converting to a single syllabary erases the distinction between the two, and that distinction carries meaning, because katakana marks loanwords, onomatopoeia and emphasis.

x <- "アか"                      # one katakana, one hiragana
to_hiragana(x)                    # both become hiragana
#> [1] "あか"
to_katakana(to_hiragana(x))       # and cannot be told apart again
#> [1] "アカ"

The second thing is that ICU makes a few choices of its own. The small katakana ヵ and ヶ become the full-size hiragana か and け, and the digraph ヿ (koto) is spelled out as two kana, so even text that was all katakana does not always survive the trip:

to_hiragana("ヵヶ")
#> [1] "かけ"
to_katakana("ヿ")
#> [1] "コト"

So the conversion is safe in the sense that matters, which is that it never guesses from context, but run it in one direction, as a normalisation, rather than expecting to undo it.

Kanji, Latin and punctuation are untouched, so it is safe on a mixed column:

to_katakana("日本語のtext です")
#> [1] "日本語ノtext デス"

This is the normalisation you want before grouping Japanese text, where the same word is often written either way for emphasis.

Halfwidth katakana is handled too, and composed on the way through. ガ is two code points, the kana and the halfwidth voiced mark U+FF9E, and comes back as the single character ガ:

to_hiragana("カタカナ")
#> [1] "かたかな"
to_katakana("ガ")
#> [1] "ガ"
nchar(to_katakana("ガ"))
#> [1] 1

Simplified and traditional Han: right on characters, blind to vocabulary

cjk_simplify("漢字")
#> [1] "汉字"
cjk_traditionalize("汉字")
#> [1] "漢字"

It is better than it looks. Simplified to traditional is genuinely one-to-many (simplified 发 is 發, “to send”, or 髮, “hair”, and 干 is 乾, 幹 or 干), and ICU resolves these from context, not character by character:

cjk_traditionalize(c("头发", "发送"))   # hair, send
#> [1] "頭髮" "發送"
cjk_traditionalize(c("干净", "树干"))   # clean, tree trunk
#> [1] "乾淨" "樹幹"
cjk_traditionalize(c("后天", "皇后"))   # after, empress: 后 splits both ways
#> [1] "後天" "皇后"

All six are right, and so were the fourteen other words checked for this release, including 面条/面对 and 里面/公里.

What ICU does not do is substitute regional vocabulary. The two standards differ in the words they use, not only in glyph shape, and that is lexical:

cjk_traditionalize(c("软件", "鼠标", "网络"))
#> [1] "軟件" "鼠標" "網絡"

Those are the correct characters and the wrong words in Taiwan, where the terms are 軟體, 滑鼠 and 網路. No character mapping can reach that, and it is what OpenCC (Kuo 2024) and its regional configurations exist for. Use these functions for script conversion; reach for OpenCC when you need Taiwanese or Hong Kong idiom.

Romanisation: depends entirely on the language

cjk_romanize("中文")
#> [1] "zhōng wén"
cjk_romanize("中文", ascii = TRUE)
#> [1] "zhong wen"

For Chinese this is pinyin, with tone marks unless you ask for ASCII. ascii = TRUE gives a sortable, greppable key for a CJK column, which is often the real reason to romanise at all.

cjk_romanize(c("こんにちは", "안녕하세요"))
#> [1] "kon'nichiha"    "annyeonghaseyo"

Three caveats, in increasing order of severity.

Han readings are per character. A character with several readings gets ICU’s preferred one regardless of the word it appears in, so common words come back wrong:

cjk_romanize(c("银行", "音乐"))   # bank, music: yín háng and yīn yuè
#> [1] "yín xíng" "yīn lè"

That is fine for a sort key and wrong for pinyin you show a reader.

No boundary between scripts. ICU spaces Han syllables but does not insert a break where the script changes:

cjk_romanize("漢字カナ")
#> [1] "hàn zìkana"

Segment first with cjk_segment() if you need word boundaries in the output.

Japanese kanji come back as Chinese. This is the severe one. ICU routes every Han character through pinyin regardless of the surrounding language, so 日本語 romanises to rì běn yǔ rather than nihongo, and 私 to sī rather than watashi:

cjk_romanize("私は日本語を話します")
#> [1] "sīha rì běn yǔwo huàshimasu"

That is not an approximation of the Japanese reading; it is the Chinese one. The kana in the same string romanise correctly, which makes the output look plausible at a glance and is exactly what makes it dangerous. Note too that the particle は gives ha, which is how it is written rather than how it is said.

For Japanese, use a morphological analyser that carries readings: gibasa binds MeCab and returns them (Kudo et al. 2004; Kato 2025). cjk_romanize() is for Chinese, for kana, and for producing an ASCII sort key.

Width forms are a separate concern

to_halfwidth() and to_fullwidth() change width and nothing else. The usual advice for fullwidth text is an NFKC pass, which does fix width, and also rewrites ligatures, Roman numerals and circled numbers, none of which a change of width asked for. When that wider fold is what you want, it is cjk_normalize() in the next section rather than a side effect here:

as.numeric(to_halfwidth("123"))
#> [1] 123

to_halfwidth("½ Ⅸ ①")   # NFKC rewrites all three; this leaves them
#> [1] "½ Ⅸ ①"

The one composition it does perform is voiced halfwidth katakana, where a single syllable is written as two code points. Composing them is the difference between text that matches a literal and text that silently does not:

x <- "ガ"
nchar(x)
#> [1] 2
nchar(to_halfwidth(x))
#> [1] 1
to_halfwidth(x) == "ガ"
#> [1] TRUE

Normalisation is the fourth axis

Width, script and syllabary are three axes; the code points themselves are the fourth. cjk_normalize() applies the Unicode normalisation forms (Whistler 2026), which is what decides whether two strings that render identically compare equal:

both <- c("が", "が")   # composed, then KA + voiced mark
nchar(both)
#> [1] 1 2
nchar(cjk_normalize(both))
#> [1] 1 1

"nfkc" is the blunt instrument the width verbs exist to avoid, and it is the right tool when matching rather than display is the goal:

cjk_normalize("AB ①", form = "nfkc")
#> [1] "AB 1"
cjk_normalize("AB ①", form = "nfc")
#> [1] "AB ①"

Canonical normalisation is not a no-op on Han. Compatibility ideographs have singleton canonical mappings, so "nfc" rewrites them just as "nfkc" does. This is the part that surprises people who reach for NFC because it is supposed to be the safe one:

block <- vapply(c(0xF900:0xFA6D, 0xFA70:0xFAD9), intToUtf8, character(1))
sum(cjk_normalize(block, "nfc") != block)   # folded away by plain NFC
#> [1] 460
sum(cjk_normalize(block, "nfc") == block)   # and the survivors
#> [1] 12

The twelve survivors are unified ideographs that were encoded in the compatibility block by accident and have no mapping to apply. So the block is neither preserved nor uniformly folded: if the distinction between a compatibility ideograph and its unified form means something in your data, as it can in Korean and Japanese name records, keep the original column.

Variation selectors are the other thing that defeats an exact match, and four of the five forms leave them alone:

ivs <- "辻\U000E0100"
nchar(cjk_normalize(ivs))
#> [1] 2
nchar(cjk_normalize(ivs, drop_variation_selectors = TRUE))
#> [1] 1

They are dropped before the form is applied, not after: a selector has combining class zero and blocks canonical composition across itself, so stripping one afterwards can leave text that is no longer in the form you just asked for.

References

Gagolewski, Marek. 2022. “stringi: Fast and Portable Character String Processing in R.” Journal of Statistical Software 103 (2): 1–59. https://doi.org/10.18637/jss.v103.i02.
Kato, Akiru. 2025. gibasa: An Alternative ’Rcpp’ Wrapper of ’MeCab’. https://CRAN.R-project.org/package=gibasa.
Kudo, Taku, Kaoru Yamamoto, and Yuji Matsumoto. 2004. “Applying Conditional Random Fields to Japanese Morphological Analysis.” Proceedings of the 2004 Conference on Empirical Methods in Natural Language Processing (EMNLP), 230–37.
Kuo, Carbo. 2024. OpenCC: Open Chinese Convert. https://github.com/BYVoid/OpenCC.
Unicode Consortium. 2024. International Components for Unicode. https://icu.unicode.org/.
Whistler, Ken. 2026. Unicode Standard Annex #15: Unicode Normalization Forms. Unicode Standard Annex No. 15. The Unicode Consortium. https://www.unicode.org/reports/tr15/.