Skip to contents

Overview

Almost every text tool in R assumes that a word is a run of characters between two spaces. That assumption is invisible until it fails, and it fails completely on Chinese, Japanese and Korean, because Chinese and Japanese do not put spaces between words at all. Hand 我今天很開心 to a whitespace tokeniser and it returns one token: the whole sentence. Split it per character instead and you get six, which cuts 開心 (“happy”) in half and destroys the very signal you were trying to measure.

The problem is not only segmentation. A CJK character occupies two terminal columns but counts as one character, so nchar() and the width of a printed table disagree, and every console table containing CJK text comes out ragged. Fullwidth digits 123 look like digits and do not parse as a number. A Japanese place name written only in Han characters cannot be told from Chinese by script alone, and most libraries guess anyway.

tidycjk addresses these as separate, honest problems, each built on the Unicode standard (Unicode Consortium 2026a, 2026b, 2026c) and on ICU, the Unicode Consortium’s own library, rather than on hand-written rules:

Task Function(s)
Detect has_cjk(), cjk_script(), cjk_detect_language(), cjk_ratio()
Summarise a column cjk_summary(), cjk_char_counts()
Segment cjk_segment(), cjk_tokens(), cjk_segmenters(), register_cjk_segmenter()
Sentences, n-grams, cleaning cjk_sentences(), cjk_ngrams(), cjk_strip_punct()
Order cjk_sort(), cjk_order()
Measure width cjk_width()
Lay out cjk_pad(), cjk_truncate(), cjk_wrap()
Normalise to_halfwidth(), to_fullwidth() (width), cjk_normalize() (Unicode forms)
Romanise cjk_romanize()
Convert Han cjk_simplify(), cjk_traditionalize()
Convert kana to_hiragana(), to_katakana()
Hangul jamo cjk_jamo(), cjk_compose_jamo()
Reference data cjk_blocks()

Two layers, one contract

Most verbs work on a plain character vector, in the manner of stringr. Three of them, cjk_summary(), cjk_char_counts() and cjk_tokens(), take (data, column) instead and return a tibble, so they drop into a dplyr pipeline without an adaptor (Wickham 2014). Those three want a data frame; they do not accept a bare character vector.

The contract is the same throughout, and it is worth stating once because the rest of this vignette relies on it. Every vector-layer verb is vectorised: NA in gives NA out, zero-length input gives zero-length output, and nothing silently recycles a ragged pair: a mismatch is an error rather than a warning and a short answer.

The tidy verbs answer about the column rather than about each row, so they summarise rather than map. cjk_char_counts() and cjk_tokens() return no rows for an empty column; cjk_summary() always returns exactly one, with NA where there was nothing to average.

posts <- data.frame(
  id = 1:5,
  text = c(
    "我今天很開心",            # Chinese
    "こんにちは、元気ですか",  # Japanese, with kana
    "안녕하세요",              # Korean
    "東京都",                  # Japanese written without kana
    "no CJK here at all"
  )
)

cjk_summary(posts, text)
#> # A tibble: 1 × 4
#>   n_docs n_with_cjk prop_with_cjk mean_ratio
#>    <int>      <int>         <dbl>      <dbl>
#> 1      5          4           0.8        0.8

prop_with_cjk answers “how many of these rows contain any CJK at all”; mean_ratio answers “how CJK are they”. They come apart exactly where you would want them to: a corpus of English with one stray ideograph per row scores near 1 on the first and near 0 on the second.

cjk_char_counts(posts, text)
#> # A tibble: 25 × 5
#>    char  codepoint script   block                      n
#>    <chr>     <int> <chr>    <chr>                  <int>
#>  1 我        25105 han      CJK Unified Ideographs     1
#>  2 今        20170 han      CJK Unified Ideographs     1
#>  3 天        22825 han      CJK Unified Ideographs     1
#>  4 很        24456 han      CJK Unified Ideographs     1
#>  5 開        38283 han      CJK Unified Ideographs     1
#>  6 心        24515 han      CJK Unified Ideographs     1
#>  7 こ        12371 hiragana Hiragana                   1
#>  8 ん        12435 hiragana Hiragana                   1
#>  9 に        12395 hiragana Hiragana                   1
#> 10 ち        12385 hiragana Hiragana                   1
#> # ℹ 15 more rows

Detection, and the value of refusing to guess

cjk_script() reports the dominant script; cjk_detect_language() reports the language.

cjk_script(posts$text)
#> [1] "han"      "hiragana" "hangul"   "han"      NA
cjk_detect_language(posts$text)
#> [1] NA         "japanese" "korean"   NA         NA

Rows one, four and five come back NA. Row five is uninteresting: it has no CJK in it at all. Rows one and four are the point. 東京都 is Tokyo Metropolis, unambiguously Japanese, but it is written entirely in Han characters, and nothing in the script distinguishes it from Chinese. Japanese written without kana and Chinese are the same character set. Any library that answers "chinese" there is guessing, and it will be wrong on Japanese input in a way you cannot detect downstream, because the output looks exactly like a confident correct answer.

tidycjk returns NA. If your corpus is known to be Chinese, ask for the guess explicitly, so that the assumption lives in your script where a reader can see it rather than in a library default where they cannot:

cjk_detect_language(posts$text, han_only = "chinese")
#> [1] "chinese"  "japanese" "korean"   "chinese"  NA

Segmentation

Where a word ends is a fact about a language, not about Unicode, so it needs a dictionary. tidycjk dispatches on an engine name rather than committing to one, and two engines ship with the package.

"icu" is a real word segmenter, using the dictionary-based break iterators that ICU carries (Unicode Consortium 2024) and stringi exposes (Gagolewski 2022):

cjk_segment("我今天很開心", engine = "icu")
#> [[1]]
#> [1] "我"   "今天" "很"   "開心"

我 / 今天 / 很 / 開心: words, not characters. "character" is the dictionary-free baseline, and the contrast shows what a segmenter is for:

cjk_segment("我今天很開心", engine = "character")
#> [[1]]
#> [1] "我" "今" "天" "很" "開" "心"

In a pipeline, cjk_tokens() is the CJK-aware counterpart of tidytext’s unnest_tokens() (Silge and Robinson 2016):

cjk_tokens(posts[1:2, ], text, engine = "icu")
#> # A tibble: 8 × 3
#>      id text                   token     
#>   <int> <chr>                  <chr>     
#> 1     1 我今天很開心           我        
#> 2     1 我今天很開心           今天      
#> 3     1 我今天很開心           很        
#> 4     1 我今天很開心           開心      
#> 5     2 こんにちは、元気ですか こんにちは
#> 6     2 こんにちは、元気ですか 元気      
#> 7     2 こんにちは、元気ですか です      
#> 8     2 こんにちは、元気ですか か

vignette("segmentation", package = "tidycjk") covers the engines, their limits, and how to register gibasa (Kato 2025) or jiebaR.

Sentences and n-grams

Two preprocessing steps that generic tooling gets wrong. A regular expression on [.!?] finds no sentence boundary at all in Chinese, because the terminator is 。:

cjk_sentences("我今天很開心。你呢?天氣很好!")
#> [[1]]
#> [1] "我今天很開心。" "你呢?"         "天氣很好!"

Character n-grams are the dictionary-free baseline for Chinese retrieval and classification. Most words are one or two characters, so bigrams capture the majority of them with no model at all, and they hold up on the names and coinages where a segmenter is least reliable:

cjk_ngrams("中文很好")
#> [[1]]
#> [1] "中文" "文很" "很好"

No gram is formed across whitespace, so nothing straddles two words of a Latin run. Punctuation is a different matter, and cjk_strip_punct() is the step that deals with it. It replaces each mark with a space rather than deleting it, so a bigram cannot span the full stop between two sentences:

cjk_strip_punct("他說(今天)。真好")
#> [1] "他說 今天  真好"

cjk_ngrams(cjk_strip_punct("好。天"))        # nothing spans the stop
#> [[1]]
#> character(0)
cjk_ngrams(cjk_strip_punct("好。天", ""))    # deleting it invents 好天
#> [[1]]
#> [1] "好天"

gsub("[[:punct:]]", "", x) is the spelling to avoid: R’s default engine resolves the class through the C library, so it removes nothing under LC_ALL=C, and perl = TRUE removes nothing from CJK in any locale.

Sorting

sort() reads LC_COLLATE, so the order it gives Han text is a property of the session rather than of the data. These three surnames come out in code point order under C and en_US.UTF-8, in pinyin order under zh_CN.utf8, and in a third order under ja_JP.utf8. Each is defensible; none is reproducible, and that is the trap: the same script sorts a name column differently on a colleague’s machine and says nothing about it.

The fallback order is worth knowing, because it is not arbitrary. Within the base block the unified ideographs are arranged in KangXi radical-stroke order, so code point order really is radical order. It is simply not phonetic, and it stops being radical order across blocks, since every Extension A character sorts before every base-block one, leaving a rare character nowhere near its radical-mates.

Naming the collation is what makes the answer the same everywhere:

x <- c("張", "王", "李")   # Zhang, Wang, Li

x[order(vapply(x, utf8ToInt, integer(1)))]  # code point order, explicitly
#> [1] "張" "李" "王"
cjk_sort(x, locale = "zh")                  # pinyin
#> [1] "李" "王" "張"
cjk_sort(x, locale = "zh-u-co-stroke")      # stroke count
#> [1] "王" "李" "張"

Width, and why your tables are ragged

nchar() counts characters. A terminal counts columns, and Unicode Annex #11 assigns CJK characters two of them (Unicode Consortium 2026a).

labels <- c("中文", "abcd", "日本語")

nchar(labels)
#> [1] 2 4 3
cjk_width(labels)
#> [1] 4 4 6

Pad to a width rather than to a character count and the column lines up:

cat(paste0("|", cjk_pad(labels, 8), "|"), sep = "\n")
#> |中文    |
#> |abcd    |
#> |日本語  |

Truncation and wrapping follow the same rule. Wrapping additionally respects the Unicode line breaking algorithm (Unicode Consortium 2026b), so it will not put a break before a closing bracket or a full stop:

cat(cjk_wrap("我今天很開心,因為天氣非常好而且朋友來看我", 12))
#> 我今天很開
#> 心,因為天氣
#> 非常好而且朋
#> 友來看我

vignette("width-and-layout", package = "tidycjk") goes further.

Normalisation and transliteration

to_halfwidth() narrows fullwidth forms and does only that, unlike an NFKC pass, which also rewrites ligatures, Roman numerals and circled numbers:

as.numeric(to_halfwidth("123"))
#> [1] 123
to_halfwidth("½ Ⅸ ①")   # NFKC would rewrite all three
#> [1] "½ Ⅸ ①"

When the blunt instrument is the right one, for matching rather than display, cjk_normalize() is it, and it also answers the case a reader may not expect: plain "nfc" rewrites most of the CJK compatibility ideographs, because they have singleton canonical mappings.

ga <- c("\u304c", "\u304b\u3099")   # composed, then KA + voiced mark
nchar(ga)
#> [1] 1 2
nchar(cjk_normalize(ga))
#> [1] 1 1
cjk_normalize("AB ①", form = "nfkc")
#> [1] "AB 1"

Romanisation, Han script conversion, kana conversion and Hangul jamo decomposition are all available on the same contract:

cjk_romanize("中文")
#> [1] "zhōng wén"
cjk_simplify("漢字")
#> [1] "汉字"
to_hiragana("カタカナ")
#> [1] "かたかな"
cjk_jamo("한")
#> [[1]]
#> [1] "ᄒ" "ᅡ"   "ᆫ"

Each has limits worth knowing before you trust it. Romanisation reads each Han character on its own, so a word like 银行 (“bank”) gets the wrong reading; simplified/traditional conversion resolves characters from context but does not substitute regional vocabulary. vignette("transliteration", package = "tidycjk") covers each.

What tidycjk is built on

cjk_blocks() exports the block table the whole package rests on, so the definition of “CJK” can be read rather than guessed at:

head(cjk_blocks(), 8)
#> # A tibble: 8 × 5
#>   block                       script      start   end n_codepoints
#>   <chr>                       <chr>       <int> <int>        <int>
#> 1 Hangul Jamo                 hangul       4352  4607          256
#> 2 CJK Symbols and Punctuation punctuation 12288 12351           64
#> 3 Hiragana                    hiragana    12352 12447           96
#> 4 Katakana                    katakana    12448 12543           96
#> 5 Bopomofo                    bopomofo    12544 12591           48
#> 6 Hangul Compatibility Jamo   hangul      12592 12687           96
#> 7 Kanbun                      kanbun      12688 12703           16
#> 8 Bopomofo Extended           bopomofo    12704 12735           32
nrow(cjk_blocks())
#> [1] 35

Nothing here makes a network request, and the package needs no compiled code of its own.

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.
Silge, Julia, and David Robinson. 2016. “Tidytext: Text Mining and Analysis Using Tidy Data Principles in R.” Journal of Open Source Software 1 (3): 37. https://doi.org/10.21105/joss.00037.
Unicode Consortium. 2024. International Components for Unicode. https://icu.unicode.org/.
Unicode Consortium. 2026a. Unicode Standard Annex #11: East Asian Width. Unicode Consortium. https://www.unicode.org/reports/tr11/.
Unicode Consortium. 2026b. Unicode Standard Annex #14: Unicode Line Breaking Algorithm. Unicode Consortium. https://www.unicode.org/reports/tr14/.
Unicode Consortium. 2026c. Unicode Standard Annex #29: Unicode Text Segmentation. Unicode Consortium. https://www.unicode.org/reports/tr29/.
Wickham, Hadley. 2014. “Tidy Data.” Journal of Statistical Software 59 (10): 1–23. https://doi.org/10.18637/jss.v059.i10.